FifeRouter

2 September 2026 · ledger money

Idempotency you get from an index, not from care

A unique index and one INSERT. The version that checks first is the one that looks correct and is not.

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:

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.


← All posts