BackCore concepts

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:

lib/notifications.ts
import { channel } from "@notifykitjs/core"

const inbox = channel.inbox()
const email = channel.email()
const sms = channel.sms()
const webhook = channel.webhook()
ChannelDelivers byNeeds
InboxWriting a row to your databaseNothing
EmailYour email providerAn email provider, and email on the recipient
SMSYour SMS providerAn SMS provider, and phone on the recipient
WebhookA JSON POST to a URLA 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

FieldRequiredWhat it is
titleYesThe main line in the inbox list
bodyNoSecondary text under the title
actionUrlNoWhere 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.

Email

FieldRequiredWhat it is
subjectYesThe subject line
bodyYesThe message body
htmlNoWhether 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

FieldRequiredWhat it is
urlYesWhere to POST. Payload values in it are URL-encoded
headersNoExtra 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.

POST body
{
	"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:

app/api/hooks/notifykit/route.ts
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 null or undefined at 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.

InboxEmail, SMS, webhook
Written or sentDuring send(), directlyThrough the queue and provider
PreferencesCheckedChecked
Quiet hoursDelivered nowHeld until the window ends
RetriesNone neededPer the retry policy
FallbacksCan be a fallback targetCan 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:

UrgencyChannelsExample
CriticalEmail and SMS, required: true, plus a fallbackPassword reset, payment failed
ImportantInbox and emailMention, invite, task assigned
InformationalInbox, with email off by default via defaultChannelsNew follower, post liked
MachineWebhook, with a signing secret and a rate limitCI 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.