On this page
Key takeaways
- A stablecoin payment has at least three records: the chain, the gateway, and the application.
- Most reconciliation pain is disagreement between records, and it falls into a handful of named cases.
- Classify exceptions by which record is ahead, then resolve with the smallest safe action.
- The goal is a Sunday answer: owed, confirmed, in flight, in one place.
Vellum's treasury could not answer a simple question on a Sunday: what is owed, what is confirmed, and what is still in flight? The answer was spread across a processor, three wallets, and a nightly export. This guide is the model we use to make that answer one query.
Three records
Every payment leaves at least three traces. Reconciliation is comparing them.
| Record | Owned by | Answers |
|---|---|---|
| The network | Nobody — it is public | Did value actually move, and how final is it? |
| The gateway | You (self-hosted) | What did we expect, and what state is it in? |
| The application | Your product team | What did the customer order, and did we fulfil it? |
When all three agree, nothing needs doing. Almost every operational problem is one of them running ahead of the others.
A taxonomy of disagreement
| Case | Chain | Gateway | Application | Usual cause |
|---|---|---|---|---|
| Silent confirmation | Confirmed | Confirmed | Pending | Webhook failed or handler rolled back |
| Late confirmation | Confirmed | Listening | Pending | Indexer lag or RPC issue |
| Underpayment | Less than expected | Partial | Pending | Customer sent the wrong amount |
| Overpayment | More than expected | Confirmed | Paid | Customer overpaid; needs a refund decision |
| Wrong network or token | On another network | Not seen | Pending | Customer chose the wrong option |
| Expired then paid | Confirmed | Expired | Cancelled | Payment arrived after expiry |
Silent confirmations
The most common case, and the one dashboard grouping was built for: on-chain yes, application no. It is almost always a delivery or handler problem, not a payment problem.
A resolution routine
- Identify which record is ahead. The chain is authoritative about value; the application is authoritative about the order.
- Find the break. Was an event delivered? Did the handler succeed? Did the indexer see the transaction?
- Use the smallest safe action. Prefer replaying an event over editing a record.
- Write down the reason. A closed reason on the invoice stops settlement reports inventing one.
- Feed the cause back. If the same break repeats, fix the contract or the handler, not the symptom.
Edit the record
- Hand-fixing an order to match the chain
- No trace of who changed it or why
- The same break happens next week
Replay the event
- Use the replay token — same contract as normal
- A closed reason is written on the invoice
- Fix the handler or contract that caused the break
Design choices that make it easier
- Stable references. The application stores its own reference on every intent, so records can be joined.
- Explicit expiry. Payment links expire in the API the same way they expire in the dashboard (release 0.9.2), removing a whole class of ambiguity.
- Closed reasons. Invoices carry why they were closed.
- A daily reconciliation job. Compare gateway state to the application even when nothing seems wrong.
-- Payments confirmed at the gateway but not fulfilled by the app
SELECT p.id, p.reference, p.amount, p.confirmed_at
FROM gateway_payments p
LEFT JOIN orders o ON o.payment_reference = p.reference
WHERE p.status = 'confirmed'
AND (o.id IS NULL OR o.status <> 'paid')
AND p.confirmed_at < now() - interval '10 minutes'
ORDER BY p.confirmed_at;What good looks like
At Vellum, settlement moved from a nightly export to a live operator queue. Treasury could answer in-flight versus confirmed without opening three tools, and manual reconciliation fell by 64%. Zero missed-webhook incidents in ninety days was not luck; it was replay tokens and a runbook.
We stopped renting a processor and started running a system we could explain to our own engineers.
— Mira Adel, Head of Treasury, Vellum Markets
Daily reconciliation routine
- Run the query for payments confirmed at the gateway but not fulfilled.
- Review silent confirmations on the dashboard before anything else.
- Check for expired links that later received value.
- Confirm every closed invoice has a closed reason.
- Log any repeat cause and open a fix for it.
Money is integers: decimals, minor units, and rounding
Before any matching logic, get the arithmetic right. Token amounts on a network are integers in the token's smallest unit, and the number of decimal places differs between tokens and, importantly, between networks: the same stablecoin can use six decimals on one network and eighteen on another. The ERC-20 standard makes decimals an optional, informational field [1], so it must be read per token and per network, never assumed.
type Amount = { units: bigint; decimals: number; token: string };
function toDisplay({ units, decimals }: Amount): string {
const s = units.toString().padStart(decimals + 1, "0");
return `${s.slice(0, -decimals)}.${s.slice(-decimals)}`;
}
function equalValue(a: Amount, b: Amount): boolean {
// normalise to the larger precision before comparing
const d = Math.max(a.decimals, b.decimals);
return a.units * 10n ** BigInt(d - a.decimals) === b.units * 10n ** BigInt(d - b.decimals);
}Never use floats for money
JavaScript numbers are IEEE-754 doubles and cannot represent most decimal fractions exactly. Use BigInt or a decimal library, store integers in the database, and format only at the edge [2].
A matching engine, step by step
Reconciliation is a matching problem: pair records from two sources and explain everything left over. The strategy is to try strong, exact matches first and only then fall back to weaker ones, recording which rule matched so a human can audit it.
| Pass | Match on | Confidence | Typical result |
|---|---|---|---|
| 1. Reference | Stable reference from the application | Exact | Most payments |
| 2. Intent id | Gateway intent id in both records | Exact | Retried or re-created intents |
| 3. Amount and window | Same amount within a time window and destination | Medium — needs review | Customer paid without a reference |
| 4. Manual | Operator decides | Human | Everything left over |
WITH matched AS (
SELECT g.id AS gateway_id, o.id AS order_id
FROM gateway_payments g
JOIN orders o ON o.payment_reference = g.reference
WHERE g.status = 'confirmed'
)
SELECT g.id, g.reference, g.amount_units, g.confirmed_at
FROM gateway_payments g
LEFT JOIN matched m ON m.gateway_id = g.id
WHERE g.status = 'confirmed'
AND m.gateway_id IS NULL -- confirmed at the gateway, no order matched
ORDER BY g.confirmed_at;Record the outcome of each pass in a reconciliation table: which rule matched, when, and by whom if manual. That table is the evidence pack an auditor will ask for.
Double-entry thinking for a payment gateway
You do not need a full accounting system to benefit from double-entry principles. The core idea — every movement debits one account and credits another, and the totals always balance — turns a pile of payments into a ledger that can prove itself [3].
| Event | Debit | Credit |
|---|---|---|
| Customer payment confirmed | Received funds (asset) | Amounts owed to customer orders (liability) |
| Order fulfilled | Amounts owed to customer orders | Revenue |
| Refund issued | Amounts owed to customer orders | Received funds (asset) |
| Underpayment detected | Suspense | Received funds (asset) |
A suspense account is the safety valve. Value that arrives without a clean match goes there instead of being forced onto a customer or ignored, and everything in suspense has an age and an owner. A growing suspense balance is your earliest indicator that something upstream is wrong.
The daily close
Reconcile on a schedule, not on suspicion. A consistent daily routine turns discrepancies from crises into chores.
- 01
Freeze the cut-off
Choose a fixed time. Everything confirmed before it belongs to today's close.
- 02
Run the matching passes
Reference first, then the fallbacks. Record each result.
- 03
Review the residual
Investigate every unmatched item until it has a classification and an owner.
- 04
Check the balances
Gateway total, ledger total, and application total should agree, or the difference should be fully explained.
- 05
Sign off
A named person records the close, including anything carried forward.
Metrics that tell you it is healthy
| Metric | Healthy | Warning sign |
|---|---|---|
| Auto-match rate (pass 1) | Very high and stable | A sudden drop means references broke upstream |
| Median age of unmatched items | Hours | Days |
| Suspense balance | Small and shrinking | Growing week over week |
| Silent confirmations per week | Near zero | A rising count means webhook or handler trouble |
| Time to close | Minutes | Increasing manual work |
Evidence pack for an auditor
- The matching rule and result for every payment in the period.
- The list of manual decisions with the operator and reason.
- Balances at cut-off from the gateway, ledger, and application.
- Any confirmation policy in force during the period.
- Incidents that affected records, with their resolution.
Closing
Reconciliation is not accounting theatre. It is knowing which of three records to believe, and having a routine for when they disagree.
References & further reading
- 1EIP-20: Token Standard — Fabian Vogelsteller, Vitalik Buterin, Ethereum Improvement ProposalsNote that name, symbol, and decimals are optional in the standard.
- 2Money — Martin Fowler, Patterns of Enterprise Application Architecture
- 3Accounting for Developers, Part I — Modern Treasury JournalAn accessible introduction to double-entry ideas for engineers.
- 4Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly Media
The product behind this post
StablePay
Self-hosted stablecoin payment infrastructure.
From $4,800 one-time license
About the authors
Layla Haddad
Principal Engineer, Payments Infrastructure
Designs the gateway architecture behind StablePay: state machines, indexing, and signing boundaries.
Daniel Whitfield
Staff Engineer, Platform & Reliability
Owns webhooks, PostgreSQL, deployment, and the operational habits that keep self-hosted software boring.
Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.
All writing