Skip to content

Payment events, retries, and reconciliation

Use a durable payment record, an event inbox, and a ledger. Keep request submission separate from settlement.

This is illustrative application architecture, not a tested OUSD integration. The adapter must follow your provider’s real API and event specification.

Give each user-approved operation a durable ID. Store the provider, account, asset, network, environment, and exact amount. Use integers in the documented smallest unit or exact decimals, never floating-point balances.

Keep application user IDs, provider customer IDs, and wallet addresses separate. Check authorization on the server before accepting a request and again before a queued operation is sent. Keep credentials and identity documents out of browser code and routine logs.

Use one provider idempotency key for retries of the same operation. A changed intent needs a new decision, not silent reuse of the old key. A timeout after submission means the outcome is uncertain: retrieve the existing operation before sending another transfer.

Deduplicate incoming events by provider, environment, and event ID. Save the event and durable processing job before acknowledging it. Stripe documents request idempotency and duplicate-event handling; other providers may use different retention and retry rules.

Illustrative pseudocode:

receive_event(raw_body, headers):
event = adapter.verify_and_parse(raw_body, headers)
require event.environment == configured_environment
transaction:
inserted = inbox.insert_if_absent(
provider, environment, event.id, raw_body
)
if inserted:
durable_jobs.enqueue("reconcile_payment", event.resource_id)
acknowledge_receipt()
reconcile_payment(resource_id):
latest = adapter.fetch_authoritative_state(resource_id)
transaction:
payment = payments.lock_by_provider_reference(resource_id)
transition = rules.evaluate(payment, latest)
ledger.apply_once(transition.business_key, transition.entries)
payments.record_evidence(payment.id, latest, transition)

Verify the signature and replay rules before trusting the event. Events may arrive twice or out of order; retrieve authoritative state rather than treating arrival order as the payment history. Stripe event ordering.

Track application state and provider state separately. Request acceptance does not prove recipient credit or bank settlement. Define the evidence needed to mark a payment settled.

Reconciliation compares payment intent, provider transaction, ledger entries, and settlement records. Preserve mismatches for review, including partial amounts, fees, returns, and late failures. Replaying an event must not create another financial effect. Refunds and compensating transfers are separate deliberate operations.

Case Expected behavior
Duplicate request or event One business effect.
Timeout after provider acceptance Retrieve or reconcile the existing operation.
Out-of-order delivery State follows authoritative evidence.
Worker interruption Durable work resumes without another transfer.
Unknown status or amount mismatch Preserve evidence and queue review.

Test these with synthetic events first. Then test the adapter in the provider’s supported environment and record the result. Bridge’s sandbox supports simulated payment activity and associated webhooks, without live bank or blockchain transactions. Use its documented simulation flow for adapter tests; simulated completion cannot prove OUSD settlement.

An illustrative application records payment P and submits one provider operation. Its response times out. Recovery looks up the stored reference instead of creating another payment. A completion event arrives, is verified and saved to an event inbox, then the worker crashes before posting the ledger entry.

After restart, the durable job fetches current provider state, checks the amount, asset, network and recipient against P, and posts once under a unique business key. A duplicate completion event or an older pending event triggers the same authoritative lookup; neither creates a second posting or reverses completion. An intent mismatch or a failed operation with possible prior effects stops automatic completion and goes to review.

The synthetic fixture below exercises those local state decisions. Its checks pass in the repository’s offline tests. The in-memory Map does not test database durability, actual webhook delivery or a provider integration. A real ledger needs an atomic transaction and a durable uniqueness constraint.

// Synthetic application fixture. No network calls, provider SDK or real money.
// The Map stands in for a durable unique ledger key; it does not test a database.
export function reconcile(intent, authoritative, postings) {
const fields = ['operationId', 'asset', 'network', 'recipient', 'amountMinor'];
if (fields.some(key => authoritative[key] !== intent[key])) return 'review';
const key = `${intent.provider}:${intent.environment}:${intent.operationId}`;
if (postings.has(key)) return 'posted';
if (authoritative.status === 'succeeded') {
postings.set(key, { amountMinor: intent.amountMinor, recipient: intent.recipient });
return 'posted';
}
if (['failed', 'rejected'].includes(authoritative.status)) return 'review';
return 'pending';
}
export function runFixture() {
const intent = { provider: 'example', environment: 'synthetic', operationId: 'op-1042',
asset: 'OUSD', network: 'example-mainnet', recipient: 'recipient-7', amountMinor: '10000000' };
const authoritative = { ...intent, status: 'succeeded' };
const postings = new Map();
// Simulate recovery after response loss and a crash before ledger posting.
// Both a replayed completion event and a late pending event trigger a fresh read.
const results = ['completion', 'completion-replay', 'older-pending'].map(() =>
reconcile(intent, authoritative, postings));
const mismatch = reconcile(intent, { ...authoritative, amountMinor: '9999999' }, new Map());
const ambiguousFailure = reconcile(intent, { ...authoritative, status: 'failed' }, new Map());
return { results, postingCount: postings.size, mismatch, ambiguousFailure };
}

After saving the download, print the fixture result with Node.js:

Terminal window
node --input-type=module -e "import {runFixture} from './reconciliation-fixture.mjs'; console.log(runFixture());"

Expected result: three posted decisions, postingCount: 1, and review for both the amount mismatch and ambiguous failure. There are no network calls or financial operations in this fixture. The application must separately test response-loss recovery and durable inbox/job storage against its real adapters.

See Privy’s documented OUSD/Tempo request and action lifecycle for a concrete provider path. Its HTTP example has not been submitted.

Page reviewed: 2026-10-10 · Modified: 2026-10-12. Individual sources retain their own review dates.

Sources & review notes

Stripe: Webhook delivery, signatures, and event ordering ↗

Stripe documents signature verification, duplicate events, asynchronous processing and non-guaranteed event ordering. These illustrate general concerns, not OUSD behavior.

Published: Unknown (undated source) · Reviewed: 2026-10-10 · Source-backed
Stripe: Idempotent requests ↗

Stripe documents idempotency keys for safe request retries; provider-specific details must not be assumed for OUSD.

Published: Unknown (undated source) · Reviewed: 2026-10-10 · Source-backed
Bridge: Set up a sandbox environment ↗

Bridge Sandbox supports simulated payment webhooks without live bank or blockchain transactions.

Published: Unknown (undated source) · Reviewed: 2026-10-12 · Source-backed