A payment gateway only POSTs to public URLs, so we spent a thirteen-hour day with a disposable relay page in production, reading what Authorize.Net actually sent it. We made seven tagged releases between 08:06 and 20:52. Locally we couldn’t reproduce the callback, so we stopped trying to fake it and read the real payload where it lands.

The setup

The production app runs Authorize.Net’s Direct Post Method (DPM). The gateway charges the card, then POSTs the result to your URL as application/x-www-form-urlencoded. You render the receipt from the echoed x_-prefixed fields. There is no server-to-server call to confirm anything. Whatever the gateway decides to send goes to a public endpoint, and your page has to make sense of it.

The gateway will not POST to localhost. You can hand-craft a form body and pretend, but the bug lives in the gap between what you think the gateway sends and what it actually sends. A faked callback reproduces your own mental model, wrong parts included.

So we took the ugly loop: ship a relay page to prod and let the gateway talk to it.

What the git log shows

The work iterated in production because that’s where the POSTs land.

The first commit of the relay work: “making new relay page for testing.” It added a brand-new 350-line relay page whose whole job is to receive the real POST. We tagged it and shipped it.

Then “trying a new receipt page.” That was 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. It went out as the next tag.

The diff keeps the evidence. 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. Nobody plans those files. They show up when you’re moving in prod and won’t burn a bridge behind you.

Once the relay page was capturing real payloads, the bugs fell out fast. Every one of them was something we had guessed wrong.

x_trans_id was never our invoice number

The receipt looked up the order by x_trans_id, the gateway’s own transaction ID. Our order ID was actually echoed back in x_invoice_num, the value we had sent. The original code OR’d them together, x_trans_id || x_invoice_num. That worked in test and failed in prod with no visible symptom: 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 we left in the code says it: “x_invoice_num contains the order ID; x_trans_id contains Authorize.net’s transaction ID and should be stored separately.” You find out which field carries your identifier by reading the field the gateway really populated. The relay page is what let us read it.

The POST body was going straight into SQL

Mid-fix we spotted "orderID = " + orderID, where orderID came directly off postData["x_invoice_num"]. It’s a gateway callback, but the relay URL is public, and 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 made that obvious in a way a faked local call never would.

A blank merchant name hiding 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 and read the account name off the page context object. It falls back through username fields and finally the gateway-echoed company name, in priority order.

A try/catch around a SELECT usually means a fragile query is being papered over. Here it surfaced as a blank merchant name instead of an error, because the catch swallowed the failure.

We also had the handler difference 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, exits without error if it isn’t an Authorize.Net post, redirects on failure, and on success marks the order row as paid and stores the gateway’s transaction ID.

Why the releases stayed small

Each fix went out as its own hotfix. We branched hotfix/X.Y.Z off master, fixed, merged to master, tagged X.Y.Z, then merged the tag back into develop. All by hand. 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.

That discipline is what makes shipping scaffolding to prod survivable. The relay page accrues cleanup debt: the backup rename, the scratch copy and the temp-folder mock all have to come out later, so budget a commit for it. Because every step was an atomic, tagged release, the messy middle never left prod somewhere we couldn’t back out of with one git revert to a tag.

If a gateway can only call a public URL, the callback you fake will repeat your own assumptions, and the real one will show you the field you had wrong.