Channels
The inbox, email, SMS, and webhook channels, and what each one needs.
At a Glance
A notification lists the channels it can go out on, at most one of each type. Each channel is a small factory you create once from channel and then call with that channel’s templates:
import { channel } from "@notifykitjs/core"
const inbox = channel.inbox()
const email = channel.email()
const sms = channel.sms()
const webhook = channel.webhook()| Channel | Delivers by | Needs |
|---|---|---|
| Inbox | Writing a row to your database | Nothing |
| Your email provider | An email provider, and email on the recipient | |
| SMS | Your SMS provider | An SMS provider, and phone on the recipient |
| Webhook | A JSON POST to a URL | A webhook provider |
If a notification uses a channel whose provider is missing, createNotifyKit() throws at startup rather than failing on the first send. Providers are covered in Email and webhook providers.
Inbox
| Field | Required | What it is |
|---|---|---|
title | Yes | The main line in the inbox list |
body | No | Secondary text under the title |
actionUrl | No | Where clicking the item goes |
inbox({
title: "{{actorName}} mentioned you",
body: "In {{postTitle}}",
actionUrl: "{{postUrl}}",
})The inbox item is written during send(), straight to your database, so it never goes through the queue, retries, or quiet hours. Payload values are HTML-escaped in title and body. The actionUrl must be an absolute http, https, or mailto URL or a path starting with /; anything else, such as a javascript: URL, is dropped. Items can be marked read, archived, and deleted, and each change is pushed to the browser when a realtime adapter is configured.
| Field | Required | What it is |
|---|---|---|
subject | Yes | The subject line |
body | Yes | The message body |
html | No | Whether payload values in body are HTML-escaped. Default true |
email({
subject: "{{actorName}} mentioned you in {{postTitle}}",
body:
"Open {{postUrl}} to reply.<br><br>" +
'<a href="{{_unsubscribeUrl}}">Unsubscribe</a>',
})The body is sent as written, so it can contain HTML; with the default html: true, values substituted from the payload are escaped so a user’s name cannot inject markup. Set html: false for plain-text bodies. The subject is never escaped. If the recipient has no email, the channel is skipped with the reason missing_address.
{{_unsubscribeUrl}} becomes a signed link that turns this email off for this recipient. It needs the unsubscribe option on createNotifyKit(); see Unsubscribe links.
SMS
sms({ body: "Your code is {{code}}" })One field, body, sent as plain text. The recipient needs a phone, or the channel is skipped with missing_address. NotifyKit ships no SMS provider, only the contract (send({ to, body })), so you wrap Twilio or similar yourself. Because texts cost money, a notification with an SMS channel and no rate limit logs a warning at startup.
Webhook
| Field | Required | What it is |
|---|---|---|
url | Yes | Where to POST. Payload values in it are URL-encoded |
headers | No | Extra request headers. Host is not allowed |
webhook({
url: "https://hooks.example.com/notify/{{teamId}}",
headers: { "x-team": "{{teamId}}" },
})Before each send, the URL is checked: it must be http or https, carry no credentials, and resolve only to public addresses. localhost, private ranges such as 10.x and 192.168.x, link-local addresses, and .internal or .local names are refused, and the provider will not follow redirects. This keeps a URL built from user data from reaching your internal network.
What the receiver gets
With the built-in webhookProvider(), every delivery is a JSON POST of the same shape. The body is not configurable per notification; to post to Slack or Discord, which expect their own formats, run a small relay or write a provider that reshapes it.
{
"notificationId": "deploy_completed",
"recipientId": "user_123",
"payload": { "projectName": "api-gateway", "status": "succeeded" },
"sentAt": "2026-06-27T14:30:00.000Z"
}tenantId and workspaceId are added when the send was scoped to them. The request carries content-type: application/json and user-agent: notifykit/0.x, and times out after 10 seconds by default.
Verifying signatures
Give the provider a secret and every request gets an x-notifykit-signature: sha256=<hex> header, an HMAC-SHA256 of the raw body. Without a secret, NotifyKit warns at startup. On the receiving side, check it with verifyWebhookSignature() against the raw body, before parsing it:
import { verifyWebhookSignature } from "@notifykitjs/core"
export async function POST(request: Request) {
const raw = await request.text()
const signature = request.headers.get("x-notifykit-signature")
if (!verifyWebhookSignature(raw, signature, process.env.WEBHOOK_SECRET!)) {
return Response.json({ error: "Invalid signature" }, { status: 401 })
}
const { notificationId, payload } = JSON.parse(raw)
// ...
return Response.json({ received: true })
}Templates
Every text field is a template. {{name}} is replaced with that payload field; spaces inside the braces are allowed. There are no conditionals, loops, or filters. When a message needs logic, compute the text before sending and pass it in the payload.
- A template that names a variable missing from the payload schema is a startup error (
UNKNOWN_TEMPLATE_VAR), so typos are caught before anything is sent. - A field that is present in the schema but
nullorundefinedat send time renders as an empty string. - The only built-in variable is
{{_unsubscribeUrl}}, and only in email. A payload field with that name is overridden. - Escaping depends on where the value lands: HTML-escaped in inbox text and (by default) email bodies, URL-encoded in webhook URLs, and left as is in subjects, SMS, and headers. Line breaks are stripped from header values.
Delivery and Retries
Email, SMS, and webhook deliveries become jobs on the queue. With the default inlineQueue(), they run inside send(), so it returns with each delivery already sent or failed. A failed attempt is retried by the retry policy, which defaults to 3 attempts in total, waiting 250 ms and then 1 s:
createNotifyKit({
// ...
retry: {
maxAttempts: 5,
delayMs: (attempt) => 1000 * 2 ** (attempt - 2), // attempt 1 never waits
},
})An error marked permanent stops retrying immediately. The Resend provider marks HTTP 400, 401, 403, and 422 as permanent, and the Nodemailer provider does the same for permanent SMTP failures. Every webhook error, including a 4xx, is retried. When a delivery gives up, it is marked failed, the delivery.failed hook fires, and a fallback can take over.
| Inbox | Email, SMS, webhook | |
|---|---|---|
| Written or sent | During send(), directly | Through the queue and provider |
| Preferences | Checked | Checked |
| Quiet hours | Delivered now | Held until the window ends |
| Retries | None needed | Per the retry policy |
| Fallbacks | Can be a fallback target | Can trigger and be fallbacks |
Channels are independent: one that fails does not stop the others. Queues, and making delivery survive a crash, are covered in Production readiness.
Choosing Channels
Match the channels to how much the message should interrupt:
| Urgency | Channels | Example |
|---|---|---|
| Critical | Email and SMS, required: true, plus a fallback | Password reset, payment failed |
| Important | Inbox and email | Mention, invite, task assigned |
| Informational | Inbox, with email off by default via defaultChannels | New follower, post liked |
| Machine | Webhook, with a signing secret and a rate limit | CI or chat integrations |
Most apps start with inbox and email. Recipients can turn off any channel for any notification that is not required; see Preferences.