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.
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.
| Cause | What happens to the comparison |
|---|---|
| Opening wallet balance | Funds received before the period remain in the closing balance |
| Treasury sweep or supplier payment | A valid incoming payment leaves the receiving address later |
| Refund | The business order may remain paid while a separate outgoing transfer reduces the wallet balance |
| Multiple chains and assets | 10 USDC on Base and 10 USDT on TRON are separate ledgers, not one balance |
| Underpayment or overpayment | The transfer reaches a merchant address but does not finalize the exact order |
| Payment after expiry | Funds can arrive after the order becomes terminal and the address has been released |
| Address reuse | One address can represent several orders over time, so address alone is not an order key |
| Reorganization or failed receipt | A previously detected or confirmed transfer can become reverted before finality |
| Webhook delivery failure | StableOps 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 balanceThen 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.
| Record | What it records | It is authoritative for |
|---|---|---|
| Merchant order and ledger | Customer, invoice, amount due, currency, fulfillment, refunds, manual credits, accounting period | The commercial obligation and how your business accounted for it |
| StableOps order and events | Accepted chains/assets, allocated addresses, exact amount, status transitions, delivery history | Whether a transfer matched an open order and reached the required finality |
| Canonical blockchain record | Token contract or mint, addresses, smallest-unit amount, transaction hash, block, receipt | Whether 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 + amountNever 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:
| Field | Purpose |
|---|---|
| Internal business ID | Idempotent fulfillment and accounting key |
merchantOrderId | StableOps-to-business join; normally derived from the internal ID |
| StableOps payment ID | Direct retrieval and delivery-log filtering |
| Requested and payable amount | Keep both because automatic amount adjustment can make the returned payable amount differ from the business amount |
| Settlement asset | Prevent USDC and USDT totals from being merged accidentally |
| Event ID | Deduplicate webhook processing |
| Delivery ID | Diagnose individual attempts without using it as the event deduplication key |
| Transaction reference | chain, transaction hash, and log index captured from the detected event |
| Reconciliation state | open, 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_idand 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 : 1Close 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.
| Finding | Likely cause | Safe next action |
|---|---|---|
| Business order exists; payment order missing | Create request failed, wrong environment, or mapping was never persisted | Check the idempotency key and API logs; retry safely or create the order under the original policy |
| Payment order exists; business order missing | Deleted/imported business data or the wrong merchantOrderId was supplied | Quarantine fulfillment; recover the business object or document an orphaned order |
| StableOps finalized; merchant still unpaid | Inbox worker failed, event was not handled, or mapping is wrong | Inspect event and delivery logs, then retry the merchant worker or replay transport idempotently |
| Merchant settled; StableOps not finalized | Optimistic fulfillment, manual credit, or incorrect state transition | Stop further fulfillment; verify canonical chain state and document or reverse the manual decision |
| Dead-letter delivery | Endpoint, authentication, timeout, or downstream failure | Fix the receiver first, then replay; do not replay repeatedly into a still-broken endpoint |
| Amount differs | Discount drift, automatic amount adjustment, wrong export, or manual credit | Compare requested, returned, received, credited, and refunded amounts without overwriting any of them |
| Transfer exists; no order matched | Wrong amount, chain, asset, address window, or late arrival | Send 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, returnedamount, 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
finalizedorders 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
failedanddead_letterdeliveries 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:
- StableOps could not deliver the HTTP request.
- The merchant endpoint rejected or timed out before durable acceptance.
- The endpoint accepted the event, but asynchronous processing failed.
- 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.