A customer paid, their card was charged, and the receipt page came back blank because it couldn’t find their registration. Nothing threw. The gateway had done its job and our page misreported it.

This was a registration flow that supports two card processors, Stripe and Authorize.Net. The post-payment landing page started life as a Stripe handler. When we bolted Authorize.Net on by copying that handler, the copy was wrong in a way that doesn’t show up in a unit test and barely shows up in staging. It only shows up when a real customer pays.

The fix was a deliberately separate handler that models a different flow shape, rather than a tweak to the shared one. The two gateways disagree on when the money moves, and one page shouldn’t try to smooth that over.

The two flow shapes

Strip away the SDKs and the gateways disagree on one thing: who confirms the charge, and when.

Stripe (post-back to capture). The user comes back to your landing page, and you make a server-side call to Stripe to capture or confirm the payment. The redirect is only the user arriving while the charge still waits on your confirmation. Your page is an active participant in completing the transaction. If your handler doesn’t fire that post-back, the charge sits there uncaptured.

Authorize.Net Direct Post Method (already done before the redirect). With DPM, the gateway POSTs the result of the transaction back to a URL you own, as application/x-www-form-urlencoded. By the time the user’s browser lands on your thank-you page, the charge has already been processed. There is no server-to-server call left to make. Your job is to read the fields the gateway echoed back, decide success or failure, and record it.

Same page in the user’s eyes, opposite contract underneath. Stripe says “We’ll redirect you, now you finish it.” Authorize.Net says “We already finished it, here’s what happened, write it down.”

Copy the Stripe handler and you inherit the assumption that the page still has work to do to make the charge real. With DPM that assumption is false, and acting on it is how you end up trying to re-process a transaction that already settled.

Why the copy fails without an error

The copied handler runs. It just runs against the wrong mental model, and a few things go sideways at once.

  • The “still processing, please wait” copy that makes sense while a Stripe capture is in flight is nonsense for Authorize.Net, where the charge is already done.
  • The success/failure branch keys off the wrong signal, because the two gateways don’t report status the same way.
  • The identifier used to find the registration is different per gateway, so the lookup that worked for one silently finds nothing for the other.

None of that surfaces as an exception. It surfaces as a customer staring at a page that can’t find their registration.

What the Authorize.Net handler needs

The DPM handler is small, and that’s the point. It reads what the gateway told us and persists it, with no capture step. This is the sequence we ended up with:

  1. Read the echoed fields off the form. Authorize.Net hands back a set of x_-prefixed fields. The three that matter here are x_response_code, x_trans_id, and x_invoice_num.

  2. Bail out if this isn’t an Authorize.Net post. If there’s no transaction id on the form, the request did not come from the gateway. Return without touching anything. The same landing URL gets hit by other flows, and a stray request must not start mutating registration rows. One guard at the top: no trans id, return.

  3. Branch on the gateway’s own success signal. For DPM, a successful transaction is x_response_code == "1". Anything else is a decline or error, and the user is redirected to a card-error path instead of being shown a confirmation. This is the line that goes wrong when a Stripe handler is copied, because Stripe doesn’t encode success as "1" in a form field.

  4. On success, record it. Update the registration row: mark it paid, store the gateway’s transaction id, set the status to an approved-credit-card state, then fire the confirmation email. No capture call, because there is nothing left to capture.

The comment we left in the code is one line:

FYI - Stripe needed to do a post back to process the transaction, Authorize.net does not need to do this.

That sentence is the entire architectural delta between the two handlers. Everything else follows from it.

The fallback that hid the bug

The identifier problem bit us in the same incident, and we made it worse with a “safety net.”

Authorize.Net DPM echoes back both x_trans_id and x_invoice_num, and it is easy to assume either one identifies the thing you care about. They mean different things:

  • x_trans_id is Authorize.Net’s transaction id. It belongs in whatever column stores the processor’s reference. It is not your registration id.
  • x_invoice_num is the value you sent into the transaction as the invoice number. In our case that was the registration id, so this is the field the registration is looked up by.

The code we had OR’d them together: look up by x_trans_id || x_invoice_num. It worked in test, because the test data lined up. It failed in production the instant the two diverged, and every receipt page silently failed to find the registrant. The fallback hid which field was actually populated and turned a config mismatch into a silent prod failure.

The repair was to decide which field carries our id, look up by exactly that, store the gateway’s id in its own column, and leave a comment so the next person doesn’t re-merge them.

One more guard belongs here. Anything off x_invoice_num arrives on a public, unauthenticated endpoint, because the gateway POSTs to a URL anyone can hit. Coerce it to a number, reject non-numeric and non-positive values, and interpolate the number, never the raw string, before it touches a query. It’s a one-line check, and it separates a trusted gateway callback from an injection vector we would have reopened without noticing.

What to check before you copy a handler

For each payment gateway, find out whether the charge is completed before the customer returns to your page or only after your page confirms it. A page copied from one gateway to another can run without errors and still fail to record a paid registration. Test with a real payment, and confirm which identifier the record is looked up by.