Why did the same payment callback arrive twice?
A provider may retry after a timeout or when it does not receive the expected acknowledgement. Your service might have committed the payment just before that response was lost. A proxy can close the connection while your worker continues. The sender sees an uncertain delivery result, even though your database already records success.
Concurrency creates another path: a retry can arrive while the first request is still running, and different application instances may process both. Manual redelivery during incident recovery can overlap with automatic retries. Check the delivery rules for your approved integration; arrival order and delivery count should not determine whether money is credited.
Two deliveries can describe one successful payment. Two distinct provider transaction references for the same merchant order need reconciliation, since the customer might have paid twice. Keep the event identifier, provider transaction reference and merchant order reference separate: they answer different questions.
Verify the payment, amount and currency
Authenticate each delivery using the provider's documented signature procedure. Preserve the raw request body when verification requires it, use the correct environment and merchant credentials, and apply documented timestamp or replay checks. An event ID is a deduplication key, not proof that the message is authentic.
Resolve the merchant order from your own database, then compare the account, provider reference, confirmed status, amount and currency. Use integer minor units or exact decimal arithmetic according to the API's documented units and currency precision. Avoid floating-point equality and never infer currency from a symbol or the customer's location.
A valid signature does not make a mismatched amount acceptable. Persist the exception for review without automatically fulfilling the order. For Indonesia virtual account collections, define underpayment and overpayment handling explicitly; see the VA wrong-amount guide. Do not replace the expected order amount with the incoming value simply to make validation pass.
Use database constraints to make processing idempotent
Idempotent handling means repeated execution produces the same intended business result. An application-level “already seen?” lookup is insufficient: two workers can both read “no” before either writes. Enforce uniqueness in the database so competing requests cannot both claim the same event.
- Insert before processing. Create an inbox row with a unique constraint on provider, merchant account and stable event ID. Insert atomically using your database's conflict-handling mechanism. A duplicate-key result means an existing record owns that event; it does not mean processing has finished.
- Preserve recoverable state. Store the verified event, references, receipt time and processing status. Separate pending, completed and failed work. Retrying delivery must not delete or reset completed records. Keep enough history for the documented redelivery window and your reconciliation needs.
- Commit the business change together. In a worker transaction, lock the inbox row and order, or use an equivalent conditional update. Recheck eligibility, amount and currency. Record the ledger operation, order transition, fulfilment outbox entry and completed event status atomically; roll back together on failure.
Event uniqueness alone is insufficient if separate events describe the same payment. Add a business-level unique constraint, such as merchant account, order and credit operation, for a single-payment order model. Model partial payments and multiple captures explicitly if supported. Detect a second distinct payment instead of silently discarding it as a duplicate.
External shipping, email or wallet calls cannot be rolled back with your database. Write an outbox task in the transaction and dispatch it afterward. Give each side effect a stable operation key and require downstream deduplication or reconciliation. A worker crash after an external call must not cause another shipment or credit on retry.
Acknowledge promptly, then process asynchronously
Keep the request path short: authenticate, validate the envelope, commit the inbox record, then return the acknowledgement specified by the provider. Business processing can run afterward. “Acknowledge first” means before slow fulfilment, not before durable storage. If the database is unavailable, follow the documented failure contract so the sender can retry.
A database inbox can itself be the durable work source. If you also publish to a queue, use a transactional outbox or a scanner that recovers unprocessed rows; committing an event and then losing the queue publish must not strand it. Workers should retry transient failures with backoff and route unresolved exceptions to review.
For an authenticated duplicate, confirm that the inbox record already exists and return the documented acknowledgement without repeating the business action. Monitor pending age and failed jobs separately from HTTP response success. A successful acknowledgement proves receipt, not completed fulfilment.
Handle conflicting events and missing callbacks
Define allowed order transitions instead of letting the latest arrival overwrite state. An older pending event must not downgrade a verified paid order. For example, two notifications for the same MYR 120 payment should leave one credit. Different references or conflicting final results require investigation; refunds and reversals follow their own documented lifecycle.
When a callback is missing or uncertain, use the provider's authenticated order-status query with the stored order or transaction reference. Schedule reconciliation for unresolved orders using bounded retries and the provider's query limits. A timeout or unavailable query is not evidence of payment failure, and a customer screenshot does not establish final status.
Match the query result against the same account, amount and currency checks, then use the same guarded transaction and business key as webhook processing. The query and a late callback may race; database constraints must still allow only one credit. Record the evidence source and query time for investigation. Review Indonesia payouts separately when defining outbound transfer states.
Pre-launch testing checklist
Use sandbox events or controlled fixtures with known expected outcomes. Check the order, ledger, inbox, outbox and provider response for every case:
- Duplicates: send the same event sequentially and to concurrent workers; verify one credit and one fulfilment task.
- Lost acknowledgement: drop the response after the inbox commit, then redeliver; verify recovery without duplicate effects.
- Out-of-order delivery: deliver success before an older pending event; verify the paid state is preserved.
- Delayed or missing delivery: reconcile through a status query, then deliver the callback; verify one business operation.
- Signature errors: alter the payload or use the wrong secret; verify rejection and no trusted event or credit.
- Mismatches: send an unexpected amount, currency or merchant reference; verify an exception without automatic fulfilment.
- Crash recovery: stop workers before and after transaction commit and interrupt dispatch; verify pending work resumes safely.
Retain sanitized references, timestamps and failure reasons for support. Alert on stalled processing and unresolved reconciliation, and avoid logging secrets or unnecessary personal data.
Related reading: Local payment API integration checklist and Indonesia payment troubleshooting.
For provider-specific delivery guidance, see Stripe's webhook documentation. Use your approved provider's documentation for field names, authentication and acknowledgements.
