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:
-
Read the echoed fields off the form. Authorize.Net hands back a set of
x_-prefixed fields. The three that matter here arex_response_code,x_trans_id, andx_invoice_num. -
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.
-
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. -
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_idis Authorize.Net’s transaction id. It belongs in whatever column stores 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 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.
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