Idempotency means repeating the same logical request does not create a second commercial effect
Payment systems see duplicates naturally: clients retry, queues redeliver, callbacks repeat and workers restart. The goal is not to prevent repeated delivery. It is to make repeated delivery safe.
Protect invoice creation
A monthly billing job should not create two November invoices because it ran twice.
UNIQUE (merchant_id, customer_id, billing_rule_id, billing_period)
The exact key depends on the product, but it should encode the merchant's definition of one commercial obligation.
Protect payment-attempt creation
When a client repeats the same request after a timeout, reuse the logical attempt rather than creating another provider call.
def create_attempt(obligation_id, idempotency_key):
existing = find_by_idempotency_key(idempotency_key)
if existing:
return existing
return insert_attempt(
obligation_id=obligation_id,
idempotency_key=idempotency_key,
status="INITIATED",
)
Implementation note: the lookup-then-insert example is simplified pseudocode. In production, enforce a database uniqueness constraint on the merchant-scoped idempotency key and make insert-or-get atomic; otherwise concurrent requests can both pass the lookup. Claim provider initiation only once for that attempt. If a provider call may have been accepted but its response was lost, mark the attempt UNKNOWN and check supported provider evidence or escalate rather than issuing a second charge. A timed-out worker lease alone cannot establish that the provider did not process the first request.
Do not reuse the same key for a genuine retry
A real second payment attempt is a new commercial action. It should receive a new logical request identity. Replaying Attempt 1 and creating Attempt 2 are different operations.
Make provider callbacks idempotent
Store a stable provider event/transaction identity and check whether the logical event has already been applied.
def handle_callback(event):
with db.transaction():
if callback_already_processed(event.dedupe_key):
return
persist_raw_event(event)
apply_state_transition(event)
mark_callback_processed(event.dedupe_key)
Enforce a database unique constraint on the provider's stable event or transaction identity; a read-before-write deduplication check alone does not prevent concurrent callback workers from both acting.
Use a single-winner transition when closing the invoice
def apply_success(obligation_id, attempt_id):
with db.transaction():
obligation = lock_for_update(obligation_id)
if obligation.status == "PAID":
record_duplicate_success(attempt_id)
return
mark_attempt_succeeded(attempt_id)
mark_obligation_paid(
obligation_id,
satisfying_attempt_id=attempt_id,
)
This protects against two workers or two wallet attempts succeeding close together.
Idempotency needs a lease when work crosses systems
A worker may reserve an attempt, call the provider and crash before saving the response. Use an atomic claim or lease token so a second worker can tell whether work is genuinely abandoned or still active.
Keep idempotency and provider status separate
Idempotency answers “is this the same logical action?” Payment status answers “what happened to the provider transaction?” You need both.
Test the dangerous cases
- Client timeout after the provider accepted the request.
- Queue message delivered twice.
- Callback replay.
- Two retry workers claim the same invoice.
- Two wallet attempts succeed nearly simultaneously.
- Worker crashes after external side effect but before local commit.
Idempotency does not replace business uniqueness
An idempotency key protects one repeated request. A separate business constraint can protect one recurring obligation for the same customer and billing period. Use both where the model requires them: request-level idempotency for retries, and database uniqueness for commercial invariants.
That distinction matters when two different workers accidentally try to create the same November invoice with different request IDs.
Related guides: preventing duplicate M-PESA collections, the recurring mobile-money payments API and asynchronous payment state.



