A payment gateway only POSTs to public URLs. That one fact is why I spent a thirteen-hour day shipping a disposable relay page to production, watching what Authorize.Net actually sent it, and making seven tagged releases between 08:06 and 20:52. I could not reproduce the callback locally, so I stopped trying to fake it and read the real payload where it lands.
Here’s the setup. The production app runs Authorize.Net’s Direct Post Method (DPM). The gateway charges the card and then POSTs the result back to your URL as application/x-www-form-urlencoded. You render the receipt from those echoed x_-prefixed fields. There’s no server-to-server API call to confirm. Whatever the gateway decides to send, it sends to a public endpoint, and your page has to make sense of it.
You cannot meaningfully reproduce that locally. The gateway will not POST to localhost. You can hand-craft a form body and pretend, but the thing you’re trying to debug is exactly the gap between what you think the gateway sends and what it actually sends. Faking the callback faithfully reproduces your own wrong mental model. It does not reproduce the bug.
So the fastest loop was the ugly one: ship a relay page to prod and let the gateway talk to it.
The commit trail is the story
If you read the git log for that day, you can watch someone iterate in production because that’s where the POSTs land.
First commit of the relay work: “making new relay page for testing.” A brand-new 350-line relay page whose whole job is to receive the real POST. Tagged and shipped.
Then “trying a new receipt page.” An 800-line rebuild of the receipt page, +1360/-29 across 14 files, plus a scratch copy and a backup-by-rename of the relay page. Shipped as the next tag.
The archaeology is right there in the diff. A git mv of the relay page to a backup-suffixed copy so the old page stays reachable. A temp-folder reference mock. A scratch working copy. These are the telltale signs of “iterate fast, keep an escape hatch.” Nobody plans those files. They show up when you’re moving in prod and refuse to burn a bridge behind you.
Once the relay page was capturing real payloads, the actual bugs fell out fast, and every one of them was a thing I would have guessed wrong if I’d kept faking the callback.
What the real payload taught me
x_trans_id is not your invoice number. The receipt was looking up the order by x_trans_id, the gateway’s own transaction ID. The order ID was actually being echoed back in x_invoice_num, the value I had sent. The original code OR’d them together, x_trans_id || x_invoice_num, which happened to work in test and silently failed in prod. Every receipt page failed to find the record.
The fix (“use invoice not txn”) reads orderID from x_invoice_num only, stores x_trans_id in the gateway transaction ID column where it belongs, and gates the whole post-handler on x_invoice_num:
var orderID = String(postData["x_invoice_num"] || "");The comment I left as the lesson, still in the code: “x_invoice_num contains the order ID; x_trans_id contains Authorize.net’s transaction ID and should be stored separately.” You only learn which field carries your identifier by reading the field the gateway actually populated. The relay page is what let me read it.
That same POST body was getting concatenated straight into SQL. Mid-fix, I noticed "orderID = " + orderID where orderID came directly off postData["x_invoice_num"]. It’s a gateway callback, sure, but the relay URL is public. Nothing stops anyone from POSTing an arbitrary x_invoice_num to it. The cheap, correct guard:
var orderIDNum = Number(orderID);if(isNaN(orderIDNum) || orderIDNum <= 0) { this.hasError = true; this.errorMessage = "Payment cannot be confirmed. Contact the site administrator for more information."; return;}// ..., "orderID = " + orderIDNum // was: + orderID (raw string)Numeric-coerce and bounds-check any echoed identifier before it touches a query, even when “it’s just the gateway calling us.” Payment callbacks are public endpoints. Watching the real POST land on a public relay page is also what makes that obvious in a way that a faked local call never would.
The merchant-name lookup was querying the wrong table behind a try/catch. The receipt’s “Merchant” line ran a fresh DB query for the account name wrapped in a try/catch whose catch fell back to the raw username. The account identity was already hydrated on the request-scoped page object the whole time. The fix (“account lookup bug fix”, the last release of the day) dropped the query entirely and read the account name off the page context object, falling back through username fields and finally the gateway-echoed company name in priority order. A try/catch wrapped around a SELECT is usually a flag that you’re papering over a fragile query instead of removing it. It manifested as a blank merchant name rather than an error because the catch swallowed it.
There was also a whole separate handler delta I’d had wrong: Stripe needs a server-side post-back to capture the charge, Authorize.Net’s DPM has already processed it before the redirect. The Authorize.Net handler reads x_response_code off the form, bails quietly if it’s not an Authorize.Net post, redirects on failure, and on success marks the order row as paid and stores the gateway’s transaction ID. The comment I left says the whole architectural delta: “FYI - Stripe needed to do a post back to process the transaction, Authorize.net does not need to do this.” Again, knowing that came from seeing the gateway’s actual behavior, not from the docs in my head.
Why seven small releases instead of one
Seven tagged releases shipped that day, from the first commit at 08:06 to the last at 20:52. The hotfix dance per version was: branch hotfix/X.Y.Z off master, fix, merge to master, tag X.Y.Z, then merge the tag back into develop. By hand, solo. The payoff was that each production change stayed one logical step: use invoice, not transaction ID; fix the account lookup; hide a broken parameter. When a regression appeared, it pointed at one diff and one known-good predecessor. Batch all the payment fixes into one large release and you lose that bisect.
This is the discipline that makes shipping scaffolding to prod survivable. Yes, the relay page accrues cleanup debt. The backup rename, the scratch copy, the temp-folder mock all have to come out later, and you should budget a commit for that. But because every step is its own atomic, tagged, revertable release, the messy middle never leaves prod in a state you can’t back out of in one git revert to a tag.
Related
- x_trans_id is NOT your invoice number: an Authorize.Net Direct-Post receipt bug: the specific field confusion the relay page revealed
- Seven production releases in one day: the case for tiny, tagged, revertable hotfixes: the release discipline that kept the production debugging loop reversible