Back to Blog
Engineering
2026-07-2818 min readBy StableOps

Crypto Payment Reconciliation: Match On-Chain Transfers to Orders

Learn crypto payment reconciliation across business orders, payment events, and on-chain transfers with a safe script and daily and monthly checklists.

Stablecoin payments
Payment reconciliation
Payment operations

Your wallet balance does not equal the total of your paid orders. That is normal. A wallet balance is a point-in-time stock after incoming transfers, treasury sweeps, refunds, and unrelated deposits. An order report is a flow over a chosen period. Add several chains, two stablecoins, reused addresses, late payments, and a failed webhook endpoint, and comparing those two numbers stops being an accounting method.

Reliable crypto payment reconciliation does not try to make one database answer every question. It joins three records: your business order says what the customer owed, the payment layer says how the order moved through its lifecycle, and the canonical chain says whether a transfer exists. Stable identifiers connect them; a scheduled API sweep catches gaps outside the real-time webhook path.

The practical result is a two-speed process:

  • Run a small automated reconciliation every day to find missing orders, state mismatches, and failed deliveries.
  • Run a controlled month-end reconciliation that closes exceptions, proves final balances, and preserves an audit package.

Why wallet balances and paid-order totals diverge

Before investigating a difference, make sure you are comparing the same kind of number. The closing balance of one address is not the settlement total for a period.

CauseWhat happens to the comparison
Opening wallet balanceFunds received before the period remain in the closing balance
Treasury sweep or supplier paymentA valid incoming payment leaves the receiving address later
RefundThe business order may remain paid while a separate outgoing transfer reduces the wallet balance
Multiple chains and assets10 USDC on Base and 10 USDT on TRON are separate ledgers, not one balance
Underpayment or overpaymentThe transfer reaches a merchant address but does not finalize the exact order
Payment after expiryFunds can arrive after the order becomes terminal and the address has been released
Address reuseOne address can represent several orders over time, so address alone is not an order key
Reorganization or failed receiptA previously detected or confirmed transfer can become reverted before finality
Webhook delivery failureStableOps advances the order, but the merchant application misses the notification and does not update its ledger

Reconcile movements, not just balances:

opening on-chain balance
+ qualifying incoming transfers
+ unmatched incoming transfers
- treasury sweeps
- refunds and other outgoing transfers
= closing on-chain balance

Then reconcile the qualifying incoming transfers to payment orders, and those payment orders to business obligations. This separation prevents a treasury movement from looking like a missing sale and prevents an unmatched transfer from looking like revenue.

Use three records, with a different authority for each question

There is no universal “single source of truth” for a payment. Authority depends on the question being asked.

RecordWhat it recordsIt is authoritative for
Merchant order and ledgerCustomer, invoice, amount due, currency, fulfillment, refunds, manual credits, accounting periodThe commercial obligation and how your business accounted for it
StableOps order and eventsAccepted chains/assets, allocated addresses, exact amount, status transitions, delivery historyWhether a transfer matched an open order and reached the required finality
Canonical blockchain recordToken contract or mint, addresses, smallest-unit amount, transaction hash, block, receiptWhether the on-chain transfer exists on the canonical chain

A block explorer is a useful view of the third record, not an accounting system. It cannot know that merchantOrderId: invoice_10482 represents a particular customer invoice or that an overpayment was partly credited and partly refunded. Likewise, a payment.finalized event proves the StableOps lifecycle reached finality; it does not prove your fulfillment worker committed its own database transaction.

The reconciliation join should look like this:

Merchant system                  StableOps                         Blockchain

invoice_10482  <------------->  merchant_order_id
business order id                payment_order_id  <------------> normalized event
settlement state                 event_id                          tx_hash + log_index
refund/manual credit             delivery_id                      chain + token + amount

Never join by receiving address alone. An address can be allocated again after an order is finalized, reverted, expired, or canceled. Never join by amount alone, either: equal payments can be valid at the same time on different addresses or chains.

Design the foreign keys before accepting the first payment

merchantOrderId is the primary bridge from StableOps back to your business object. It is required and unique per organization and environment. Use a stable ID generated by your system, not a display number that can be edited or reused.

Store these fields on the merchant side:

FieldPurpose
Internal business IDIdempotent fulfillment and accounting key
merchantOrderIdStableOps-to-business join; normally derived from the internal ID
StableOps payment IDDirect retrieval and delivery-log filtering
Requested and payable amountKeep both because automatic amount adjustment can make the returned payable amount differ from the business amount
Settlement assetPrevent USDC and USDT totals from being merged accidentally
Event IDDeduplicate webhook processing
Delivery IDDiagnose individual attempts without using it as the event deduplication key
Transaction referencechain, transaction hash, and log index captured from the detected event
Reconciliation stateopen, matched, exception, or closed, plus reason and reviewer

Use metadata for search and operational context, not as the only copy of a critical foreign key:

const order = await client.paymentOrders.create(
  {
    merchantOrderId: 'invoice_10482',
    amount: '249.00',
    acceptedAssets: [
      { chain: 'base', asset: 'USDC' },
      { chain: 'tron', asset: 'USDT' },
    ],
    expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
    metadata: {
      customerId: 'cus_781',
      invoiceNumber: 'INV-2026-10482',
      ledgerAccount: 'sales-software',
    },
  },
  { idempotencyKey: 'invoice_10482:create-payment-order' },
)

merchantOrderId prevents a second payment order from claiming the same business reference. The Idempotency-Key is a separate replay key: retrying the identical create request with the same key returns the original response, while reusing merchantOrderId with a new idempotency key returns a conflict.

If metadata contains customer or invoice information, configure webhook metadata redaction according to the receiving endpoint's needs. Do not put secrets, private keys, or regulated data into metadata merely to make reconciliation convenient.

Build the event ledger before performing side effects

The webhook handler should first verify the raw request signature and atomically insert X-Event-Id into an inbox table. Only then should a worker update the business order. A useful inbox row contains:

  • event ID with a unique constraint;
  • delivery ID for attempt-level diagnostics;
  • event type and payment-order ID;
  • merchant_order_id and the original payload;
  • received time, processing state, and processing error;
  • transaction hash and log index from payment.detected, when present.

Keep two idempotency boundaries. Event-ID uniqueness makes redelivery or replay harmless. A second uniqueness rule on the internal business order or fulfillment operation prevents two different valid events from issuing the same entitlement twice.

For reconciliation, do not treat “a successful webhook delivery exists” as “the merchant ledger was updated.” The HTTP request can return 2xx after durable inbox acceptance while the asynchronous worker later fails. Your daily job must compare the StableOps order state with the merchant's applied state.

The complete handler pattern is covered in Stablecoin Payment Webhooks: Prevent Duplicate Fulfillment.

Add an API sweep as the recovery path

Webhooks provide low-latency notification. The list APIs provide independent recovery and audit paths:

  • List payment orders returns orders for the API key's organization and environment, most recent first, with status filtering and pagination.
  • List webhook deliveries can filter by status, endpoint, or payment-order ID and exposes attempts, response status, errors, and dead-letter state.
  • Replay a specific delivery creates a new delivery from a prior one without deleting the original audit row.
  • Replay dead-letter deliveries requeues a bounded set after the endpoint has been repaired.

For this audit path to retain transfer evidence, subscribe at least one endpoint to payment.detected and payment.finalized, and keep your own verified event inbox. The delivery list is a recovery source for subscribed events, not a substitute for merchant-side retention.

The following Node.js script performs a bounded reconciliation for one closed accounting window. Its input separates the business amount from the amount returned to the payer, stores the StableOps payment ID, and optionally contains the transaction reference already saved by the merchant:

{
  "windowStart": "2026-07-27T00:00:00.000Z",
  "windowEnd": "2026-07-28T00:00:00.000Z",
  "orders": [
    {
      "merchantOrderId": "invoice_10482",
      "paymentOrderId": "po_01...",
      "requestedAmount": "249.00",
      "payableAmount": "249.000001",
      "settlementAsset": "USDC",
      "paymentApplied": true,
      "transfer": {
        "chain": "base",
        "asset": "USDC",
        "chainAmount": "249000001",
        "txHash": "0x...",
        "logIndex": 7
      }
    }
  ]
}

The script compares both directions, derives transfer evidence from deduplicated payment.detected events, and checks whether a failed or dead-lettered event later succeeded on the same endpoint. Because the current list APIs use offset pagination, it accepts the combined order-and-delivery dataset only after two complete snapshots match. Concurrent changes therefore make the job fail for a later retry instead of producing a false clean result.

import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'

type TransferRef = {
  chain: string
  asset: string
  chainAmount: string
  txHash: string
  logIndex: number
}

type LocalOrder = {
  merchantOrderId: string
  paymentOrderId: string
  requestedAmount: string
  payableAmount: string
  settlementAsset: string | null
  paymentApplied: boolean
  transfer?: TransferRef
}

type ExportFile = {
  windowStart: string
  windowEnd: string
  orders: LocalOrder[]
}

type PlatformOrder = {
  id: string
  merchant_order_id: string
  amount: string
  requested_amount: string
  settlement_asset?: string
  status: string
  created_at: string
}

type Delivery = {
  id: string
  webhook_endpoint_id: string
  event_id: string
  payment_order_id: string | null
  event_type: string
  status: string
  created_at: string
  payload: Record<string, unknown>
}

type Page<T> = { items: T[]; has_more: boolean }
type TransferEvidence = TransferRef & {
  eventId: string
  paymentOrderId: string
  normalizedEventId: string
}

const apiKey = process.env.STABLEOPS_API_KEY
const baseUrl = process.env.STABLEOPS_API_URL ?? 'https://api.stableops.dev'
if (!apiKey) throw new Error('STABLEOPS_API_KEY is required')

function canonical(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(canonical)
  if (value && typeof value === 'object') {
    const record = value as Record<string, unknown>
    return Object.fromEntries(
      Object.keys(record)
        .sort()
        .map((key) => [key, canonical(record[key])]),
    )
  }
  return value
}

function fingerprint<T extends { id: string }>(items: T[]) {
  const sorted = [...items].sort((a, b) => a.id.localeCompare(b.id))
  return createHash('sha256')
    .update(JSON.stringify(canonical(sorted)))
    .digest('hex')
}

async function listOnce<T extends { id: string }>(
  path: string,
  query: Record<string, string> = {},
) {
  const items = new Map<string, T>()
  for (let offset = 0; ; offset += 200) {
    const search = new URLSearchParams({ ...query, limit: '200', offset: String(offset) })
    const response = await fetch(`${baseUrl}${path}?${search}`, {
      headers: { authorization: `Bearer ${apiKey}` },
    })
    if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`)
    const page = (await response.json()) as Page<T>
    for (const item of page.items) {
      if (items.has(item.id)) throw new Error(`${path}: pagination changed during the scan`)
      items.set(item.id, item)
    }
    if (!page.has_more) return [...items.values()]
  }
}

async function scanAuditData() {
  const [platform, deliveries] = await Promise.all([
    listOnce<PlatformOrder>('/v1/payment-orders'),
    listOnce<Delivery>('/v1/webhook-deliveries'),
  ])
  return { platform, deliveries }
}

async function stableAuditSnapshot() {
  let previous = await scanAuditData()
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const current = await scanAuditData()
    if (
      fingerprint(previous.platform) === fingerprint(current.platform) &&
      fingerprint(previous.deliveries) === fingerprint(current.deliveries)
    )
      return current
    previous = current
  }
  throw new Error('no stable audit snapshot; retry after the cutoff settles')
}

function decimalKey(value: string) {
  if (!/^\d+(?:\.\d+)?$/u.test(value)) throw new Error(`invalid decimal amount: ${value}`)
  const [whole, fraction = ''] = value.split('.')
  return `${BigInt(whole)}.${fraction.replace(/0+$/u, '')}`
}

function inWindow(value: string, start: number, end: number) {
  const time = Date.parse(value)
  return Number.isFinite(time) && time >= start && time < end
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}

function detectedEvidence(delivery: Delivery): TransferEvidence | null {
  if (delivery.event_type !== 'payment.detected' || !isRecord(delivery.payload.data)) return null
  const data = delivery.payload.data
  if (
    !delivery.payment_order_id ||
    typeof data.normalized_event_id !== 'string' ||
    typeof data.chain !== 'string' ||
    typeof data.asset !== 'string' ||
    typeof data.chain_amount !== 'string' ||
    typeof data.tx_hash !== 'string' ||
    typeof data.log_index !== 'number'
  )
    return null
  return {
    eventId: delivery.event_id,
    paymentOrderId: delivery.payment_order_id,
    normalizedEventId: data.normalized_event_id,
    chain: data.chain,
    asset: data.asset,
    chainAmount: data.chain_amount,
    txHash: data.tx_hash,
    logIndex: data.log_index,
  }
}

function transferKey(value: TransferRef) {
  return [value.chain, value.asset, value.chainAmount, value.txHash, value.logIndex].join('|')
}

const file = process.argv[2]
if (!file) throw new Error('usage: npx tsx reconcile.ts business-export.json')

const input = JSON.parse(await readFile(file, 'utf8')) as ExportFile
const start = Date.parse(input.windowStart)
const end = Date.parse(input.windowEnd)
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
  throw new Error('invalid reconciliation window')

const { platform, deliveries } = await stableAuditSnapshot()

const findings: string[] = []
const localByMerchantId = new Map<string, LocalOrder>()
const localByPaymentId = new Map<string, LocalOrder>()
for (const order of input.orders) {
  if (localByMerchantId.has(order.merchantOrderId))
    throw new Error(`duplicate merchantOrderId: ${order.merchantOrderId}`)
  if (localByPaymentId.has(order.paymentOrderId))
    throw new Error(`duplicate paymentOrderId: ${order.paymentOrderId}`)
  localByMerchantId.set(order.merchantOrderId, order)
  localByPaymentId.set(order.paymentOrderId, order)
}

const platformById = new Map(platform.map((order) => [order.id, order]))
const paymentIdsChangedInWindow = new Set(
  deliveries
    .filter((delivery) => delivery.payment_order_id && inWindow(delivery.created_at, start, end))
    .map((delivery) => delivery.payment_order_id as string),
)
const platformInWindow = platform.filter(
  (order) => inWindow(order.created_at, start, end) || paymentIdsChangedInWindow.has(order.id),
)
const relevantPaymentIds = new Set([
  ...localByPaymentId.keys(),
  ...platformInWindow.map((order) => order.id),
])

for (const order of input.orders) {
  const remote = platformById.get(order.paymentOrderId)
  if (!remote) {
    findings.push(`${order.merchantOrderId}: payment order ${order.paymentOrderId} is missing`)
    continue
  }
  if (remote.merchant_order_id !== order.merchantOrderId)
    findings.push(`${order.paymentOrderId}: merchantOrderId mapping differs`)
  if (decimalKey(order.requestedAmount) !== decimalKey(remote.requested_amount))
    findings.push(`${order.merchantOrderId}: requested amount differs`)
  if (decimalKey(order.payableAmount) !== decimalKey(remote.amount))
    findings.push(`${order.merchantOrderId}: payable amount differs`)
  if (order.settlementAsset !== (remote.settlement_asset ?? null))
    findings.push(`${order.merchantOrderId}: settlement asset differs`)
  if (order.paymentApplied && remote.status !== 'finalized')
    findings.push(
      `${order.merchantOrderId}: merchant applied payment but StableOps is ${remote.status}`,
    )
  if (!order.paymentApplied && remote.status === 'finalized')
    findings.push(`${order.merchantOrderId}: finalized but merchant is not settled`)
}

for (const remote of platformInWindow) {
  if (!localByMerchantId.has(remote.merchant_order_id))
    findings.push(`${remote.id}: StableOps order has no business order in this window`)
}

const deliveryGroups = new Map<string, Delivery[]>()
for (const delivery of deliveries) {
  if (!delivery.payment_order_id || !relevantPaymentIds.has(delivery.payment_order_id)) continue
  const key = `${delivery.webhook_endpoint_id}:${delivery.event_id}`
  deliveryGroups.set(key, [...(deliveryGroups.get(key) ?? []), delivery])
}
for (const group of deliveryGroups.values()) {
  if (group.some((delivery) => delivery.status === 'succeeded')) continue
  const sample = group[0]
  if (group.some((delivery) => delivery.status === 'dead_letter'))
    findings.push(`${sample.payment_order_id}: ${sample.event_type} remains dead-lettered`)
  else if (group.some((delivery) => delivery.status === 'failed'))
    findings.push(`${sample.payment_order_id}: ${sample.event_type} delivery is still failing`)
}

const evidenceByEvent = new Map<string, TransferEvidence>()
for (const delivery of deliveries) {
  if (!delivery.payment_order_id || !relevantPaymentIds.has(delivery.payment_order_id)) continue
  const evidence = detectedEvidence(delivery)
  if (!evidence) {
    if (delivery.event_type === 'payment.detected')
      findings.push(`${delivery.id}: malformed payment.detected evidence`)
    continue
  }
  const existing = evidenceByEvent.get(evidence.eventId)
  if (existing && transferKey(existing) !== transferKey(evidence))
    findings.push(`${evidence.eventId}: inconsistent replay payload`)
  else evidenceByEvent.set(evidence.eventId, evidence)
}

const evidenceByPaymentId = new Map<string, TransferEvidence[]>()
for (const evidence of evidenceByEvent.values()) {
  evidenceByPaymentId.set(evidence.paymentOrderId, [
    ...(evidenceByPaymentId.get(evidence.paymentOrderId) ?? []),
    evidence,
  ])
}
for (const order of input.orders) {
  const evidence = evidenceByPaymentId.get(order.paymentOrderId) ?? []
  const remote = platformById.get(order.paymentOrderId)
  if (evidence.length > 1)
    findings.push(`${order.merchantOrderId}: multiple detected transfers are associated`)
  if (remote?.status === 'finalized' && evidence.length === 0)
    findings.push(`${order.merchantOrderId}: finalized without retained transfer evidence`)
  if (order.transfer && evidence[0] && transferKey(order.transfer) !== transferKey(evidence[0]))
    findings.push(`${order.merchantOrderId}: merchant and StableOps transfer references differ`)
  if (!order.transfer && evidence[0])
    findings.push(`${order.merchantOrderId}: merchant ledger is missing the transfer reference`)
}

console.log(
  JSON.stringify(
    {
      window: { start: input.windowStart, end: input.windowEnd },
      checkedLocalOrders: input.orders.length,
      checkedPlatformOrders: platformInWindow.length,
      transfers: [...evidenceByEvent.values()],
      findings,
    },
    null,
    2,
  ),
)
process.exitCode = findings.length === 0 ? 0 : 1

Close the window far enough behind real time for your finality policy, asynchronous workers, and eventually consistent list reads to settle. Re-run a first-time discrepancy before escalating it. The script discovers reverse-side orphans from payment orders created in the window and payment events delivered in the window, so the endpoint must subscribe to the payment events you intend to audit.

For a high-volume production account, make the verified merchant inbox the primary event dataset and retrieve locally stored payment IDs directly. Use the guarded full scan for periodic orphan discovery. If concurrent writes prevent two matching snapshots, the job must remain failed; do not weaken it to “first page looked clean.”

The generated transfers array is the join index for chain evidence, not a fresh blockchain query. For exceptions and month-end audit samples, use the canonical explorer or your chain provider to recheck its chain, token contract or mint, destination, smallest-unit amount, transaction hash plus log index, receipt status, and current block hash. Save the verification time and block reference with the reconciliation result.

This script finds structural mismatches; it does not automatically settle or refund anything. Every discrepancy still needs evidence and an approved resolution.

Route each discrepancy by cause

Turn comparison output into a controlled queue rather than editing records until totals happen to match.

FindingLikely causeSafe next action
Business order exists; payment order missingCreate request failed, wrong environment, or mapping was never persistedCheck the idempotency key and API logs; retry safely or create the order under the original policy
Payment order exists; business order missingDeleted/imported business data or the wrong merchantOrderId was suppliedQuarantine fulfillment; recover the business object or document an orphaned order
StableOps finalized; merchant still unpaidInbox worker failed, event was not handled, or mapping is wrongInspect event and delivery logs, then retry the merchant worker or replay transport idempotently
Merchant settled; StableOps not finalizedOptimistic fulfillment, manual credit, or incorrect state transitionStop further fulfillment; verify canonical chain state and document or reverse the manual decision
Dead-letter deliveryEndpoint, authentication, timeout, or downstream failureFix the receiver first, then replay; do not replay repeatedly into a still-broken endpoint
Amount differsDiscount drift, automatic amount adjustment, wrong export, or manual creditCompare requested, returned, received, credited, and refunded amounts without overwriting any of them
Transfer exists; no order matchedWrong amount, chain, asset, address window, or late arrivalSend it to the exception process described in the payment mismatch guide

For every exception, retain who resolved it, when, the reason code, supporting event and transaction references, any manual journal entry, and any refund transaction. “Adjusted to match” is not an auditable reason.

Daily crypto payment reconciliation checklist

Copy this list into an operations runbook:

  • Freeze the window and record organization, Sandbox or Live environment, timezone, and settlement asset.
  • Export business orders created, changed, fulfilled, refunded, or manually credited during the window.
  • Pull a combined StableOps order-and-delivery snapshot; fail the job if repeated scans differ.
  • Join on merchantOrderId; verify StableOps payment IDs are stored locally.
  • Compare requested_amount, returned amount, settlement asset, and terminal state separately.
  • Check both directions for business orders without payment orders and payment orders without business orders.
  • Confirm each local settlement has exactly one idempotent fulfillment record.
  • Match each finalized order to retained chain, asset, transaction hash, log index, and smallest-unit amount.
  • Find StableOps finalized orders not applied in the merchant ledger.
  • Find merchant-settled orders whose StableOps state is not finalized.
  • Group deliveries by endpoint and event; treat a historical dead letter as resolved only when a replay in that group succeeded.
  • Review unresolved failed and dead_letter deliveries and the inbox worker's own failures.
  • Review expired, reverted, canceled, and still-open orders rather than excluding them from the report.
  • Send unmatched and late transfers to the exception queue; never force-match by address or amount.
  • Assign every finding an owner, reason code, deadline, and immutable evidence links.
  • Save counts and totals by chain and asset, plus the job window and completed page count.

The daily report should be small enough to act on. If it repeatedly contains hundreds of exceptions, fix the integration or policy generating them rather than scaling a manual team around the symptom.

Month-end reconciliation checklist

Month-end adds financial closure to the daily operational checks:

  • Re-run every daily window and prove every API scan reached two matching complete snapshots.
  • Roll forward each chain-and-asset balance from opening balance through incoming transfers, sweeps, and refunds to closing balance.
  • Separate exact matched receipts, unmatched receipts, manual credits, refunds, network fees, and treasury movements.
  • Confirm all finalized orders are posted once to the correct accounting period and ledger account.
  • Confirm all manual credits and refunds have approval records and transaction references.
  • Resolve or age every open exception; carry unresolved items forward explicitly instead of hiding them in a net adjustment.
  • Review all dead letters, replays, and events accepted by the inbox but not completed by its worker.
  • Export business records, StableOps orders and delivery logs, chain evidence, exception decisions, and the reconciliation summary into a read-only audit package.
  • Have someone other than the preparer review and sign off on the period.

Keep totals denominated by asset and chain until your accounting policy performs a documented valuation. Adding raw USDC and USDT numbers across networks may be useful operationally, but it is not a substitute for an accounting currency and valuation timestamp.

Replay repairs delivery; it does not rewrite history

StableOps retains webhook delivery attempts. Replaying an endpoint event, a specific delivery, or a dead-letter row creates a new delivery row and leaves the original record intact. That is the desired audit behavior.

It also means the replayed event carries the same event identity. Your handler must deduplicate on X-Event-Id, not delivery ID. If the event was durably accepted but the merchant worker failed, merely replaying it may follow the no-op inbox path; the correct recovery is often to retry the merchant's failed worker job. If the event never reached the inbox, replay is the right transport recovery.

Always identify which boundary failed before pressing replay:

  1. StableOps could not deliver the HTTP request.
  2. The merchant endpoint rejected or timed out before durable acceptance.
  3. The endpoint accepted the event, but asynchronous processing failed.
  4. Processing succeeded, but a later accounting export or join is wrong.

Only the first two are fixed by delivery replay.

FAQ

What is crypto payment reconciliation?

Crypto payment reconciliation is the process of joining a merchant's business obligation, the payment-order and event lifecycle, and the canonical on-chain transfer, then proving that every finalized receipt was posted once and every exception was explained. Comparing a wallet balance with sales totals is only a balance check, not complete reconciliation.

Can I reconcile stablecoin payments from blockchain data alone?

No. Blockchain data proves transfers, token identities, amounts, addresses, and canonical status, but it does not contain your customer, invoice, fulfillment, refund policy, or accounting period. You need a stable business foreign key such as merchantOrderId and an event ledger that preserves the payment-order relationship.

Should webhooks or polling be the source of truth?

Use webhooks for prompt state changes and API listing for scheduled recovery and audit. They expose the same payment lifecycle through different delivery paths. Your handler still needs event deduplication, and an offset-paginated audit scan must fail unless repeated complete snapshots match. Reconciliation must compare platform state with the side effects actually committed in your own ledger.

Make reconciliation part of the payment design

Do not wait for the first month-end close to discover that transaction hashes, event IDs, and business references live in separate logs with different retention periods. Start with the payment-order model, implement the payment-order list and webhook delivery list as recovery paths, and run the daily checklist in Sandbox before accepting production funds. A payment integration is complete only when it can explain both the happy path and every difference.

Related articles

An underpaid crypto payment, overpayment, wrong-network transfer, or late payment should never silently complete an order. Learn how to detect, resolve, and prevent each mismatch.

There is no single best chain for stablecoin payments. Compare payer-side fees, finality, and wallet distribution, then accept a set of chains and match each transfer to an order.

Use x402 for HTTP-native agent payments, but keep merchant-side order state, policy, finality, webhooks, and audit trails separate.

Build USDC subscription payments with recurring invoices, payer-initiated checkout, and verified subscription settlement events.