GoldVault: the anatomy
Payment infrastructure for high-risk merchants. Identity, risk screening, a ledger and fee collection, sitting between a merchant's customers and the merchant's own card processing. It never holds anyone's money.
In production since 2025. Co-founder. I built the engineering and I run it.
Stack Next.js, Postgres with row-level security, Supabase, Vercel, Sentry, Metabase
- 914
- commits, Jul 2025 to Aug 2026
- 95k
- active lines, TypeScript and SQL, archive excluded
- 100
- Postgres functions, shipped as migrations
- 2
- processors behind one adapter, a third slot reserved
- 4
- auth mechanisms across three trust tiers
- 21
- admin screens
The signal path reads top to bottom. Every stage is a decision that was made in the code, with the problem it answered and what it cost.
01 Checkout: The merchant's customer starts a deposit or a cash-out.
The problem
The client declares the deposit amount. Risk and velocity thresholds are evaluated on that number, and the card is charged in full. A small declared amount would duck every threshold.
The decision
Risk and velocity evaluate the figure that becomes the card charge, not the declared split beside it. Both arrive from the browser; only one of them is what the card is charged. The declared amount is treated as untrusted input like everything else that arrives from a browser.
What it cost
Nothing moved. One variable changed and the ordering did not: the check still runs before the processor is called, on the amount the processor will be asked to charge. The cost was the version that shipped first, where a small declared amount ducked every threshold, and the commit that fixed it says so.
02 Identity: Document verification, then an SMS check before money moves.
The problem
The SMS verification id was an optional parameter. Anyone calling the API directly, rather than through the app, could leave it out and skip the check and its dispute-evidence trail entirely.
The decision
Made it required, with the reason written next to the change so the next person does not relax it for a convenience case.
What it cost
Making a field required is a breaking change for every caller that had been omitting it, which was the point. One legacy deposit route still declares it optional and never reads it; that route has no callers in the app and is on the list to delete. That is what the parameter being optional had been hiding.
03 Risk: Velocity limits and a shared-device graph across customers.
The problem
Device fingerprints arrive from the client. A malformed or forged one, if stored as-is, links unrelated customers together in the shared-device graph and poisons every decision downstream.
The decision
A fingerprint that fails validation is recorded as absent, not as whatever string was sent. On the live deposit path the graph only receives values that passed that check. One legacy route still writes the raw value, which is why that route is on the list to delete.
What it cost
A customer on a browser that mangles the fingerprint gets slightly less protection, because absent is weaker evidence than a valid match. That is the right direction to fail in.
04 Processor: The charge goes to one of several card processors.
The problem
Merchants in this category get dropped by processors. If the platform is welded to one, a processor leaving is an outage that lasts as long as a rewrite.
The decision
One adapter interface, per-tenant credentials, weighted routing. Two processors sit behind it today, with a third slot already carved out in the types and the schema. One has already left, and the code for it moved to an archive folder rather than into a crisis.
What it cost
Every processor has its own idea of a webhook, a signature and a refund. The adapter is the place all of that gets normalised, and it is among the code that changes least: the interface has one commit and the busiest adapter eight, against eighty for the busiest dashboard screen.
05 Webhook: The processor reports the result. It retries until acknowledged.
The problem
Delivery is at-least-once. The body is unverified until you have looked up the right tenant's secret, and the only link back to a customer is a session id inside that unverified body. One processor's documented signature scheme was also wrong.
The decision
Deterministic operation ids, derived from the delivery itself, so a retry collides with its own first attempt instead of crediting twice. A dedup slot claimed on arrival. And the part most implementations miss: when the credit fails, the slot is released and a 503 goes back, so the processor redelivers instead of the credit being lost. The signature scheme was reverse-engineered from a captured sandbox delivery. An unknown tenant and a bad signature return the identical response, because different responses would let someone enumerate tenants.
What it cost
A hand-rolled UUIDv5 rather than a dependency, and a loose header scan that had matched the hosting platform's own proxy signature header, since pinned to the exact name with the reason written beside it. Both are documented in the code so they do not get cleaned up by someone helpful.
if (auditErr) {
if (auditErr.code === '23505') {
return NextResponse.json({ received: true, deduplicated: true })
}
return NextResponse.json({ error: 'Temporary error' }, { status: 503 })
}
// If a credit RPC fails after we've claimed the dedup slot, release the slot and
// return a retryable status so the processor re-delivers — otherwise the event is
// permanently deduped and the credit is silently lost.
const releaseDedupAndRetry = async (msg: string) => {
console.error(msg)
await supabaseAdmin
.from('audit_logs')
.delete()
.eq('action', 'webhook:[processor]')
.contains('new_values', { processor_event_id: event.eventId })
return NextResponse.json({ error: 'Credit failed, will retry' }, { status: 503 })
}
06 Ledger: Every movement of value, in Postgres, through functions only.
The problem
Application code with a database connection can write anything. A ledger that can be edited from a route handler is not a ledger.
The decision
Deny-direct-write policies on the money tables. All movement goes through Postgres functions, and the row-level security policies and one legacy ledger function are tested with pgTAP; the live payment-rail functions are not yet, and that is the next test to write.
What it cost
Around a hundred functions, and a migration for every change to one. Slower to change, which for a ledger is the point.
07 Payout: A customer cashes out. The processor is asked to send the money.
The problem
Two requests for the same payout arriving together could both reach the processor. Double-pay, real money.
The decision
Flip the redemption to an initiated state atomically before calling the processor, and compensate on every failure branch. Only the request that won the flip proceeds.
What it cost
The fix had a bug inside it. The database layer re-applied the filter on the returned row, so the winning update came back empty and every payout looked like a conflict. Finding that meant reading the query layer's source rather than its docs. It is the kind of story that only exists if one person owns the whole path.
08 Compliance: A certificate of controls for sponsoring banks and underwriters.
The problem
Banks and underwriters ask what controls exist. A slide deck is not evidence.
The decision
A generated PDF certificate of compliance controls with an evidence-exhibit pipeline behind it, with a tests-of-controls matrix and an exhibits section; the pointer from a control to its exhibit is editorial today, not structural.
What it cost
It is a document generator inside a payments platform, which nobody plans for. It exists because the people asking were the ones deciding whether the platform could operate at all.