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
| Option | What it does |
|---|---|
recipientId | Who it is for. Must already exist |
notificationId | Which definition to use |
payload | The data the definition's schema asks for |
tenantId, workspaceId | Scope the send, checked against the recipient’s own. organizationId is an alias for tenantId. See Multi-tenancy |
idempotencyKey | Makes a repeated call return the first call’s result instead of sending again. Up to 256 characters |
dedupeKey, dedupeWindowMs | Drops a repeat of the same event inside the window. The window is required, and at most 30 days |
dryRun: true | Returns 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:
| Step | If it stops the send | What you see |
|---|---|---|
| 1. Look up the notification and recipient | Unknown id or recipient | Throws, with code UNKNOWN_NOTIFICATION or UNKNOWN_RECIPIENT |
| 2. Validate the payload | Missing or wrong-typed fields | Throws PayloadValidationError; nothing is stored |
| 3. Idempotency | The key was used within its lifetime (24 hours by default) | The first result again, with idempotent: true |
| 4. Deduplication | The dedupe key was seen inside the window | Every channel in skipped with reason "duplicate" |
| 5. Preferences | The recipient turned off every channel | Every channel in skipped, usually "preferences_disabled" |
| 6. Rate limit | Over the limit for the window | rateLimited: true |
| 7. Digest | The definition has a digest | digested: true; delivered later as a batch |
| 8. Quiet hours | The recipient is in quiet hours | Email, SMS, and webhooks in deferredChannels; the inbox is written now |
| 9. Deliver | Per channel: turned off, or no address | That 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
| Field | Meaning |
|---|---|
notification | The stored notification record. null only when the send was digested |
inboxItems | Inbox rows written by this send |
deliveries | Email, SMS, and webhook deliveries, including skipped ones (status "skipped"). The inbox has no delivery record |
skipped | Channels that did not go out, each with a reason and details |
deferredChannels | Channels held until the recipient's quiet hours end |
digested | The send went into a digest batch |
rateLimited | The rate limit stopped it |
idempotent | This 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)),
},
})| Queue | Behavior | Use it for |
|---|---|---|
inlineQueue() | send() waits for providers, retries included | The default. Scripts, tests, and simple apps |
setTimeoutQueue() | send() returns at once with pending deliveries; they run on the event loop | Demos and prototypes. In-flight deliveries are lost if the process exits |
| Your own | Implements 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:
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.
| Code | Cause |
|---|---|
UNKNOWN_NOTIFICATION | The id is not registered. The error lists the ids that are |
UNKNOWN_RECIPIENT | No recipient with that id; call upsertRecipient() first |
PAYLOAD_VALIDATION_ERROR | The payload does not match the schema. error.fields lists each problem |
INVALID_INPUT | An empty recipient id, a key over 256 characters, or a dedupeKey without a valid dedupeWindowMs |
TENANT_MISMATCH, WORKSPACE_MISMATCH | The scope passed does not match the recipient's |
- All of these are
NotifyKitErrors with acodeand, usually, afixexplaining what to change. - An email that lands in spam is a sending-domain problem (SPF, DKIM, DMARC), set up with your email provider.