Star
Build

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

ChannelContact targetNotes
emailEmail addressOne delivery task per address on file. Supported by @notifkit/provider-resend.
smsPhone number in E.164One task per number. Supported by @notifkit/provider-twilio or custom SMS transports.
pushDevice tokenTokens are resolved at send time; invalidations deactivate tokens immediately. Supported by @notifkit/provider-fcm.
slackChannel ID, User ID, or Webhook URLPosts via Slack Web API (chat.postMessage) or incoming webhook with Block Kit layouts. Supported by @notifkit/provider-slack.
whatsappPhone number in E.164Delivered via Meta WhatsApp Cloud API with zero per-message SaaS markup. Supported by @notifkit/provider-whatsapp.
telegramTelegram Chat IDDelivered via Telegram Bot API with HTML/Markdown and silent alerts. Supported by @notifkit/provider-telegram.
discordDiscord Webhook URLDelivered via incoming webhooks with custom avatars, usernames, and embeds. Supported by @notifkit/provider-discord.
webhookHTTP(S) URLMachine-to-machine delivery: an HTTP POST to an arbitrary endpoint. No first-party transport; you register one, usually a single send() that POSTs.
in-appAnything your transport understandsNo 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.

Fixed set

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" }
  }'
Note

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" }
  }'
channels: ["push", "email", "sms"] · fallback: true only the first channel is ever dispatched up front push every push transport, in priority order all failed chain: [email, sms] email re-enters the pipeline at the enriched stream all failed chain: [sms] sms last in the chain nothing left to try delivered · chain stops delivered · chain stops delivered delivery:failed
When a channel is exhausted, the task is republished to the enriched stream with the rest of the chain, so the next channel goes through the Engine again. It's a fresh entry, not a retry: preferences and quiet hours are re-evaluated for the new channel and the address resolved again. A user who disabled SMS won't get the SMS fallback.

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 maxAttempts allows.

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.

Note

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.

Note

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" };
  }
}
MemberRequiredPurpose
channelyesWhich channel this transport serves.
send(task)yesDeliver, and report the outcome. Never throw for an expected failure: return { success: false }.
limitsnoFleet-wide rate ceiling for this provider.
webhookPathnoMounts a POST route for provider callbacks, e.g. /webhooks/resend.
verifyWebhooknoSignature check. Required in practice: the mounted route answers 501 without it.
parseWebhooknoMaps the provider's payload to opened, clicked, bounced, complained, unsubscribed.
Careful

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.