DEV Community

Shubham
Shubham

Posted on Originally published at shubhkumar.in

How to Reconcile Unknown Payouts: Webhooks, Status Polling, and Ledger Evidence

A payout timeout is not a payout failure. It is an unknown outcome: your application did not receive a final answer, but the payment provider or bank may still have accepted and processed the transfer.

The safe response is not to retry with a new reference, restore the user’s balance, or hide the record. It is to preserve the payout, reserve the money, and reconcile the outcome from independent evidence.

This article turns that principle into an operating model: accept webhooks as timely signals, poll provider status as a recovery path, and use your internal ledger as the durable record of what your product has committed.

What makes a payout unknown?

Money movement crosses application, provider, bank, and recipient-bank boundaries. A connection can fail at any boundary. The following cases can look identical to a caller:

  • The provider never received the request.

  • The provider received it but rejected it before processing.

  • The provider accepted it and is still processing it.

  • The payout succeeded but the HTTP response was lost.

HTTP status alone therefore cannot be your final settlement authority. A local timeout is evidence about the connection, not evidence that money did not move.

create payout → request leaves application → timeout
                                         ↓
                                outcome unknown
Enter fullscreen mode Exit fullscreen mode

The failure mode to prevent is straightforward: mark the payout failed, release the balance, and allow another payout. If the original instruction was accepted, the user can withdraw the same money twice.

Use explicit states, not an overloaded “pending”

Providers use different labels, so map their statuses into a small internal state machine. Do not use one word to mean both “we have not sent it” and “the bank may have funds in flight.”

Internal state

Meaning

Funds treatment

Next action

created

Intent recorded; provider call has not begun.

Reserve funds.

Submit once.

submitted

Call began or its response was lost.

Keep reserved.

Retry only with the same idempotency key; reconcile.

pending

Provider acknowledged processing; final bank outcome is open.

Keep reserved.

Await webhook and poll.

succeeded

Reliable evidence confirms the payout.

Finalize debit.

Notify and retain evidence.

failed

Reliable evidence confirms no payout completed.

Release or reverse reservation.

Expose reason; allow a new intent.

review

Evidence conflicts or remains absent beyond policy.

Keep reserved unless risk policy says otherwise.

Manual investigation.

The crucial rule is: only terminal evidence can move an unknown payout to failed. A client-side exception cannot.

Start with a durable payout intent

Before making an external call, write a durable payout-intent record in the same database transaction that reserves the customer balance. Give it an immutable internal ID and a unique idempotency key. Store the amount, currency, beneficiary version, rail, request hash, and timestamps.

payout_intent_id: po_8f...
idempotency_key:  7c0c...UUID
amount:           250000
currency:         INR
state:            created
request_hash:     sha256(canonical request)
provider_payout_id: null
Enter fullscreen mode Exit fullscreen mode

Use an idempotency key for one business operation, not one HTTP attempt. If a request times out, retry the same operation with the same key and identical payload. A new key can create a new transfer. Keep your own idempotency record longer than a provider’s documented retention window; provider keys are a control, not your complete audit trail.

For example, Stripe documents idempotent POST requests as a way to safely retry after connection errors, and it compares parameters when the key is reused. RazorpayX documents idempotency keys as mandatory for payout requests. The exact mechanics differ, but the design requirement does not: retries must refer to the original intent.

Webhooks are signals, not a complete database

Webhooks reduce time to resolution. A provider can notify you when a payout reaches a terminal or intermediate state without your application maintaining a tight polling loop. But webhook delivery is asynchronous and operationally imperfect: events can arrive late, be duplicated, be delivered out of order, or fail while your endpoint is unavailable.

Build a webhook ingestion path that is deliberately boring:

  1. Verify the provider signature against the raw request body.

  2. Persist the event and its provider event ID before business processing.

  3. Return a 2xx response quickly.

  4. Process the event asynchronously and idempotently.

  5. Record the raw payload, verification result, received time, and correlation IDs.

Never let a user-facing payout page depend on synchronous webhook handling. The handler should acknowledge a valid delivery quickly, then put work on a durable queue or outbox. Stripe similarly recommends returning a successful response before complex logic.

Deduplicate and order defensively

Create a unique constraint on (provider, provider_event_id). A second delivery should become a harmless no-op. Do not assume arrival order. If a succeeded event is processed before an earlier pending event, the later delivery must not regress the payout.

Use monotonic transition rules. A terminal state should not move backward from a stale event. When providers include event timestamps, sequence values, or resource versions, retain them as evidence but still validate that a proposed transition is permitted by your state machine.

Status polling closes the webhook gap

Polling is not a replacement for webhooks. It is the reconciliation backstop for lost events, endpoint outages, and ambiguous submissions. Poll a provider’s “fetch payout” endpoint using the provider payout ID when available. If the submission response was lost, search using a provider-supported idempotency key, reference, or a narrowly bounded query. Never infer that “not found” means “failed” unless the provider explicitly guarantees that meaning for the relevant time window.

A practical cadence is tiered:

  • Immediately after an uncertain submit: retry the same idempotent request or query the original reference.

  • Active pending payouts: poll with exponential backoff and jitter.

  • Ageing payouts: lower-frequency polling until the rail’s resolution SLA.

  • Daily reconciliation: compare all open and recently terminal payouts with provider data.

Rate limits matter. Schedule polls by next-check time, batch where supported, and prioritize high-value or customer-visible payouts. Persist every observation rather than overwriting it. The observation history is what lets engineers explain why a state changed.

The ledger is your evidence of product-level truth

A provider dashboard tells you what the provider currently reports. Your internal ledger tells you what your product promised, reserved, and recognized. You need both.

A robust ledger is append-only. Instead of changing a balance in place, post balanced entries tied to a payout intent. A simplified flow looks like this:

  1. On intent creation: debit available customer funds; credit payouts reserved.

  2. On confirmed success: debit payouts reserved; credit provider clearing or an equivalent settlement account.

  3. On confirmed failure: debit payouts reserved; credit available customer funds.

The names vary by business model. The invariant is more important: every movement balances, every posting is traceable to an intent, and corrections are new reversing entries not edits to history.

Do not post the final debit merely because an API call returned 200. Equally, do not release the reservation merely because your client timed out. The ledger state should reflect the strongest evidence currently available.

Build a reconciliation worker

Reconciliation should be a scheduled, observable workflow not an emergency script. Its job is to find mismatches between internal payout intents, provider records, webhook observations, and ledger postings.

For each non-terminal payout, the worker should:

  1. Load the internal intent and its latest known provider identifiers.

  2. Fetch or search the provider record using safe correlation fields.

  3. Normalize the provider status into the internal state model.

  4. Validate amount, currency, beneficiary, rail, and provider reference.

  5. Apply only an allowed state transition in one transaction.

  6. Post the matching ledger entry exactly once.

  7. Save the provider response or a cryptographic reference to it as evidence.

  8. Escalate if the result conflicts, is missing past the SLA, or violates an invariant.

Terminal does not mean forgettable. Continue reconciling recent successes and failures against settlement or provider reports. Some rails expose reversals, returns, or delayed corrections. Model these as explicit follow-on events, not as silent edits to a prior success.

Evidence hierarchy and conflict handling

Define which evidence can decide each state transition before incidents happen. A sensible hierarchy may look like:

  1. Provider settlement or reconciliation report, where available.

  2. Authenticated provider status API response.

  3. Authenticated webhook event tied to the provider payout ID.

  4. Original synchronous API response.

  5. Application logs and network telemetry.

This is not universal. Your provider contract and payment rail determine the actual authority. The point is to write down the hierarchy and treat lower-confidence evidence as a reason to investigate rather than a reason to release funds.

If a webhook says success but a status query says pending, retain both observations, avoid a destructive transition, re-query after a short delay, and alert when the discrepancy persists. If the provider says success but the amount or beneficiary differs from the original request, lock the case for review. A reconciliation worker should prefer an unresolved, visible exception to an automatic financial mistake.

Operational controls that prevent expensive incidents

  • Unique business reference: one payout intent per withdrawal or invoice obligation.

  • Idempotent processing: both outbound creation and inbound event handling must tolerate replay.

  • Outbox pattern: publish internal events only after the payout-intent transaction commits.

  • Audit retention: keep request hashes, raw webhook payloads, provider responses, actor IDs, and transition reasons.

  • Alerts: monitor unknown payouts by age and value, webhook verification failures, polling errors, duplicate-event rates, and ledger imbalance.

  • Runbooks: specify who can resolve a payout, what evidence they need, and how every manual action is recorded.

A safe recovery path for a timeout

1. Write intent + reserve funds.
2. Submit with idempotency key K.
3. Connection times out.
4. Mark submitted/unknown; do not release funds.
5. Retry with K or fetch provider record.
6. Accept verified webhook when it arrives.
7. Reconcile API result, webhook, and ledger.
8. Finalize success or failure only from reliable evidence.
9. Escalate unresolved or conflicting cases.
Enter fullscreen mode Exit fullscreen mode

This sequence may delay a small number of customers. It avoids creating a much larger class of duplicate-payout losses.

Conclusion

Unknown payouts are normal in distributed money movement. Treat them as a first-class state, not as an error-handling inconvenience. Webhooks make outcomes arrive sooner; status polling recovers missed information; an append-only ledger proves what your product has done; and idempotency prevents a recovery attempt from becoming a second payment.

The goal is not to make uncertainty disappear. It is to contain it: keep funds reserved, preserve evidence, reconcile deliberately, and make terminal financial decisions only when the system can justify them.

Further reading

Powered by Mole. AI Agent for SEO & GEO

Top comments (0)