← website
Build

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

ChannelContact targetNotes
emailEmail addressOne delivery task per address on file.
smsPhone numberOne task per number.
pushDevice tokenTokens are resolved at send time, so invalidations take effect immediately.
webhookURLMachine-to-machine delivery — a POST to a customer's endpoint.
in-appNot 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.

fixed set

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

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" }
  }'
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
Rollover is a re-entry, not a retry. When a channel is exhausted the task is republished to the enriched stream carrying the remaining chain, so the next channel goes through the Engine again — preferences and quiet hours are re-evaluated for it, and the address is resolved fresh. A user who has disabled SMS will not receive 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 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.

note

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.

note

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" };
  }
}
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 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.