Duplicate collection usually starts with an unsafe assumption
An STK request is sent. The callback is late. The system assumes failure and sends a second request. The customer completes one or both. A late success then arrives for the first attempt.
The fix is not to ban retries. It is to make the retry decision depend on invoice state and provider evidence.
Keep one invoice and separate attempts
The commercial obligation should exist once. Every real payment action gets its own attempt ID and provider identifiers such as CheckoutRequestID.
Do not overwrite Attempt 1 when Attempt 2 is created. Immutable attempt history is what lets the system understand late callbacks.
UNKNOWN must block automatic retry
If the expected M-PESA Express callback has not arrived, query the request by CheckoutRequestID before deciding it failed. A missing callback is not a failure code.
Use idempotency for repeated client requests
If your own API client times out and repeats the same logical request, the server should return or recover the same attempt instead of creating a new one.
A genuine new retry should have a new logical request identity. The idempotency guide covers implementation patterns in more detail.
Make callback processing idempotent
The same provider event may be delivered more than once. Processing it twice should not issue two receipts, update service twice or create duplicate accounting entries.
Protect final state from stale events
If an attempt is already SUCCEEDED, a later stale PENDING event must not move it backwards. Define allowed state transitions and treat final states as monotonic.
Close the invoice atomically
When a success event arrives, use one guarded transaction: if the invoice is still unpaid, record the satisfying attempt and mark it paid. If it is already paid, classify the new success as a duplicate-payment exception.
Test the failure modes that actually create duplicates
| Failure test | What can go wrong | Expected protection |
|---|---|---|
| Client times out after STK initiation | Client resends same logical request | Idempotency returns/reuses existing attempt |
| Callback delivered twice | Success processed twice | Webhook dedupe / idempotent handler |
| Callback arrives late | Second attempt starts too early | UNKNOWN blocks retry; query first |
| Two retry workers run | Two new attempts created | Atomic claim/locking around retry eligibility |
| Customer pays via another route | Scheduled retry still fires | Re-check invoice state at execution time |
| Two wallets succeed | Invoice appears paid twice | Single-winner invoice closure; duplicate exception |
What if two wallets both succeed?
If M-PESA is unresolved, do not immediately offer Airtel Money. Wait until the first attempt is final or verified safe. If two successes still occur, the second one should become an exception for refund or credit treatment under merchant policy.
Customer messaging can prevent duplicate payment too
When the system is checking an earlier attempt, the customer should be told that no second payment is needed yet. Keep the reusable copy in the payer-experience article rather than duplicating it here.
Sources
- Safaricom M-PESA Express Query API: https://developer.safaricom.co.ke/apis/MpesaExpressQuery
- Safaricom M-PESA Express API: https://developer.safaricom.co.ke/apis/MpesaExpressSimulate
- Safaricom Daraja Getting Started: https://developer.safaricom.co.ke/apis/GettingStarted



