BackCore concepts

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:

idempotencyKeydedupeKey
StopsThe same call being processed twiceTwo different calls about the same event
Typical causeA retried request, a webhook redelivered, a job rerunTwo code paths reacting to one thing
A repeat returnsThe original result, with idempotent: trueA new result with every channel skipped as duplicate
Remembered foridempotencyKeyTtlMs, 24 hours by defaultdedupeWindowMs, 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

SituationUseKey
A provider webhook is delivered twiceidempotencyKeystripe:${event.id}
A background job crashes and rerunsidempotencyKeyjob:${job.id}
Two handlers both notify about one mentiondedupeKeymention:${postId}:${actorId}
Someone saves a form ten times in a minutededupeKeyedited:${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 windowGood for
A few seconds to a minuteDouble submits and racing code paths
An hourRepeated edits or reactions to the same thing
A day or moreReminders 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.