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.

Built for at-least-once delivery
Retry Safety Overview
Connecting to retrysafe…
Effects Analyzed
At-least-once delivery
Stripe, GitHub, Shopify, SQS, and every queue retry until they get an acknowledgement. Three situations happen to every handler, eventually.
The response was slow or a deploy restarted the process. The provider sends the same event again, and every unguarded effect happens twice.
A retry lands while the first attempt is still running. Both read “not processed yet”, and both charge the card.
Record first and a crash loses the event. Work first and a crash duplicates it. Only one pattern survives both.
The trap
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.
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 →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 →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
Finds the handlers
Webhook routes in Express, Fastify, and Next.js; BullMQ, SQS, and Lambda consumers; Probot apps. No list to maintain.
Maps every effect
Follows calls through your own code to payments, emails, writes, queue publishes, and API calls, and knows which are idempotent.
Checks the guards
Unique inserts, SET NX, idempotency keys, transactions, and your schema's unique constraints, and the order they run in.
Explains the fix
Each finding has a failing sequence, the safe pattern for the library you use, and a codemod that applies it.
$ npx retrysafePOST /api/webhooks/stripe route.ts:6Effect map✔ verify stripe.webhooks.constructEvent✖ db-write prisma.order.create no guard✖ email resend.emails.send no guard✖ unguarded-side-effect error · high confidence1. Stripe delivers evt_1; handler creates the order2. The response is slow; Stripe retries evt_13. A second order is createdFix: claim event.id in the same transaction$ npx retrysafe fix --guard @/lib/guard#guard --write✔ wrapped POST /api/webhooks/stripe in guard.run(...)$
16 rules
From unguarded payments to dedupe keys that expire before the provider stops retrying.
unguarded-side-effectA non-idempotent effect (payment, email, plain insert, increment) runs with no dedupe guard on its path.
record-before-effect-no-txThe event is marked processed before its work runs, outside one transaction and with no rollback.
check-then-insert-raceA lookup-then-insert dedupe check with no atomic claim before the effect; concurrent duplicates both pass it.
effect-before-recordAn effect runs before the event is recorded, behind a check that is not an atomic claim.
nondeterministic-keyA dedupe or idempotency key is derived from Date.now(), Math.random(), uuid(), or similar.
effect-missing-idempotency-keyA call whose SDK supports an idempotency key is made without one.
swallowed-error-returns-2xxA catch block swallows a failure of the handler's work and still acknowledges the event (2xx, or a completed job).
permanent-error-returns-5xxA permanent failure (invalid signature, malformed payload) is answered with 5xx, causing pointless retries.
ack-timingThe response is sent before the work completes (or the work is not awaited), or slow work delays the response past the provider's timeout.
What ships

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