Logo

Idempotency: Why the Same Callback Arrives Twice

Networks retry, servers restart, and delivery is at-least-once by design. Handling that is four lines of code and the difference between shipping once and shipping twice.

August 8, 20265 min readAIO Research Team
Idempotency: Why the Same Callback Arrives Twice

At some point your server will be told the same payment succeeded twice. This is not a bug in the platform. It is the design.

Why exactly-once does not exist

The platform sends a callback and waits for a 200. If none arrives it cannot tell the difference between two situations: your server never received it, or your server processed it perfectly and the acknowledgement was lost on the way back.

Those are indistinguishable from the outside. So the platform retries, because a duplicate delivery is recoverable and a missed payment notification is not.

Every payment system worth using behaves this way. The correct response is not to complain about it but to make duplicates harmless.

The pattern

Pick a stable identifier from the payload. Usually the event id, or the payment id combined with the state.

Record it with a unique constraint before doing the work:

INSERT INTO processed_events (event_id) VALUES ($1);
-- unique violation means we have already handled this one

If the insert succeeds, you own this event, so do the work. If it violates the constraint, somebody already handled it, so return 200 and stop.

The database is doing the hard part. A unique index is atomic in a way that a check-then-act in application code is not.

The mistake this avoids

if not order.paid:      # check
    fulfil(order)       # act
    order.paid = True

Two duplicate callbacks arriving at the same moment both read paid = False, both fulfil, and you have shipped twice. It looks correct and it is a race. The unique constraint removes the window entirely.

Where else it matters

  • Outgoing payouts. A retried payout request must not send twice. Use a client-side idempotency key on the request.
  • Refunds. Same problem, worse consequences.
  • Reconciliation jobs. A job that runs twice should converge on the same state, not double anything.

Keeping it tidy

The processed-events table grows forever if you let it. Keep a retention window comfortably longer than the platform's retry ladder, then prune. A few days is usually generous.

How to test it

Fire the same valid callback at your endpoint twice in a row. The order should move once. Then fire twenty in parallel. If anything ships twice, the check-then-act pattern is still in there somewhere.

Frequently Asked Questions

What is idempotency in payments?

Idempotency means processing the same message more than once has the same effect as processing it once. It matters because webhook delivery is at-least-once by design, so a handler that is not idempotent will eventually fulfil an order twice.

Why do payment platforms send the same webhook twice?

Because the alternative is worse. Guaranteeing exactly-once delivery over an unreliable network is not possible, so platforms retry until they get an acknowledgement. That means a callback your server processed but failed to acknowledge in time will be sent again.

How do I make a webhook handler idempotent?

Key on a stable identifier from the payload, usually the payment or event id. Record it in a table with a unique constraint before doing the work, and treat a duplicate insert as a signal to stop and return success.

What is the difference between deduplication and idempotency?

Deduplication drops the repeat message. Idempotency makes the repeat harmless whether or not you detect it. Idempotency is the stronger property because it survives cases where the duplicate arrives in a different process or after a restart.

Related News

Continue exploring the latest updates and insights from our blog.