Deduplication and idempotency
Keep a retried request or a repeated event from notifying someone twice.
Two Tools
send() takes two optional keys that solve different problems:
| idempotencyKey | dedupeKey | |
|---|---|---|
| Stops | The same call being processed twice | Two different calls about the same event |
| Typical cause | A retried request, a webhook redelivered, a job rerun | Two code paths reacting to one thing |
| A repeat returns | The original result, with idempotent: true | A new result with every channel skipped as duplicate |
| Remembered for | idempotencyKeyTtlMs, 24 hours by default | dedupeWindowMs, which you pass each time |
Both are scoped to the recipient and the notification: the same key for a different recipient or a different notification is a different send. Both can be at most 256 characters. They run before preferences, rate limits, and digests, in that order: idempotency first, then deduplication.
Idempotency
Pass a key that identifies one logical attempt, usually an id you already have: the incoming webhook’s event id, the job id, or the id of the row that triggered the send.
export async function POST(request: Request) {
const event = await request.json()
await notify.send({
recipientId: event.userId,
notificationId: "payment_received",
payload: { amount: event.amount },
idempotencyKey: `stripe:${event.id}`,
})
return Response.json({ ok: true })
}If the same key arrives again within the TTL, nothing is processed: send() returns the stored record, inbox items, and deliveries of the first call, with idempotent: true. Two calls with the same key at the same moment are serialized, and the database’s unique constraint on the key covers separate processes. After the TTL, the key is released and a repeat is treated as a new send.
createNotifyKit({
// ...
idempotencyKeyTtlMs: 7 * 24 * 60 * 60_000, // remember keys for a week
})Deduplication
A dedupe key names the event, not the call. Pass it with a window, which is required and can be at most 30 days:
await notify.send({
recipientId: mentionedUser.id,
notificationId: "comment_mentioned",
payload: { actorName, postTitle, postUrl },
dedupeKey: `mention:${post.id}:${actor.id}`,
dedupeWindowMs: 60 * 60_000,
})The first send with a key records it for the window. Any send with the same key before the window ends is a duplicate: it is recorded, but every channel is skipped with the reason duplicate, nothing is delivered, and the notification.deduplicated hook fires. Duplicates do not extend the window.
Because deduplication runs before preferences, the key is recorded even if the first send is then suppressed or rate-limited; a repeat inside the window is still a duplicate.
Choosing Keys
| Situation | Use | Key |
|---|---|---|
| A provider webhook is delivered twice | idempotencyKey | stripe:${event.id} |
| A background job crashes and reruns | idempotencyKey | job:${job.id} |
| Two handlers both notify about one mention | dedupeKey | mention:${postId}:${actorId} |
| Someone saves a form ten times in a minute | dedupeKey | edited:${docId} |
- Make keys deterministic. A key with a timestamp or a random id in it never repeats, so it never protects anything.
- Include what makes the event distinct.
mention:${postId}would drop a mention by a second person on the same post; add the actor. - Use both when both can happen. The idempotency key catches the retried call, the dedupe key catches the second path.
| Dedupe window | Good for |
|---|---|
| A few seconds to a minute | Double submits and racing code paths |
| An hour | Repeated edits or reactions to the same thing |
| A day or more | Reminders that should go out at most once a day |
With Other Features
- Digests: deduplication runs first, so a duplicate never joins a batch. It is the way to keep repeats out of a digest.
- Rate limits: duplicates are dropped before the rate limit and do not count against it.
- Quiet hours: a send held for quiet hours already recorded its key, so a repeat during the night is a duplicate.
Debugging
explain() reports wouldReplayIdempotent and wouldDeduplicate without recording anything, and the channel outcomes read idempotent or deduplicated. In the timeline, look for idempotent.replay and deduplicated events. If someone received the same notification twice, the sends were probably missing a key, or used keys that differ between the two calls.