Fallback channels

When a primary channel fails after all retries, a fallback catches the notification so it's never silently lost. Fallbacks also trigger when a recipient lacks a destination address or when a channel is skipped by preferences.

Simple fallback

One backup channel. Email fails → write to inbox. Covers 90% of cases with a single line.

Rule-based cascade

Ordered chain of fallbacks. Email → SMS → inbox. Each rule specifies a trigger and a target channel.

No fallback

For low-urgency notifications (likes, follows, digests). If delivery fails, the world keeps turning.

When to use fallbacks. Any notification that must reach the user — password resets, security alerts, payment confirmations. If email fails, at least put it in the inbox. If SMS fails, try email. Never let a critical notification vanish.

Simple inbox fallback

The simplest pattern: if email fails, drop an inbox item so the user still sees the notification.

lib/notifications/password-reset.ts
notification({
  id: "password_reset",
  payload: { resetUrl: "string" },
  channels: [
    email({
      subject: "Reset your password",
      body: "Click here: {{resetUrl}}",
    }),
  ],
  fallback: inbox({
    title: "Reset your password",
    body: "Click to set a new password: {{resetUrl}}",
    actionUrl: "{{resetUrl}}",
  }),
})

Rule-based fallbacks

For more control, pass an array of rules. Each rule specifies a trigger condition and a target channel:

lib/notifications/security-alert.ts
notification({
  id: "security_alert",
  payload: { event: "string", ip: "string" },
  channels: [
    email({ subject: "Security alert: {{event}}", body: "From IP {{ip}}" }),
    sms({ body: "Security alert: {{event}} from {{ip}}" }),
  ],
  fallback: [
    { if: "channel.failed", from: "email", then: sms({ body: "Security: {{event}}" }) },
    { if: "channel.failed", from: "sms", then: inbox({ title: "Security: {{event}}" }) },
    { if: "missing_address", from: "email", then: sms({ body: "Security: {{event}}" }) },
  ],
})

Choosing a fallback strategy

Not every notification needs a fallback. Walk through these questions to decide if and how to configure one:

Is delivery failure acceptable?

Marketing, social updates, digests — if the user misses one, it's fine. Skip the fallback.

One backup channel or a cascade?

Most cases need one backup (email → inbox). Security-critical alerts may need a full cascade (email → SMS → inbox).

Should fallback bypass preferences?

If yes, add required: true to the notification. Without it, fallbacks still respect user opt-outs.

Notification typeRecommended strategyWhy
Password reset, 2FA codeSimple fallback + required: trueMust reach user regardless of preferences. One fallback is enough.
Security alert (suspicious login)Rule-based cascadeTry every available channel in priority order. User safety depends on delivery.
Payment receipt, invoiceSimple fallback (inbox)Important but not urgent. Inbox ensures visibility without over-escalating.
Comment mention, task assignedNo fallbackFailure is acceptable — the user will see it next time they open the app.
New follower, post likedNo fallbackLow-urgency social. Over-delivering with fallbacks creates noise.
Rule of thumb: if you'd wake an engineer at 3 AM because the notification didn't deliver, it needs a fallback. If not, skip it — simpler config, fewer edge cases to test.

Common fallback patterns

Use casePrimaryFallbackPattern
Password resetEmailInboxSimple — one fallback target
Security alertEmail + SMSEmail → SMS → InboxCascade — each failure tries the next
Payment receiptEmailInbox (if no email address)Missing address — graceful degradation
Team inviteEmailInbox (if user unsubscribed)Preference skip — still reach the user via pull

Trigger conditions

TriggerFires when
"channel.failed"A channel exhausts all retry attempts
"missing_address"Recipient lacks the destination (no email, no phone)
"skipped"Channel was skipped by preferences

The from field

Optionally scope the rule to a specific source channel. Without from, the rule matches any channel that hits the trigger condition.

Rules evaluate in order. The first matching rule fires. If you need a cascade (email → SMS → inbox), order your rules from most specific to broadest. A fallback channel can itself have a fallback — the engine processes the chain recursively.

Fallback and retries

Fallback triggers after all retries are exhausted, or after a provider returns a permanent error. Here's the full sequence for a failed email with an inbox fallback:

1
delivery.created

Email delivery queued to provider.

2
delivery.attempt (x3)

Retries with exponential backoff. All three fail.

3
delivery.failed

Email marked as permanently failed. Retries exhausted.

4
fallback.triggered

Fallback rule matches — inbox fallback fires.

5
inbox.created

Fallback inbox item written. User still sees the notification.

Interactions

ScenarioBehavior
Fallback channel disabled by preferencesSkipped — fallbacks respect user preferences
required: true notificationFallback bypasses preferences too — always delivers
Tracking and timelineFallback deliveries are tracked like any other — full visibility in timeline
Watch out. If your fallback targets inbox but the user has inbox disabled, the notification is lost. For truly critical notifications, combine required: true with a fallback to guarantee delivery.

Designing fallback content

The fallback fires when the primary channel failed — but the user doesn't know (or care) about your infrastructure. Write fallback copy that serves the user, not your debugging:

ApproachExampleVerdict
Expose the failure"Email delivery failed — here's your reset link"Bad — confuses users, erodes trust, isn't actionable
Identical to primary"Reset your password" (same as email subject)Good — user gets the same clear message regardless of channel
Adapted for channelInbox: "Reset your password" with action button vs. email's full HTMLBest — same intent, tailored to the channel's strengths

Preserve the action

The user still needs to do something. Include the URL, the code, or whatever lets them complete the task.

Respect channel limits

Inbox titles are short (~60 chars). SMS is 160 chars. Strip details, keep the verb and the link.

Never mention the failure

Users don't understand "email failed" and it makes your product feel broken. Just deliver the message.

lib/notifications/password-reset.ts
// ❌ Bad: exposes infrastructure to the user
fallback: inbox({
  title: "Password reset (email delivery failed)",
  body: "We couldn't send your email. Click here instead: {{resetUrl}}",
})

// ✅ Good: same message, adapted for inbox
fallback: inbox({
  title: "Reset your password",
  body: "Click to set a new password: {{resetUrl}}",
})

// ✅ Good: cascade with channel-appropriate copy
fallback: [
  {
    if: "channel.failed",
    from: "email",
    then: sms({ body: "Your password reset link: {{resetUrl}}" }),
  },
  {
    if: "channel.failed",
    from: "sms",
    then: inbox({ title: "Reset your password" }),
  },
]
Primary channelFallback channelWhat to adapt
Email (rich HTML)InboxStrip HTML formatting. Keep subject as title, first sentence as body.
Email (rich HTML)SMSOne sentence + link only. Drop all context that doesn't fit 160 chars.
SMSInboxSMS body works as-is for inbox. Add a title if the SMS was body-only.
WebhookEmail or inboxWebhooks carry structured data. Write a human-readable summary for the fallback.
Write fallback content at definition time, not as an afterthought. When defining a notification, write all channel variants together — primary and fallback. If you treat fallback copy as a TODO for later, it'll ship with placeholder text that confuses users during the exact moment your system is already degraded.

Testing fallbacks locally

You can't wait for a production failure to learn if your fallback works. Use a provider that always throws to trigger the chain in dev:

tests/fallbacks.test.ts
import type { EmailProvider } from "@notifykitjs/core"

const failingEmailProvider: EmailProvider = {
  id: "failing",
  async send() {
    throw new Error("Simulated provider failure")
  },
}

const testNotify = createNotifyKit({
  notifications: [securityAlert] as const,
  database: memoryAdapter(),
  providers: { email: failingEmailProvider },
  retry: { maxAttempts: 1, delayMs: () => 0 },
})

const result = await testNotify.send({
  recipientId: "user_1",
  notificationId: "security_alert",
  payload: { event: "login", ip: "1.2.3.4" },
})

expect(result.deliveries.some(d => d.status === "failed")).toBe(true)
expect(result.inboxItems.length).toBe(1)
What to testSetupAssert
Email fails → inbox fallbackFailing email provider, maxAttempts: 1result.inboxItems.length === 1
Missing address → fallbackRecipient without email fieldresult.skipped has missing_address, fallback fires
Cascade: email → SMS → inboxBoth email and SMS providers failInbox item exists as last-resort fallback
Fallback respects preferencesDisable inbox for the recipient, no requiredNo fallback delivery — notification is lost (expected)

Monitoring fallback health

Fallbacks firing means your primary channel failed. An occasional trigger is expected (intermittent provider errors). A sustained spike means your provider is degrading and users are getting a worse experience than intended.

lib/notifykit.ts
createNotifyKit({
  // ...
  on: {
    "delivery.failed": ({ delivery, error }) => {
      metrics.inc("notifykit.delivery.failed", {
        channel: delivery.channel,
        provider: delivery.provider,
        notification: delivery.notificationId,
      })
    },
    "delivery.sent": ({ delivery }) => {
      if (delivery.isFallback) {
        metrics.inc("notifykit.fallback.fired", {
          channel: delivery.channel,
          notification: delivery.notificationId,
        })
      }
    },
  },
})
SignalMeaningAction
Fallback rate > 5%Primary provider is partially degradedCheck provider status page; consider switching to backup provider
Fallback rate > 50%Primary provider is down or rejecting most sendsIncident — swap provider or pause sends until resolved
Fallback fires for one notification onlyLikely a payload or template issue, not a provider outageCheck timeline for the specific error message
Fallback fires for one recipient onlyRecipient has invalid address (bounced, deactivated)Mark recipient as inactive or prompt them to update their email
Alert on fallback rate, not count. A single fallback trigger at 3am is noise. But if 20% of your sends are hitting fallbacks over a 5-minute window, that's a provider incident worth waking someone for. Use a ratio alert: fallback.fired / delivery.sent > 0.05.

Debugging fallbacks that don't fire

The opposite problem: a channel fails but no fallback kicks in. Walk through these checks to find where the chain broke:

SymptomRoot causeFix
Email fails but no inbox item appearsNo fallback configured on the notification definitionAdd a fallback field — it's per-notification, not global
Fallback rule exists but doesn't matchfrom field doesn't match the failed channel nameCheck spelling: from: "email" not "Email". Channel names are lowercase.
Fallback fires for failures but not missing addressesRule trigger is "channel.failed" which only matches retries-exhaustedAdd a second rule with if: "missing_address" for the no-destination case
Retries still running — fallback hasn't fired yetFallback waits for all attempts to exhaust before triggeringExpected — reduce maxAttempts if fallback latency is too high
Fallback channel is also disabled by preferencesUser opted out of both primary and fallback channelsAdd required: true to bypass preferences for critical notifications
Simple fallback doesn't fire on preference skipSimple fallback (non-rule) only triggers on channel.failedUse rule-based fallback with if: "skipped" to catch preference-based skips

Using explain() to diagnose

The explain() dry run shows whether a fallback would trigger for a given input — without writing any records:

scripts/diagnose-fallback.ts
const explanation = await notify.explain({
  recipientId: "user_123",
  notificationId: "security_alert",
  payload: { event: "suspicious_login", ip: "203.0.113.1" },
})

for (const [channel, info] of Object.entries(explanation.channels)) {
  console.log(`${channel}: ${info.outcome}`)
}

console.log("Fallback configured:", explanation.fallback)
Simulate failure with a failing provider in tests. The explain() dry run shows what would happen, but it can't simulate a provider error. To verify the full chain (retry exhaustion → fallback trigger → fallback delivery), use the failing provider pattern from the testing section above.