Every registration receipt on the site was silently failing to find the registrant. Payments went through. The money moved. But the thank-you page couldn’t tell you whose payment it was, because it was looking up the registration by the wrong field, and a fallback was hiding which field that should have been.

It took eight tagged production releases in one thirteen-hour day to chase this down. The fix that actually mattered was deleting half of one line. Here is the part worth keeping.

How Direct Post works, and where the trap is

Authorize.Net’s Direct Post Method does not work like Stripe. There is no server-to-server capture call. The gateway processes the charge, then POSTs the result straight back to a URL on your own server as application/x-www-form-urlencoded, and you render the receipt from those form fields. The fields all come prefixed with x_.

Two of those fields look almost interchangeable and are not:

  • x_trans_id is Authorize.Net’s transaction ID. It belongs to the gateway. You store it so you have a reference back to their system. In our schema that goes in the gateway transaction column.
  • x_invoice_num is the value you sent the gateway when you kicked off the payment. It gets echoed back untouched. We had put the registrationID there.

So the identifier you actually need to find the customer’s row is in x_invoice_num. The gateway’s own ID is in x_trans_id. They are not the same number, they don’t come from the same place, and only one of them maps to a row in your database.

The original receipt code looked it up like this:

var registrationID = String(
this.authNetPostData["x_trans_id"] || this.authNetPostData["x_invoice_num"] || ""
);

Read that || and you can see the whole bug. The code is saying “use the transaction ID, and if that’s empty, fall back to the invoice number.” It treats two unrelated identifiers as names for the same thing, and it treats the fallback as a safety net. The fallback was the opposite of a safety net.

Why it passed in test and broke in prod

This is the part that makes the OR genuinely dangerous, not just sloppy. In the test environment, x_trans_id came back empty or in a shape that didn’t resolve, so the expression fell through to x_invoice_num and found the registration. The receipt rendered. Everything looked correct.

In production, x_trans_id came back populated with a real Authorize.Net transaction ID. The OR short-circuits on the first truthy value, so the lookup grabbed the gateway’s transaction ID and went looking for a registration row with that number as its primary key. There was no such row. The page failed to find the registrant, every time, for every live payment.

The fallback didn’t make the code more robust. It made the code’s behavior depend on which of two fields happened to be populated in a given environment, and it hid that dependency behind an expression that looks like a sensible default. Test exercised one branch. Prod exercised the other. Nothing in between told you the two branches resolved to different identifiers.

The fix

The fix (shipped as a hotfix tag late that evening) was to stop pretending the fields are interchangeable:

// x_invoice_num contains our registrationID;
// x_trans_id contains Authorize.net's transaction ID
// and should be stored separately in the gateway transaction column
var registrationID = String(this.authNetPostData["x_invoice_num"] || "");

Read the registration ID from x_invoice_num and nowhere else. Store x_trans_id separately in the gateway transaction column where it belongs. Gate the entire post-handler on x_invoice_num being present, not x_trans_id, because x_invoice_num is the field that means “this is one of our payments and here is which registration it’s for.”

That comment survived in the code as the actual lesson. If you write down which echoed field carries your identifier versus the gateway’s, the next person debugging this at 8pm doesn’t have to rediscover it from a failing receipt.

There was a related correctness fix in the same change. The receipt’s printed “Invoice Number” line had been preferring the database registrationID over the gateway’s echoed x_invoice_num. Those should agree, but the source of truth for what the customer was actually charged against is the value the gateway echoed back, not what your DB thinks it sent. Flip it to prefer the echoed value.

The card-number formatting trap, while we were in there

One more Direct Post gotcha from the same change, same shape of mistake: assuming you know the format of a field the gateway hands you.

Authorize.Net returns the card number already masked, as XXXX1111. The old formatting helper blindly took the last four characters of whatever string it was given and prefixed ****. So a value that was already XXXX1111 came out as ****1111, which happens to look fine, but a value in any other shape would double-format or mangle. The guard was simple once you knew the input was already masked:

if (cardNum.indexOf("X") == -1 && cardNum.length > 4) {
// only reformat if it isn't already masked
}

If the string already contains an X, the gateway already masked it. Leave it alone. The bug here is the same root cause as the x_trans_id one: a field arrived from a third party in a known shape, and the code guessed at that shape instead of writing down which one was real. When the gateway tells you the format, encode it; don’t infer it.