Most of the bugs I have shipped into a multi-gateway checkout came from the same lie: that the gateways behave the same way. They don’t. They don’t even agree on when the money moves. And the moment your code pretends they do, you get a thank-you page that either double-processes a charge or never records it at all.
I hit this on a registration flow that supports two card processors: Stripe and Authorize.Net. The post-payment landing page started life as a Stripe handler. When I 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 and the receipt comes back blank.
The fix wasn’t a tweak to the shared handler. It was a deliberately separate handler that models a different flow shape. Here’s the difference, and why one page should not try to smooth it over.
The two flow shapes
Strip away the SDKs and the two 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 that brought the user back is not the money moving, it is just 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 “I’ll redirect you, now you finish it.” Authorize.Net says “I already finished it, here’s what happened, write it down.”
If you copy the Stripe handler for Authorize.Net, 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 quietly
The reason this class of bug is nasty is that the copied handler does not throw. It runs. It just runs against the wrong mental model.
A Stripe-shaped handler expects to drive the confirmation. Point it at an Authorize.Net DPM callback 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 Stripe and Authorize.Net don’t report status the same way. And the identifier you use 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 thank-you page that can’t find their registration, while their card was charged just fine. The gateway did its job, and the page misreported it.
What the Authorize.Net handler actually needs
The Authorize.Net DPM handler is small, and that’s the point. It is not a capture flow. It is a “read what the gateway told me and persist it” flow.
Walking the post-payment handler I ended up with:
-
Read the echoed fields off the form. Authorize.Net hands you back a set of
x_-prefixed fields. The three that matter here arex_response_code,x_trans_id, andx_invoice_num. -
Bail quietly if this isn’t an Authorize.Net post. If there’s no transaction id on the form, this request did not come from the gateway. Return without touching anything. The same landing URL gets hit by other flows, and you do not want a stray request to start mutating registration rows. One guard at the top: no trans id, return.
-
Branch on the gateway’s own success signal. For Authorize.Net DPM, a successful transaction is
x_response_code == "1". Anything else is a decline or error, and the user should be redirected to a card-error path, not shown a confirmation. This is the line people get wrong when they copy a Stripe handler, because Stripe doesn’t encode success as"1"in a form field. -
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. That’s the whole job. No capture call, because there is nothing left to capture.
The honest comment I left in the code, for whoever reads it next, 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.
Get your identifiers straight
One trap deserves its own callout because it bit me in the same incident: which echoed field carries your identifier versus the gateway’s.
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 don’t mean the same thing:
x_trans_idis Authorize.Net’s transaction id. It belongs in whatever column you use to store the processor’s reference. It is not your registration id.x_invoice_numis the value you sent into the transaction as the invoice number. In my case that was the registration id. So this is the field you look the registration up by.
The original code OR’d them together as a “safety net”: look up by x_trans_id || x_invoice_num. That happened to work in test, because the test data lined up, and it failed in production the instant the two diverged. Every receipt page silently failed to find the registrant.
Do not || two identifiers together as a fallback. The fallback hides which field is actually populated and turns a config mismatch into a silent prod failure. Decide which field carries your id, look up by exactly that, and store the gateway’s id somewhere else. Write it down in a comment so the next person doesn’t re-merge them.
While you are at it: anything off x_invoice_num is arriving on a public, unauthenticated endpoint. The gateway POSTs to a URL anyone can hit. Coerce it to a number, reject non-numeric and non-positive, and interpolate the number, not the raw string, before it touches a query. That is a one-line guard, and it is the difference between “trusted gateway callback” and “open injection vector you didn’t know you reopened.”
Related
- x_trans_id is NOT your invoice number: an Authorize.Net Direct-Post receipt bug: the identifier confusion that bites immediately after getting the flow shape right
- x_trans_id Is Not Your Invoice Number: An Authorize.Net Direct-Post Bug: alternate angle on the same Authorize.Net field-identity mistake
- The Whole Feature Was Built and Verified in Sandbox, Then Prod Said E00044: CIM Not Enabled: a second Authorize.Net gotcha, gateway features enabled in sandbox that aren’t live in prod
- Shipping a Throwaway Relay Page to Debug a Payment Integration in Prod: using a debug surface to inspect the actual gateway POST before writing the real handler
- Single-currency database, dual-currency checkout: adding CAD without a schema change: another checkout flow built on top of this gateway abstraction layer