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.
Simple inbox fallback
The simplest pattern: if email fails, drop an inbox item so the user still sees the notification.
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:
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 type | Recommended strategy | Why |
|---|---|---|
| Password reset, 2FA code | Simple fallback + required: true | Must reach user regardless of preferences. One fallback is enough. |
| Security alert (suspicious login) | Rule-based cascade | Try every available channel in priority order. User safety depends on delivery. |
| Payment receipt, invoice | Simple fallback (inbox) | Important but not urgent. Inbox ensures visibility without over-escalating. |
| Comment mention, task assigned | No fallback | Failure is acceptable — the user will see it next time they open the app. |
| New follower, post liked | No fallback | Low-urgency social. Over-delivering with fallbacks creates noise. |
Common fallback patterns
| Use case | Primary | Fallback | Pattern |
|---|---|---|---|
| Password reset | Inbox | Simple — one fallback target | |
| Security alert | Email + SMS | Email → SMS → Inbox | Cascade — each failure tries the next |
| Payment receipt | Inbox (if no email address) | Missing address — graceful degradation | |
| Team invite | Inbox (if user unsubscribed) | Preference skip — still reach the user via pull |
Trigger conditions
| Trigger | Fires 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.
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:
Email delivery queued to provider.
Retries with exponential backoff. All three fail.
Email marked as permanently failed. Retries exhausted.
Fallback rule matches — inbox fallback fires.
Fallback inbox item written. User still sees the notification.
Interactions
| Scenario | Behavior |
|---|---|
| Fallback channel disabled by preferences | Skipped — fallbacks respect user preferences |
required: true notification | Fallback bypasses preferences too — always delivers |
| Tracking and timeline | Fallback deliveries are tracked like any other — full visibility in timeline |
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:
| Approach | Example | Verdict |
|---|---|---|
| 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 channel | Inbox: "Reset your password" with action button vs. email's full HTML | Best — 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.
// ❌ 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 channel | Fallback channel | What to adapt |
|---|---|---|
| Email (rich HTML) | Inbox | Strip HTML formatting. Keep subject as title, first sentence as body. |
| Email (rich HTML) | SMS | One sentence + link only. Drop all context that doesn't fit 160 chars. |
| SMS | Inbox | SMS body works as-is for inbox. Add a title if the SMS was body-only. |
| Webhook | Email or inbox | Webhooks carry structured data. Write a human-readable summary for the fallback. |
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:
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 test | Setup | Assert |
|---|---|---|
| Email fails → inbox fallback | Failing email provider, maxAttempts: 1 | result.inboxItems.length === 1 |
| Missing address → fallback | Recipient without email field | result.skipped has missing_address, fallback fires |
| Cascade: email → SMS → inbox | Both email and SMS providers fail | Inbox item exists as last-resort fallback |
| Fallback respects preferences | Disable inbox for the recipient, no required | No 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.
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,
})
}
},
},
})| Signal | Meaning | Action |
|---|---|---|
| Fallback rate > 5% | Primary provider is partially degraded | Check provider status page; consider switching to backup provider |
| Fallback rate > 50% | Primary provider is down or rejecting most sends | Incident — swap provider or pause sends until resolved |
| Fallback fires for one notification only | Likely a payload or template issue, not a provider outage | Check timeline for the specific error message |
| Fallback fires for one recipient only | Recipient has invalid address (bounced, deactivated) | Mark recipient as inactive or prompt them to update their email |
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:
| Symptom | Root cause | Fix |
|---|---|---|
| Email fails but no inbox item appears | No fallback configured on the notification definition | Add a fallback field — it's per-notification, not global |
| Fallback rule exists but doesn't match | from field doesn't match the failed channel name | Check spelling: from: "email" not "Email". Channel names are lowercase. |
| Fallback fires for failures but not missing addresses | Rule trigger is "channel.failed" which only matches retries-exhausted | Add a second rule with if: "missing_address" for the no-destination case |
| Retries still running — fallback hasn't fired yet | Fallback waits for all attempts to exhaust before triggering | Expected — reduce maxAttempts if fallback latency is too high |
| Fallback channel is also disabled by preferences | User opted out of both primary and fallback channels | Add required: true to bypass preferences for critical notifications |
| Simple fallback doesn't fire on preference skip | Simple fallback (non-rule) only triggers on channel.failed | Use 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:
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)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.