Channels & fallback
You pick channels, never providers. notifkit decides which transport serves a channel, whether to try channels at once or in turn, and what to do when the first refuses.
The channels
| Channel | Contact target | Notes |
|---|---|---|
email | Email address | One delivery task per address on file. Supported by @notifkit/provider-resend. |
sms | Phone number in E.164 | One task per number. Supported by @notifkit/provider-twilio or custom SMS transports. |
push | Device token | Tokens are resolved at send time; invalidations deactivate tokens immediately. Supported by @notifkit/provider-fcm. |
slack | Channel ID, User ID, or Webhook URL | Posts via Slack Web API (chat.postMessage) or incoming webhook with Block Kit layouts. Supported by @notifkit/provider-slack. |
whatsapp | Phone number in E.164 | Delivered via Meta WhatsApp Cloud API with zero per-message SaaS markup. Supported by @notifkit/provider-whatsapp. |
telegram | Telegram Chat ID | Delivered via Telegram Bot API with HTML/Markdown and silent alerts. Supported by @notifkit/provider-telegram. |
discord | Discord Webhook URL | Delivered via incoming webhooks with custom avatars, usernames, and embeds. Supported by @notifkit/provider-discord. |
webhook | HTTP(S) URL | Machine-to-machine delivery: an HTTP POST to an arbitrary endpoint. No first-party transport; you register one, usually a single send() that POSTs. |
in-app | Anything your transport understands | No first-party transport. Contacts can be stored, but a send fails with no_transport (or falls back to the next channel) unless you register a transport for in-app. For an in-app feed, the usual route is webhook into your own database. |
Omit channels and notifkit assumes ["email"]. That leaves eight
deliverable channels above: seven with first-party provider transports, and
webhook, which takes one you write.
The list can't be extended at runtime. channel is a PostgreSQL enum used by six
tables, so adding one needs a migration (ALTER TYPE "channel" ADD VALUE …) and
edits to two Zod schemas. A notify() naming an unknown channel is rejected with
400 before reaching the database.
Adding a provider is the opposite (see below): the transport registry is in-memory and touches no schema.
Chat platforms are channels
Slack, Discord, Telegram and WhatsApp are channels of their own, each with its own contacts,
opt-out toggle and series in notifkit_delivery_failed_total{channel="…"}, so a user
can mute Slack without muting Discord. Store the address as a contact on that channel and name
the channel in notify().
// A Slack channel ID, user ID, or incoming-webhook URL.
await notifkit.addContact("usr_123", {
channel: "slack",
target: "https://hooks.slack.com/services/T00/B00/xxx",
});
await notifkit.notify({
user: "usr_123",
template: "deploy-failed",
channels: ["slack"],
data: { service: "checkout", commit: "a91f2c3" },
});
# A Slack channel ID, user ID, or incoming-webhook URL.
curl -X POST http://localhost:3000/v1/users/usr_123/contacts -H "Authorization: Bearer $NK_KEY" -H "Content-Type: application/json" -d '{
"channel": "slack",
"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": ["slack"],
"data": { "service": "checkout", "commit": "a91f2c3" }
}'
Each channel needs its transport registered at boot:
@notifkit/provider-slack,
@notifkit/provider-discord,
@notifkit/provider-telegram, or
@notifkit/provider-whatsapp. Without one,
sends on that channel fail with no_transport.
Other platforms, like Microsoft Teams, ride the webhook channel: store the
incoming-webhook URL as a webhook contact and register a webhook
transport that POSTs to it. The cost of not widening the enum: everything on
webhook shares one opt-out toggle and one metrics series.
Multicast: everything at once
By default, several channels send concurrently, each as an independent task, so one failing doesn't 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 (log line:
no active contact for channel). Multicasting to a channel the user never
registered is harmless.
Fallback: one at a time, in order
fallback: true changes the array's meaning from "all of these" to "these, in 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 dropped rather than failed doesn't trigger fallback. Opt-outs, quiet-hours deferrals and throttling stop the chain where it is: the user's decision isn't something to route around.
When the chain runs out, or there was none, a failed row goes into the delivery log, visible to
GET /v1/notifications/{taskId} and the SSE stream. That includes the two cases with
no provider call: no transport registered (no_transport) and exceeding the
provider's rate-limit retries (provider_throttle_exceeded). Both are non-retryable,
so the message stops there instead of 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. registerTransport's second
argument is a priority: higher goes first.
import { NotifkitServer, registerTransport } from "notifkit";
import { ResendTransport } from "@notifkit/provider-resend";
registerTransport(new ResendTransport({ apiKey: RESEND_KEY, from: "[email protected]" }), 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: "[email protected]" })],
});
The Delivery worker tries the channel's transports in priority order and stops at the first success. That's provider failover inside one channel: if Resend is down, the email goes out through Postmark, the channel isn't considered failed, and your app code is untouched.
Two levels of failover stack. Provider failover happens within a channel, 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, which Delivery enforces across all worker processes
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, not
counted as a failure, up to maxAttempts times (3 by default). After that the channel
counts as failed and the fallback chain takes over.
Circuit breaker
Each transport has a breaker: five consecutive failures open it for 30 seconds, and calls fail immediately instead of piling onto a struggling provider. If the channel has a second provider, traffic shifts to it meanwhile.
Timeout
Every send() races a 10-second timeout. The task also gets an
AbortSignal, so a well-behaved transport can cancel its in-flight request instead of
leaking it.
Push is a special case
For push, the destination is resolved late, at send time rather than during enrichment, so a token invalidated a moment ago isn't used. When a provider reports a token dead, notifkit deactivates that contact for all future sends and 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 in the delivery
worker, so they must be Node. From another language, reach a custom provider through the
webhook channel: point a contact at your endpoint and translate the POST there.
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 webhookPath and parseWebhook are
both present, and it rejects with 501 unless verifyWebhook is
implemented too. Treat all three as one unit. Engagement events whose
providerMessageId matches no known message are dropped unrecorded.
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: "[email protected]", // resolved address — send here
renderedContent: {
content: { subject: "Welcome aboard, Alice", text: "Hi Alice, …" },
},
recipient: { id: "usr_123", email: "[email protected]", locale: "en", timezone: "America/New_York" },
deliveryOptions: { maxAttempts: 3, timeoutMs: 10000 },
fallbackChain: ["sms"], // present only mid-chain
}
renderedContent.content is your template's content with placeholders
filled. First-party transports read subject, text or
body, and html or htmlBody; yours can read any keys.
destination is optional on the payload type. It's always set for
email, sms and webhook; on push it's the
device token, absent when the recipient has none. Treat a missing destination as a
failure to report, not something to fall back on: it's never silently replaced with the user id.