Engineering · 3 Aug 2026

Reconciling stablecoin payments: a treasury operator's guide.

Three records, one truth. How to tell what is owed, what is confirmed, and what is still in flight — without opening three tools.

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.

The networkDid value move, and how final is it?
The gatewayWhat did we expect, and what state is it in?
The applicationWhat was ordered, and was it fulfilled?
CompareFind which record is ahead
Reconciliation is comparing three records that should agree

Three records

Every payment leaves at least three traces. Reconciliation is comparing them.

RecordOwned byAnswers
The networkNobody — it is publicDid value actually move, and how final is it?
The gatewayYou (self-hosted)What did we expect, and what state is it in?
The applicationYour product teamWhat 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

CaseChainGatewayApplicationUsual cause
Silent confirmationConfirmedConfirmedPendingWebhook failed or handler rolled back
Late confirmationConfirmedListeningPendingIndexer lag or RPC issue
UnderpaymentLess than expectedPartialPendingCustomer sent the wrong amount
OverpaymentMore than expectedConfirmedPaidCustomer overpaid; needs a refund decision
Wrong network or tokenOn another networkNot seenPendingCustomer chose the wrong option
Expired then paidConfirmedExpiredCancelledPayment 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.

17 daysVellum: time to first live payment
−64%Manual reconciliation
0 in 90 daysMissed-webhook incidents

A resolution routine

  1. Identify which record is ahead. The chain is authoritative about value; the application is authoritative about the order.
  2. Find the break. Was an event delivered? Did the handler succeed? Did the indexer see the transaction?
  3. Use the smallest safe action. Prefer replaying an event over editing a record.
  4. Write down the reason. A closed reason on the invoice stops settlement reports inventing one.
  5. 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.
Illustrative — table and column names depend on your schemaSQL
-- 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.

Illustrative — carry amounts as integer minor units with their decimalsTypeScript
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.

PassMatch onConfidenceTypical result
1. ReferenceStable reference from the applicationExactMost payments
2. Intent idGateway intent id in both recordsExactRetried or re-created intents
3. Amount and windowSame amount within a time window and destinationMedium — needs reviewCustomer paid without a reference
4. ManualOperator decidesHumanEverything left over
Illustrative — pass 1 and the residual, expressed as one querySQL
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].

EventDebitCredit
Customer payment confirmedReceived funds (asset)Amounts owed to customer orders (liability)
Order fulfilledAmounts owed to customer ordersRevenue
Refund issuedAmounts owed to customer ordersReceived funds (asset)
Underpayment detectedSuspenseReceived 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.

  1. 01

    Freeze the cut-off

    Choose a fixed time. Everything confirmed before it belongs to today's close.

  2. 02

    Run the matching passes

    Reference first, then the fallbacks. Record each result.

  3. 03

    Review the residual

    Investigate every unmatched item until it has a classification and an owner.

  4. 04

    Check the balances

    Gateway total, ledger total, and application total should agree, or the difference should be fully explained.

  5. 05

    Sign off

    A named person records the close, including anything carried forward.

AgeDays an item has sat in suspense — the metric that matters
RateShare of payments matched by reference alone
TimeMinutes from cut-off to sign-off

Metrics that tell you it is healthy

MetricHealthyWarning sign
Auto-match rate (pass 1)Very high and stableA sudden drop means references broke upstream
Median age of unmatched itemsHoursDays
Suspense balanceSmall and shrinkingGrowing week over week
Silent confirmations per weekNear zeroA rising count means webhook or handler trouble
Time to closeMinutesIncreasing 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

  1. 1
    EIP-20: Token Standard — Fabian Vogelsteller, Vitalik Buterin, Ethereum Improvement ProposalsNote that name, symbol, and decimals are optional in the standard.
  2. 2
    Money — Martin Fowler, Patterns of Enterprise Application Architecture
  3. 3
    Accounting for Developers, Part I — Modern Treasury JournalAn accessible introduction to double-entry ideas for engineers.
  4. 4
    Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly Media
Found this useful? Share it

Get the next essay in your inbox

Practical writing on payments infrastructure, operations software, and shipping real systems. No spam, no sales sequence.

We only use your email to send the studio's writing. See the privacy policy.

About the authors

Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.

All writing

Have a system like this to run?

Explore the catalog, or write down the problem and the constraints. We respond when the fit is real.

Free apps from the studio. Enter your email, get a private download link. Free for personal use.

Get them free

Have a product to sell? We review, list, and sell it for you — you keep 90% of every sale.

Apply to sell with us