
A payment notification arrives. Your app creates a download entitlement, commits it, and sends a response. The connection drops before the payment service receives that response. A moment later, the same notification arrives again.
The retry is expected. Your fulfillment code needs to make it harmless.
For a digital entitlement stored in your database, use two identities: an event key to recognize repeated notifications and an order key to prevent a second fulfillment. Save the accepted event and its pending work together, then commit the entitlement and the completed task together. A worker can retry after an interruption without creating another entitlement for that order.
This guide walks through that design, relates it to StableIn’s merchant example, and includes a small Python/SQLite exercise you can run without a payment account.
Why an event ID alone leaves a gap
An event identifies a notification. An order identifies the thing you intend to fulfill. A payment event and a later partial-refund event can refer to the same order. Both may be valid inputs, but they should not each create another download entitlement.
| Record | Unique key | What it protects |
|---|---|---|
| Received event | Event identity | Processing the same notification repeatedly |
| Pending fulfillment | Local order ID | Scheduling separate fulfillment jobs for the same order |
| Entitlement | Local order ID | Granting the same order more than once |
Choose the scope of those keys deliberately. If one receiver serves multiple providers or merchant accounts, include the relevant namespace rather than assuming their event IDs are globally unique. If an order contains independently fulfilled items, use an order-line or fulfillment-unit key instead of copying this one-entitlement-per-order model.
Commit the receipt and the work together
First verify the signature against the original request bytes, then validate the event and bind it to your merchant and local order. Compare the expected network, asset and amount. A valid signature tells you who sent the message; the order checks establish whether it belongs to this purchase.
Inside one database transaction, record the event and insert its pending fulfillment task. Return a successful response after the commit. If you save the event first and enqueue work in a separate step, a crash between them can leave an event marked as received with no work scheduled.
The durable pending-work table is an outbox. For this design, the worker reads it directly. If you later publish tasks to a separate broker, the publisher also needs retry handling and a consumer that tolerates duplicates.

A repeated event with the same identity and expected payload can receive a successful acknowledgement. The same identity with a conflicting payload should be investigated. StableIn compares a hash of the received bytes; that comparison assumes the provider preserves those bytes on retries. Check your provider’s contract before adopting that exact rule.
Stripe’s documentation also describes duplicate event delivery and asynchronous processing. Its guidance is useful background here; StableIn has its own notification protocol and does not use Stripe to process these demo payments. See the Stripe webhook documentation.
Make fulfillment survive a worker retry
A worker should claim a pending task, recheck fulfillment eligibility and write the entitlement. A database unique constraint is the final guard against two workers granting the same order. A check such as “read first, insert if missing” needs a transaction and appropriate locking; two workers can otherwise both observe an empty result.
For a local database entitlement, write the entitlement and mark the task complete in the same transaction. This shortened SQLite example assumes the pending task and current eligibility have already been checked inside that transaction.
BEGIN IMMEDIATE;
INSERT INTO entitlements(order_id)
VALUES ('order-1')
ON CONFLICT(order_id) DO NOTHING;
UPDATE outbox
SET status = 'fulfilled'
WHERE order_id = 'order-1';
COMMIT;
An interruption before commit leaves neither change committed. An interruption after commit leaves both records available to the next worker. The downloadable example includes the missing checks and rollback handling. SQLite permits one writer at a time, so its concurrent tests demonstrate this exercise’s behavior, not the locking performance of a MySQL deployment. See SQLite transaction behavior.
How the StableIn merchant example handles it
StableIn’s merchant example uses MySQL/InnoDB. Its receiver locks the local order, checks the event identity and payload hash, and fetches the current payment order before recording the event and scheduling work. The outbox and shipment tables each use the local order ID as a primary key.
The worker claims work with a lease owner and a lease version. Before committing, it checks that it still owns a valid lease, fetches and validates the order state, and rejects an older snapshot than the locally recorded revision. It writes the local entitlement and the task outcome within a transaction. These checks address a slower worker returning after another attempt has taken over.
Refund timing matters too. A full refund known before fulfillment prevents a new entitlement. A stale “paid” notification should not overwrite a newer refund state. Those checks do not make an external payment service and the merchant database one atomic system. A refund after an entitlement has been granted needs a separate business rule; this example does not automatically revoke an already-issued download.
The product evidence comes from a local EVM demonstration using test assets and simulated finality. For this article, the four existing merchant rule tests were rerun successfully. The full chain and database end-to-end suite was not rerun. This is an implementation walkthrough, not a production security assessment. MySQL’s locking-read documentation explains the database mechanism used by the merchant example.
Run the small example and keep the failure checklist
Download the database idempotency exercise and checklist. It uses Python’s standard library, temporary SQLite databases and synthetic data. It includes no server, wallet connection or payment credentials. Run it from the extracted directory with python3 -m unittest -v.
All nine tests passed locally on 27 October 2026. The interruptions are injected exceptions that trigger rollback; they are not power-loss tests. The exercise starts after payment verification and does not implement signatures, provider reconciliation, leases or a production HTTP receiver.
| Test | Expected result |
|---|---|
| Response lost after commit; same event retried | One event, one pending-work record, one entitlement |
| Different event IDs for the same order | Both events retained, one entitlement |
| Same event ID with changed bytes | Conflict rejected |
| Interruption between event and task writes | Both writes roll back; retry can recover |
| Interruption between entitlement and completion writes | Both writes roll back; retry can recover |
| 20 receiver calls across 8 worker threads | One accepted event, 19 recognized duplicates |
| 20 fulfillment calls across 8 worker threads | One entitlement |
| Order becomes ineligible before the worker runs | No entitlement |
| Unknown local order | No event or task committed |
Keep external side effects outside the guarantee
An email provider or shipping API cannot join your local database transaction. If the external call succeeds and your worker stops before recording that success, a retry may repeat the action. Pass a stable idempotency key when the downstream service supports it, keep its operation identifier and reconcile uncertain results before resubmitting.
For a download product, one durable entitlement can remain the source of access even if a notification email is sent again. Decide which effect must be unique, and test that effect directly.
If you are diagnosing a payment that has already stalled, start with the payment-to-delivery troubleshooting guide. To see the checkout, merchant operations and recovery workflow behind this example, explore StableIn Payments. For an integration discussion, bring your current order API, the delivery action and the refund rules your team needs to support.