Most developers have never seen a production payment flow written in server-side JScript on classic ASP. Mine processes real credit-card registrations right now, in 2026, under IIS, on Windows, against MSSQL. The top of the file reads <%@LANGUAGE="JAVASCRIPT" CODEPAGE="65001"%>. The page logic hangs off Page.prototype.*. The HTML comes out of a hand-rolled server-side DOM builder, not a template string. And the database writes go through an ORM-lite I built in-house. This is OOP JavaScript that predates Node by a decade, and it took a payment from a customer twenty minutes ago.

I want to walk through what that actually looks like, because the usual reaction to “classic ASP in production” is a laugh, and the laugh skips the interesting part. The stack is old. The craft of keeping it shippable is not optional, and it is not a punchline. It is the same discipline you’d want on anything that touches money: validate at the boundary, keep changes atomic, make implicit schema explicit, and prefer request context over re-querying. The payment incident behind these examples ran from the first commit at 08:06 to the last at 20:52 and produced seven tagged production releases that day. It is a useful X-ray of the whole stack.

The shape of a page

A “page” here is a prototype-based JScript class. You hydrate it for the request, hang methods off the prototype, and call them in order:

Page.prototype.RegistrationInit = function() { ... }

Includes are pulled the classic-ASP way, with <!--#include virtual="/src/...-s.asp">. There’s no framework underneath in the modern sense. There’s a convention: the page object is the request, methods mutate it, and by the time you render, the object holds everything you need. That last part matters more than it sounds, and I’ll come back to it.

Rendering does not use template strings. It builds a server-side tag tree:

this.addTag("td", parentRow, {cssclass:"DataColInfo1R"}, value);

You construct the receipt the way you’d build a DOM in the browser, except it’s running server-side in JScript and emitting HTML. It’s readable. It’s also verbose. When I rebuilt one receipt page during the firefight, the diff was north of 800 lines for what is, conceptually, one table.

The DB helper: convention over config, in 2003-flavored JScript

Writes go through an in-house helper. You set fields on an object and call a command:

var db = new DBObj();
db.table = "dbo." + sport + "registration";
db.action = "UPDATE";
db.whereNums["registrationID"] = registrationIDNum;
db.whereStrs["username"] = username;
db.numbers["gatewayConfirmed"] = 1;
db.strings["gateway_transaction_id"] = transID;
db.strings["status"] = "Approved (credit card)";
db.run();

Reads go the other direction through a single accessor:

getRecord("gatewayConfirmed, formID, datestamp, firstName",
"dbo." + sport + "registration",
"registrationID = " + registrationIDNum);

Notice the table name is string-built from the sport, so one code path serves multiple sports by templating the table it talks to. That’s a pattern you either love or fear, and on a long-lived multi-sport app it’s genuinely load-bearing. There’s also a DRY_RUN = true; flag at the top of write-heavy files, which a maintenance tool can flip to run a plan pass that logs every row it would touch without touching anything. Cheap, and it has saved me from myself.

So that’s the stack. Keeping it shippable is the actual engineering.

The bug that proves the discipline

Authorize.Net’s Direct Post Method POSTs the payment result back to your URL as form-encoded fields, all prefixed x_. You render the receipt from those fields. No server-to-server callback (contrast Stripe, which on this same page does need a post-back to confirm). The trap is which field carries your identifier versus the gateway’s.

The original receipt looked up the registration by x_trans_id. That is Authorize.Net’s own transaction ID. The registration ID had actually been sent, and echoed back, in x_invoice_num. Worse, the old code OR’d them together:

this.gatewayPost["x_trans_id"] || this.gatewayPost["x_invoice_num"]

which happened to work in test and silently failed in prod, because the fallback hides which field is actually populated. Every receipt page failed to find the registrant, and nobody could tell you why from the code, because the || made both paths look equally plausible. The fix was to read only the field that carries the value you sent:

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

and store the gateway’s x_trans_id where it belongs, in a dedicated transaction-ID column. The lesson I wrote into the file, and the one I’d hand to anyone integrating a relay-style gateway: write down which echoed field is yours and which is theirs, and never || them as a safety net. The “safety net” is the bug.

Public endpoint, numeric boundary

Mid-fix, I noticed the receipt was building "registrationID = " + registrationID where registrationID came straight off the POST body. It’s a gateway callback, sure, but the relay URL is public. Nothing stops anyone from POSTing arbitrary x_invoice_num to it. That’s a string concatenated into a WHERE clause from an unauthenticated request.

This codebase predates parameterized-query discipline being everywhere, and rewriting the data layer mid-incident is not the move. The realistic, correct move is to validate and coerce at the boundary:

var registrationIDNum = Number(registrationID);
if (isNaN(registrationIDNum) || registrationIDNum <= 0) {
this.hasError = true;
this.errorMessage = "Payment cannot be confirmed. Contact the site administrator.";
return;
}
// ...interpolate the number, not the raw string:
// "registrationID = " + registrationIDNum

One coerce, one bounds check, and the number goes into the query instead of the string. That’s a one-line guard that closes an injection vector you didn’t realize you’d reopened. “It’s just the gateway calling us” is exactly the assumption that makes you skip it.

Prefer the context object over a fresh query

The receipt’s merchant line was running a fresh SELECT displayName FROM ... WHERE username = '...', wrapped in a try/catch whose catch just fell back to the raw username. The fix deleted the query. The framework had already hydrated the page object with the account identity for this request, so the value was sitting right there:

merchantName = page.displayName || page.username || dbRow["username"] || gatewayPost["x_company"];

Two things stack here. A try/catch wrapped around a SELECT is usually a smell: it means you’re not confident the query is even valid, so you’re papering over it with a fallback instead of removing it. Beyond that, in a long-lived app with request-scoped page objects, re-querying for something already hydrated is both slower and a fresh chance to query the wrong table. It manifested as a blank merchant name on the receipt rather than an error, because the catch swallowed the failure. Of course it did.

Seven small releases made the incident legible

That single payment bug produced seven tagged production releases on January 28. Seven tags can sound chaotic. In this case, they created the smallest possible rollback and debugging surface. 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.

Each release was one logical step: use invoice, not transaction ID; fix the account lookup; hide a broken parameter. When a regression showed up, it pointed at one diff and one known-good predecessor. Batch all the payment fixes into one large release and you lose that bisect. The manual ceremony was costly, but it was also the part worth automating later. The release unit stayed small even after the mechanics became cheaper.