---
title: "Shipping a Throwaway Relay Page to Debug a Payment Integration in Prod"
canonical: https://dxdev.com/blog/throwaway-relay-page-debug-payment-callback-in-prod/
datePublished: 2026-01-28
---
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`:

```js
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:

```js
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](x-trans-id-not-invoice-number-authorize-net-direct-post): the specific field confusion the relay page revealed
- [Seven production releases in one day: the case for tiny, tagged, revertable hotfixes](many-tiny-tagged-hotfixes-beat-one-big-release): the release discipline that kept the production debugging loop reversible
