BackCore concepts

Sending

What happens between notify.send() and a delivered message: validation, preferences, storage, and delivery.

A Basic Send

A recipient has to exist before you can send to them. Create or update one with upsertRecipient(), typically when the user signs up or changes their email, then send:

await notify.upsertRecipient({ id: user.id, email: user.email, name: user.name })

const result = await notify.send({
	recipientId: user.id,
	notificationId: "comment_mentioned",
	payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
})

send() is typed against the notifications you registered: an unknown id, a missing field, or a field of the wrong type is a compile error. It is server-only and trusts the recipientId it is given, so never pass it one straight from the browser. Browser code goes through the handler routes, which get the user from identify().

Send Options

OptionWhat it does
recipientIdWho it is for. Must already exist
notificationIdWhich definition to use
payloadThe data the definition's schema asks for
tenantId, workspaceIdScope the send, checked against the recipient’s own. organizationId is an alias for tenantId. See Multi-tenancy
idempotencyKeyMakes a repeated call return the first call’s result instead of sending again. Up to 256 characters
dedupeKey, dedupeWindowMsDrops a repeat of the same event inside the window. The window is required, and at most 30 days
dryRun: trueReturns what would happen instead of sending; the same as notify.explain()

The two keys solve different problems. Use an idempotencyKey when the caller might retry the same call, such as a webhook handler or a queue job. Use a dedupeKey when the same logical event can happen twice, such as a comment edited three times in a minute. Both can be set at once. Details are on Deduplication and idempotency.

The Pipeline

Each send goes through these steps in order. The first one that stops it tells you why:

StepIf it stops the sendWhat you see
1. Look up the notification and recipientUnknown id or recipientThrows, with code UNKNOWN_NOTIFICATION or UNKNOWN_RECIPIENT
2. Validate the payloadMissing or wrong-typed fieldsThrows PayloadValidationError; nothing is stored
3. IdempotencyThe key was used within its lifetime (24 hours by default)The first result again, with idempotent: true
4. DeduplicationThe dedupe key was seen inside the windowEvery channel in skipped with reason "duplicate"
5. PreferencesThe recipient turned off every channelEvery channel in skipped, usually "preferences_disabled"
6. Rate limitOver the limit for the windowrateLimited: true
7. DigestThe definition has a digestdigested: true; delivered later as a batch
8. Quiet hoursThe recipient is in quiet hoursEmail, SMS, and webhooks in deferredChannels; the inbox is written now
9. DeliverPer channel: turned off, or no addressThat channel in skipped; the others still go out

Steps 3 to 8 stop the send as a whole. In step 9, one channel can be skipped while another is delivered, so a result can have both skipped and deliveries. Even a send that stops at dedupe, preferences, or the rate limit writes a notification record with skipped deliveries, so it shows up in the timeline. Explain and dry run shows where a send would stop without running it.

The Result

FieldMeaning
notificationThe stored notification record. null only when the send was digested
inboxItemsInbox rows written by this send
deliveriesEmail, SMS, and webhook deliveries, including skipped ones (status "skipped"). The inbox has no delivery record
skippedChannels that did not go out, each with a reason and details
deferredChannelsChannels held until the recipient's quiet hours end
digestedThe send went into a digest batch
rateLimitedThe rate limit stopped it
idempotentThis is a replay of an earlier send with the same key

Check digested and rateLimited before telling a user something was sent: in both cases nothing went out yet, or at all. skippedChannels still exists but is deprecated in favour of skipped.

Queues and Retries

The inbox is written during send(). Email, SMS, and webhook deliveries are handed to a queue. The default, inlineQueue(), runs them before send() returns, so the deliveries in the result already have their final status.

A delivery is tried up to 3 times, waiting 250 ms and then 1 s between attempts. A provider can mark an error as permanent to stop early. If every attempt fails, the delivery is marked "failed", the delivery.failed hook runs, and any fallback for that channel is tried. send() does not throw for a provider failure.

createNotifyKit({
	// ...
	retry: {
		maxAttempts: 5,
		delayMs: (attempt) => (attempt === 1 ? 0 : 500 * 2 ** (attempt - 2)),
	},
})
QueueBehaviorUse it for
inlineQueue()send() waits for providers, retries includedThe default. Scripts, tests, and simple apps
setTimeoutQueue()send() returns at once with pending deliveries; they run on the event loopDemos and prototypes. In-flight deliveries are lost if the process exits
Your ownImplements enqueue(job, run) and drain()A durable worker (BullMQ, SQS, and so on) that calls notify.processDeliveryJob(job)

Call await notify.drain() in tests and before shutdown to wait for outstanding deliveries. Production readiness covers durable queues.

Patterns

Retried triggers

Derive the idempotency key from the event, so a redelivered webhook is a no-op:

app/api/stripe/route.ts
await notify.send({
	recipientId: event.userId,
	notificationId: "payment_received",
	payload: { amount: event.amountText },
	idempotencyKey: `stripe:${event.id}`,
})

Many recipients

send() targets one recipient. To notify a group, send once per person; each send gets its own preferences, quiet hours, and rate limit. Give each a key that includes the recipient, so retrying the whole batch only resends what failed:

const results = await Promise.allSettled(
	members
		.filter((m) => m.userId !== actor.id) // don't notify the person who did it
		.map((m) =>
			notify.send({
				recipientId: m.userId,
				notificationId: "project_shipped",
				payload: { projectName, actorName: actor.name },
				idempotencyKey: `shipped:${projectId}:${m.userId}`,
			}),
		),
)

For large lists, send in batches or hand each recipient to a background job, so one broadcast does not exhaust your database connections.

One event, several audiences

A single comment may need to tell the people mentioned, the post’s author, and its watchers. Give each audience its own notification id, so someone can mute “comments on posts I watch” without also losing “mentioned you”, and make sure each person gets only the most specific one. Keep this fan-out in one function per event, such as onCommentPosted(), that your mutation calls.

When a send must not be lost

An un-awaited send() in a request handler can vanish in a crash or deploy. If a notification must go out, write an outbox row in the same database transaction as the change that caused it, and have a worker call send() for each row with an idempotency key derived from the row.

Scope dedupe keys

A dedupe key is already scoped to the notification and recipient, so a key like "comment" would silence every later comment notification to that person for the whole window. Include the entity: `comment:${postId}:${actorId}`.

Errors

send() only throws for problems in the call itself; delivery problems end up in the result.

CodeCause
UNKNOWN_NOTIFICATIONThe id is not registered. The error lists the ids that are
UNKNOWN_RECIPIENTNo recipient with that id; call upsertRecipient() first
PAYLOAD_VALIDATION_ERRORThe payload does not match the schema. error.fields lists each problem
INVALID_INPUTAn empty recipient id, a key over 256 characters, or a dedupeKey without a valid dedupeWindowMs
TENANT_MISMATCH, WORKSPACE_MISMATCHThe scope passed does not match the recipient's
  • All of these are NotifyKitErrors with a code and, usually, a fix explaining what to change.
  • An email that lands in spam is a sending-domain problem (SPF, DKIM, DMARC), set up with your email provider.