One checkout keeps one payment intent
A tap is a user action, not a payment ID. A second tap, browser refresh, mobile reconnect, or queue retry for the same unpaid checkout must find the existing operation. Create a new operation only for a distinct approved purchase, or a new attempt after the earlier attempt has a final outcome that permits it. A timeout is not that outcome.
Give the checkout a durable order ID before sending money. In one database transaction, find or create its active payment operation, save a random provider key, and bind the amount, currency, customer, recipient, and operation type. Put a unique rule on the order's active attempt so two devices cannot create competing charges. A client key is useful, but the server must also reject a new key that tries to start a second active operation for that order.
Keep request identity and business identity separate
The business operation ID answers which purchase is being paid. The provider key answers which provider request is being retried. Store both. Scope the client key to the tenant and operation type, then save a canonical digest of the money instruction. Amounts use integer minor units. A changed recipient, amount, or currency under the same key must return a conflict before any provider call.
A completed payment remains linked to its order even after a provider key expires. A new key must not reopen a paid order. For a failed attempt, keep its record and create a separate attempt only after checking the final provider state and the order's payment rules. This preserves the earlier evidence and lets support explain each attempt.
Close the database and queue crash gap
Saving an operation and then publishing a queue message are two writes. A crash between them leaves an operation that no worker sees. Publishing first creates the opposite risk: a worker receives work before its operation exists. Use a durable outbox: commit the operation and an unsent work row in the same database transaction. A dispatcher reads committed outbox rows, sends them, and marks them delivered.
A dispatcher can crash after sending but before marking delivery, so the worker must accept duplicate messages. It claims the saved operation with a lock or version check, loads the original key and instruction, and calls the provider under that provider's retry contract. Marking an outbox row delivered proves dispatch, not payment success. A repair job must find stale operations and unsent work. Never keep a database transaction open across a slow provider call.
Recover when the provider accepted the request
The second crash gap is after provider acceptance but before saving its reply. Persist the reference before dispatch when the API supports a caller reference. Otherwise keep the original idempotency key and use the provider's supported recovery method. An unknown result stays pending while a lookup, callback, or reconciliation resolves it.
Stripe caches the first response for a key, including a 500 error, and compares reused parameters. A repeated error therefore does not prove that no payment object exists. Its key retention also does not replace your durable order history. Confirm the provider state before sending a new request after key expiry.
Run a synthetic checkout test
Use order O-17 for ₦8,000, operation P-17, and provider key K-17. Inject each failure in staging; inspect the order, outbox, provider object, and journal after recovery.
| Action | Expected result |
|---|---|
| Tap Pay twice from two devices | Both requests return P-17; one active operation and one provider charge. |
| Crash after operation and outbox commit | The dispatcher later sends saved work using K-17. |
| Deliver the queue message twice | The worker reuses P-17 and K-17; it does not create a second charge. |
| Lose the reply after provider acceptance | The original operation stays pending until provider evidence resolves it. |
| Change ₦8,000 to ₦9,000 under K-17 | A conflict is returned; no second provider request runs. |
| Retry a paid order with a fresh client key | The existing paid result is returned; the order cannot be charged again. |
Then crash after the journal commits but before the API replies. Recovery returns the saved operation and leaves one journal. Case PAY-02 in the synthetic payment test pack covers the lost reply. Add the outbox cut points above to test the full path.
Return a state the caller can use
Return the operation ID and current state on every retry. Pending means the result is still being checked. Succeeded means provider evidence and your posting rules support completion. A validation error before an operation exists follows a separate documented input rule. Keep the original key, request digest, attempt history, provider reference, and journal ID so support can trace the outcome.
Sources and next step
Use Stripe's idempotent request contract for Stripe calls and PostgreSQL's isolation guide for concurrent database work. Trace one checkout from its order ID through outbox delivery and provider recovery. For a review of the full path, see payment gateway testing.