Handling duplicate payment callbacks without crediting an order twice

A provider-neutral guide to durable references, duplicate events, database idempotency and safe payment reconciliation.

A payment integration needs a plan for events arriving twice, arriving late or never reaching your application. A callback tells your system about a payment event. Your application still has to decide whether that event belongs to the expected transaction and which business action it permits.

Here is a provider-neutral design for a Nigerian merchant collecting a fixed NGN amount.

Start with a durable payment attempt

Imagine an order for NGN 25,000. Before requesting payment instructions, your application saves an attempt containing the merchant, order, unique merchant reference, expected currency, expected amount and current state. In this example, the amount is stored as 2,500,000 kobo, an integer. Confirm the API's amount units separately.

Keep the order identity, payment-attempt identity and provider transaction identity separate. An order may have multiple attempts. A customer may also genuinely pay twice; your records need to distinguish that from receiving one payment notification twice.

Verify, record, then process

At the callback endpoint, apply the provider's documented authentication and replay checks before trusting the event. Match the merchant, transaction, reference, amount and currency to the saved attempt. An unknown reference or mismatched amount should enter an exception workflow rather than automatically fulfil an order.

Persist an accepted event durably before acknowledging receipt. Keep the request path short and move slower work to a worker. If durable storage fails, use the provider's documented failure behaviour so the event is not silently lost.

Use a unique event identifier where one exists. Also protect the business operation: different events can refer to the same payment. Your deduplication key should identify the operation, such as crediting a particular provider transaction, within the correct merchant scope. A later refund or reversal is a separate operation.

Make concurrent delivery safe

Consider two workers receiving the same successful-payment event at nearly the same time. Both can read “not processed yet” before either writes anything. An application-level check alone leaves a race.

Enforce uniqueness in the database. PostgreSQL supports unique constraints across a group of columns. [2] In one database transaction, claim the relevant operation, write its ledger effect and update the payment state. A competing duplicate must be handled as already applied, without a second credit.

For our synthetic order, the first valid success produces one NGN 25,000 credit. Replaying it ten times still produces one credit. A second, genuinely distinct payment should be recorded separately and flagged against the already-paid order.

Keep fulfilment recoverable

Updating the database and sending a fulfilment message are separate writes. A crash between them can leave a paid order without its next action. A transactional outbox records the state change and an outgoing work item in the same database transaction. A worker then delivers that item. AWS documents this pattern and notes that consumers must still handle duplicate messages. [3]

Give fulfilment its own idempotency boundary. Retrying a delivery job should not dispatch another parcel or issue another entitlement.

Reconcile missing and uncertain results

Run a scheduled review of unresolved attempts. Query the provider using its supported transaction or merchant reference, validate the response against your saved record, and apply the same transition rules used for callbacks.

A timeout leaves an uncertain outcome. Keep it pending for investigation or another bounded check. For payouts, retain the distinction between a request accepted for processing and confirmed completion. Never create a fresh transfer merely because the first response was lost without first resolving its status or following documented idempotent retry behaviour.

Set an escalation threshold, retain a useful audit trail and avoid logging secrets or unnecessary customer details. Reconcile settlement reports separately where available; a transaction-status lookup alone does not prove settlement to your bank account.

A practical acceptance checklist

  • Replay the same callback sequentially and concurrently.
  • Deliver an older event after a newer state has been recorded.
  • Simulate a database failure before acknowledgement.
  • Crash a worker after its database commit and before delivery acknowledgement.
  • Test an unknown reference, a wrong amount and a second genuine payment.
  • Recover an unresolved payment through status lookup.
  • Confirm that payout timeouts do not trigger duplicate transfers.

For each test, inspect the ledger and fulfilment records, not just the HTTP response. The useful outcome is a consistent, explainable business record after retries and recovery.

Sources for the article

  1. Stripe: receive webhook events, duplicate events and event ordering. Cited as a concrete provider example, not as a statement of Velvpay's contract.
  2. PostgreSQL: constraints. Database mechanism supporting the proposed design.
  3. AWS Prescriptive Guidance: transactional outbox pattern. Recoverable publication and idempotent consumers.

The worked example and acceptance checklist are an illustrative design proposal, not results from an integration test.

Prepared with AI assistance. The worked example is synthetic and was not run against a live payment provider.


I work on Velvpay. This article covers general integration design rather than a provider-specific implementation. See the public integration overview and the API documentation.

Get weekly payment-operations guides and practical updates by email. Unsubscribe anytime.