Channels & fallback
You pick channels, never providers. NotifKit decides which registered transport serves a channel, whether to try several channels at once or one after another, and what to do when the first one refuses.
The channels
| Channel | Contact target | Notes |
|---|---|---|
email | Email address | One delivery task per address on file. |
sms | Phone number | One task per number. |
push | Device token | Tokens are resolved at send time, so invalidations take effect immediately. |
webhook | URL | Machine-to-machine delivery — a POST to a customer's endpoint. |
in-app | — | Not deliverable. The value exists in the enum, but no endpoint can create an in-app contact, so the engine finds no address and drops the message. Use webhook and persist it yourself. |
Omit channels entirely and NotifKit assumes ["email"]. In practice you
have the four deliverable channels above.
This list is not extensible at runtime. channel is a PostgreSQL enum used by
five tables, so adding one means a migration
(ALTER TYPE "channel" ADD VALUE …) plus edits to two Zod schemas. A
notify() naming a channel outside the set is rejected with 400
before it reaches the database.
Adding a provider is the opposite — see below. The transport registry is in-memory and touches no schema at all.
Chat platforms ride the webhook channel
Slack, Discord, and Teams are not channels of their own, and they do not need to be: each is a
URL that accepts a POST. Model them as webhook contacts and write one transport.
// Store the incoming-webhook URL like any other address.
await notifkit.addContact("usr_123", {
channel: "webhook",
target: "https://hooks.slack.com/services/T00/B00/xxx",
});
await notifkit.notify({
user: "usr_123",
template: "deploy-failed",
channels: ["webhook"],
data: { service: "checkout", commit: "a91f2c3" },
});
# Store the incoming-webhook URL like any other address.
curl -X POST http://localhost:3000/v1/users/usr_123/contacts \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "webhook",
"target": "https://hooks.slack.com/services/T00/B00/xxx"
}'
curl -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "usr_123",
"template": "deploy-failed",
"channels": ["webhook"],
"data": { "service": "checkout", "commit": "a91f2c3" }
}'
The tradeoff is that everything on webhook shares one channel. You get a single
opt-out toggle rather than one per platform, and
notifkit_delivery_failed_total{channel="webhook"} aggregates them. If you need
Slack and Discord to be independently mutable by the user, that is the case for spending a
migration and widening the enum.
Platforms with a real API rather than a webhook URL — WhatsApp Business, Telegram Bot — work
the same way, but the transport does more than a bare POST. Nothing about them requires a new
channel either; pick the channel whose delivery semantics fit and implement
send().
Multicast: everything at once
By default, listing several channels sends on all of them concurrently. Each becomes an independent task, so one failing does not affect the others.
// Reaches the phone and the inbox in parallel.
await notifkit.notify({
user: "usr_123",
template: "security-alert",
channels: ["push", "email"],
priority: "critical",
data: { ipAddress: "203.0.113.7", city: "Lisbon" },
});
# Reaches the phone and the inbox in parallel.
curl -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "usr_123",
"template": "security-alert",
"channels": ["push", "email"],
"priority": "critical",
"data": { "ipAddress": "203.0.113.7", "city": "Lisbon" }
}'
A channel with no contact on file is skipped silently — the log line is
no active contact for channel. Multicasting to a channel the user has never
registered is harmless, not an error.
Fallback: one at a time, in order
Setting fallback: true changes the meaning of the array from "all of these" to
"these, in this order, until one works".
// Try push. If it fails outright, try email. Then SMS.
await notifkit.notify({
user: "usr_123",
template: "unread-message",
channels: ["push", "email", "sms"],
fallback: true,
data: { senderName: "Alice" },
});
# Try push. If it fails outright, try email. Then SMS.
curl -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": "usr_123",
"template": "unread-message",
"channels": ["push", "email", "sms"],
"fallback": true,
"data": { "senderName": "Alice" }
}'
What counts as "failed"
Rollover happens only when a channel is genuinely exhausted:
- Every transport registered for that channel returned a failure, threw, or timed out.
- No transport is registered for that channel at all.
- A push token came back invalid — the contact is deactivated and the chain moves on.
- The provider's own rate limit was hit more times than
maxAttemptsallows.
A message that is dropped rather than failed does not trigger fallback. Opt-outs, quiet-hours deferrals, and throttling all stop the chain where it is: the user's decision is not something to route around.
When the chain runs out — or there was no chain to begin with — the outcome is written to
the delivery log as a failed row, so
GET /v1/notifications/{taskId} and the SSE stream show it. That includes the
two cases with no provider call to fail: a channel with no transport registered
(no_transport) and one that exceeded the provider's own rate-limit retries
(provider_throttle_exceeded). Both are marked non-retryable, so the message
stops there rather than burning attempts on its way to the dead-letter queue.
Choosing a provider for a channel
Register as many transports per channel as you like. The second argument to
registerTransport is a priority — higher goes first.
import { NotifkitServer, registerTransport } from "notifkit";
import { ResendTransport } from "@notifkit/provider-resend";
registerTransport(new ResendTransport({ apiKey: RESEND_KEY, from: "hi@acme.com" }), 10);
registerTransport(new PostmarkTransport({ apiKey: POSTMARK_KEY }), 5);
// Or pass them to the constructor, which registers each at priority 0:
const server = new NotifkitServer({
services: ["all"],
providers: [new ResendTransport({ apiKey: RESEND_KEY, from: "hi@acme.com" })],
});
On a send, the Delivery worker walks the transports for that channel in priority order and stops at the first success. So the example above gives you provider failover inside a single channel: if Resend is down, the same email goes out through Postmark without the channel being considered failed and without touching your application code.
Two levels of failover stack here. Provider failover happens within a channel and is invisible to the rest of the pipeline. Channel fallback happens only after every provider for that channel has failed.
Guards on the delivery attempt
Provider rate limits
A transport can declare its own ceiling. Delivery enforces it across every worker process
before calling send():
class MyTransport implements Transport {
readonly channel = "sms" as const;
readonly limits = { limit: 100, windowSeconds: 1 }; // 100/s across the fleet
// …
}
Over the limit, the task is parked with the scheduler and retried when the window reopens
rather than being burned as a failure. That happens up to maxAttempts times
(3 by default); after that the channel is treated as failed and the fallback chain takes over.
Circuit breaker
Each transport gets a breaker: five consecutive failures opens it for 30 seconds, during which calls fail immediately instead of piling onto a struggling provider. With a second provider registered for the channel, traffic shifts to it for the duration.
Timeout
Every send() is raced against a 10-second timeout. The task is also given an
AbortSignal, so a well-behaved transport can cancel its in-flight HTTP request
rather than leaking it.
Push is a special case
For push, the destination is deliberately resolved late — at send time rather than during enrichment — so a token invalidated a moment ago is not used. When a provider reports a token as dead, NotifKit deactivates that contact so it is skipped on every future send, and then moves on to the fallback chain.
// FcmTransport maps provider error codes onto invalidToken:
return {
success: false,
invalidToken: true, // → contact deactivated, chain continues
error: "messaging/registration-token-not-registered",
};
Writing your own transport
The interface is small. Implement send(), declare a channel, and you have a new
provider.
Transports are the one part of NotifKit with no HTTP equivalent — they run inside the
delivery worker, so they have to be Node. If your services are in another language, reach a
custom provider through the webhook channel instead: point a contact at your own
endpoint and translate the POST on your side.
import type { Transport, NotificationDispatchedPayload, DeliveryResult } from "notifkit";
export class SlackTransport implements Transport {
readonly channel = "webhook" as const;
readonly limits = { limit: 1, windowSeconds: 1 };
async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
const content = task.renderedContent.content as Record<string, string>;
const res = await fetch(task.destination, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: content.text ?? content.body }),
signal: (task as any).signal, // honour the delivery timeout
});
if (!res.ok) {
return { success: false, error: `slack responded ${res.status}` };
}
return { success: true, providerMessageId: res.headers.get("x-slack-req-id") ?? "ok" };
}
}
| Member | Required | Purpose |
|---|---|---|
channel | yes | Which channel this transport serves. |
send(task) | yes | Deliver, and report the outcome. Never throw for an expected failure — return { success: false }. |
limits | no | Fleet-wide rate ceiling for this provider. |
webhookPath | no | Mounts a POST route for provider callbacks, e.g. /webhooks/resend. |
verifyWebhook | no | Signature check. Required in practice — the mounted route answers 501 without it. |
parseWebhook | no | Maps the provider's payload to opened, clicked, bounced, complained, unsubscribed. |
A webhook route is mounted only when both webhookPath and
parseWebhook are present, and it rejects with 501 unless
verifyWebhook is implemented too. Treat all three as one unit. Engagement
events whose providerMessageId does not match a known message are dropped
rather than recorded.
What lands in send()
{
projectId: "6f1c9c1e-…",
taskId: "0f8c2b7e-…", // unique per (message, contact)
recipientId: "usr_123", // your user id
channel: "email",
priority: "normal",
templateId: "welcome",
destination: "alice@example.com", // resolved address — send here
renderedContent: {
content: { subject: "Welcome aboard, Alice", text: "Hi Alice, …" },
},
recipient: { id: "usr_123", email: "alice@example.com", locale: "en", timezone: "America/New_York" },
deliveryOptions: { maxAttempts: 3, timeoutMs: 10000 },
fallbackChain: ["sms"], // present only mid-chain
}
renderedContent.content is your template's content object with the
placeholders already filled. The first-party transports read subject,
text or body, and html or htmlBody; a
transport you write can read whatever keys you like.
destination is optional on the payload type. It is always set for
email, sms, and webhook; on push it
carries the device token, and is absent when the recipient has none on record. A transport
should treat a missing destination as a failure to report rather than a value
to fall back on — it is never quietly substituted with the user id.