Stripe retries webhooks until it gets a 2xx. The reconciler re-reads decision records it has already billed. Both of those are normal, and both mean the same operation arrives more than once.
The fix is four words of DDL:
CREATE UNIQUE INDEX ledger_dedupe_idx ON ledger (dedupe_key)
WHERE dedupe_key IS NOT NULL;
Everything else follows from it.
The version that looks right
if await already_posted(key):
return
await post_entry(key, ...)
Read, decide, write. Correct in a single-threaded story, and wrong in the one that matters: two deliveries of the same webhook arriving at once. Both read, both find nothing, both write. Stripe retried and you credited twice.
The window is small and the traffic that hits it is exactly the traffic you would rather not get wrong — retries cluster, because whatever caused the first delivery to be slow is still happening.
Making the check-then-write atomic means a transaction and a lock, which is the database doing what the unique index would have done, with more moving parts and a lock you now have to reason about.
Returning False is not an error
async def post(account_id, kind, amount, description, *, dedupe_key=None):
...
# returns False when dedupe_key has already been posted
The duplicate case returns False, and callers treat that as an ordinary
outcome. That is the design decision, not the index.
If a replay raised, every caller would need a try/except around it, and the
first person to write except Exception: pass would swallow real failures with
it. Making "already done" a boolean rather than an exception keeps the
exceptional path exceptional.
You can see it in the webhook's return value:
{"ok": true, "credited": false, "reversed": 0, "restored": 0}
credited: false on a replayed event. Nothing went wrong; the work was already
done.
Choosing the key
The key is where the thinking is. It has to name the thing that happened once, and that is not always the message that told you about it.
| Event | Key | Why |
|---|---|---|
| A top-up | stripe:{event_id} |
one payment, one event |
| A refund | stripe:refund:{refund_id} |
not the event |
| A dispute | stripe:dispute:{dispute_id} |
one dispute |
| A won dispute | stripe:dispute-won:{dispute_id} |
its own motion |
| A grant | grant:{account_id} |
once per account, ever |
The refund row is the one that cost us. charge.refunded fires again for every
subsequent partial refund and carries the cumulative amount_refunded. Keyed
on the event id, two £5 refunds would reverse £5 and then £10 — £15 taken back
from a £10 payment.
Keyed on the refund object, each refund is reversed exactly once and the cumulative figure is never used. The event is the notification; the refund is the thing that happened.
grant:{account_id} is the other interesting one, because the key encodes a
business rule rather than an external id. One grant per account, ever, enforced
by the same index — so a replayed signup, a retried request, or somebody calling
the function twice all collapse to one.
Where the guarantee lives
This is the same argument as the CHECK constraints on ledger signs. The
property is held by a schema object, so it survives:
- a caller who forgets to check
- two callers racing
- a refactor that moves the logic
- a new code path nobody thought about
None of which is true of a property held by an if statement at the top of one
function.
The rule of thumb it left us with: when you catch yourself writing a comment that says "this is safe because we always check first", the sentence is describing a constraint you have not written down yet.