Built for at-least-once delivery

Ship webhooks that are safe to run twice

Try the Playground

Retry Safety Overview

Connecting to retrysafe…

Effects Analyzed

  • Effects
  • Effects
  • Effects
View full report

At-least-once delivery

Your handler will run twice. The question is what it does then.

Stripe, GitHub, Shopify, SQS, and every queue retry until they get an acknowledgement. Three situations happen to every handler, eventually.

evt_1timeout
evt_1 again

Retry after success

The response was slow or a deploy restarted the process. The provider sends the same event again, and every unguarded effect happens twice.

evt_1 · check → work
evt_1 · check → work

Two copies at once

A retry lands while the first attempt is still running. Both read “not processed yet”, and both charge the card.

record✕ crash
retry → skipped

A crash part-way

Record first and a crash loses the event. Work first and a crash duplicates it. Only one pattern survives both.

The trap

Two common fixes, and why each one fails

Adding a dedupe check swaps one bug for the other. retrysafe checks which side of the trap each handler is on, and whether its guard is really atomic.

Loses events

Record first, then work

const ok = await redis.set(key, 1, "NX");
if (!ok) return;
await credit(wallet); // crash → lost

Safe against duplicates. A crash after the claim marks the event done with the work undone.

record-before-effect-no-tx →
Duplicates

Work first, then record

if (await seen(evt.id)) return;
await sendEmail(user); // crash → twice
await markSeen(evt.id);

Safe against loss. A crash before the record, or a concurrent copy, repeats the work.

effect-before-record →
Safe

Claim and work in one transaction

await db.$transaction(async (tx) => {
  await tx.processed.create({ id });
  await tx.wallet.update(credit);
});

A duplicate fails the unique claim; a crash rolls back both. Use idempotency keys for external calls.

How it works

Reads your code the way a careful reviewer would

  1. 1

    Finds the handlers

    Webhook routes in Express, Fastify, and Next.js; BullMQ, SQS, and Lambda consumers; Probot apps. No list to maintain.

  2. 2

    Maps every effect

    Follows calls through your own code to payments, emails, writes, queue publishes, and API calls, and knows which are idempotent.

  3. 3

    Checks the guards

    Unique inserts, SET NX, idempotency keys, transactions, and your schema's unique constraints, and the order they run in.

  4. 4

    Explains the fix

    Each finding has a failing sequence, the safe pattern for the library you use, and a codemod that applies it.

$ npx retrysafe
 
POST /api/webhooks/stripe route.ts:6
Effect map
✔ verify stripe.webhooks.constructEvent
✖ db-write prisma.order.create no guard
✖ email resend.emails.send no guard
 
✖ unguarded-side-effect error · high confidence
1. Stripe delivers evt_1; handler creates the order
2. The response is slow; Stripe retries evt_1
3. A second order is created
Fix: claim event.id in the same transaction
 
$ npx retrysafe fix --guard @/lib/guard#guard --write
✔ wrapped POST /api/webhooks/stripe in guard.run(...)
$

What ships

Find it, fix it, keep it fixed

See what a retry would break, before a retry does

Paste a handler into the playground, or run it on your whole repo in one command.

Try the Playground$ npx retrysafe
or npm install -D retrysafe · view on npm ↗