Two Webhooks, One Rank: Race-Safe Payments with Postgres Advisory Locks
Payment webhooks get retried. Users double-click. Two rivals outbid each other in the same second. If your "apply payment" path isn't concurrency-safe, you get double-applied bids, phantom ranks, and money that doesn't match the leaderboard.
I run Steal the Spot — a pay-to-rank leaderboard where every bid is money and every rank is public. Here's the exact pattern that keeps our board correct, using Supabase Postgres + Dodo webhooks. Three ingredients, all in one function.
1. Serialize with a transaction-scoped advisory lock
Two bids landing "at the same time" must see each other, or both compute rank against a stale snapshot:
perform pg_advisory_xact_lock(hashtext('steal-bid:' || p_season_id));
Why pg_advisory_xact_lock and not pg_advisory_lock: the _xact_ variant auto-releases on commit/rollback. A crashed worker can't leave the board locked forever. One lock per season keeps unrelated seasons parallel.
2. Make the webhook idempotent on payment_id
Payment providers retry webhooks. Ours checks before doing anything:
if exists (select 1 from public.bids where payment_id = p_payment_id) then
return jsonb_build_object('already_applied', true, ...);
end if;
Retry #2 returns success without touching money. The provider stops retrying, the board never double-counts. This check lives inside the locked transaction, so two concurrent deliveries of the same webhook can't both slip through.
3. Lock the row, compute rank, fail closed
select * into v_listing
from public.listings
where id = p_listing_id and season_id = p_season_id
for update;
if not found then
raise exception 'listing not found';
end if;
FOR UPDATE means concurrent bids on the same listing queue instead of interleaving. Rank math is deterministic: strictly better bids plus older ties ahead, excluding self:
select count(*) + 1 into v_rank_to
from public.listings o
where o.season_id = p_season_id
and o.status = 'active'
and o.id <> p_listing_id
and (o.bid_amount_cents > v_new_total
or (o.bid_amount_cents = v_new_total
and o.created_at < v_listing.created_at));
Ties break to whoever got there first — no randomness, no arguments.
4. Keep the function away from clients
revoke all on function public.steal_apply_bid(...) from public, anon, authenticated;
grant execute on function public.steal_apply_bid(...) to service_role;
Only the server (webhook handler with the service-role key) can call it. Rank previews in the UI are estimates; the webhook is the truth. Never let a client write its own rank.
The failure modes this kills
| Scenario | Without the pattern | With it |
|---|---|---|
| Provider retries webhook | Bid applied twice |
already_applied, no-op |
| Two bids, same second | Both rank off stale snapshot | Serialized, both correct |
| Worker crashes mid-apply | Lock held forever (session lock) |
_xact_ lock dies with the transaction |
| User crafts rank via API | Fake #1 | Function is service-role only |
Try the board this protects
stealthespot.lol — list free, pay from $2 to climb, every bid public. Outbid someone and watch the rank move on the next webhook.
Built by @probiex007 — Next.js 16 + Supabase + Dodo, deployed on Vercel.
Top comments (0)