Email & webhook providers
The zero-config defaults — memory adapter, fake email — are great for getting started. For real delivery, configure a provider and test its failure behavior as well as its happy path.
| Channel | Built-in | Custom |
|---|---|---|
@notifykitjs/resend | Postmark, SES, SendGrid — ~20 lines | |
| SMS | — | Twilio, Vonage — implement SmsProvider |
| Webhook | webhookProvider() in core | N/A (generic by design) |
send() function. If your service has an HTTP API, you can wrap it in under 20 lines. NotifyKit handles retries, fallbacks, and rate limiting — your provider just makes the request.Automatic retries
Exponential backoff with configurable max attempts. Transient errors retry; permanent errors fail fast.
Provider failover
Compose multiple providers behind one provider contract when same-channel failover is worth the added complexity.
Durable queues
Connect the queue contract to your worker system. NotifyKit does not yet ship a first-party durable queue adapter.
Environment switching
Fake providers in dev and test, sandbox in staging, live keys in production — one config pattern.
Choosing an email provider
Haven't picked a provider yet? Here's a quick comparison of common options and when they fit:
| Provider | Best for | NotifyKit integration |
|---|---|---|
| Resend | Developer-first transactional email with a small HTTP API. | First-party @notifykitjs/resend package |
| Postmark | Transactional email and provider-managed delivery tooling. | Custom HTTP provider or SMTP through Nodemailer |
| AWS SES | Teams already operating AWS email and identity infrastructure. | Custom SDK provider or SMTP through Nodemailer |
| SendGrid | Teams already using its delivery and analytics tooling. | Custom HTTP provider or SMTP through Nodemailer |
Need to ship fast?
Use Resend — it has a first-party package and a small configuration surface. Swap later if needed.
Already standardized on a provider?
Wrap its HTTP/SDK API or use SMTP through Nodemailer. Add same-channel failover only when your reliability requirements justify it.
Not ready to pick?
Use fakeEmailProvider() — zero config, logs to console. Swap to a real provider with one line when ready.
Resend
npm install @notifykitjs/resendimport { resendProvider } from "@notifykitjs/resend"
export const notify = createNotifyKit({
// ...
providers: {
email: resendProvider({
apiKey: process.env.RESEND_API_KEY!,
from: process.env.RESEND_FROM!, // "App <noreply@app.com>"
replyTo: "support@app.com", // optional
}),
},
})Uses fetch internally with a 10-second timeout. Non-2xx responses throw, triggering the retry + fallback pipeline.
Custom email provider
Every provider implements the same minimal contract:
| Field | Type | Purpose |
|---|---|---|
id | string | Identifier for logs and timeline (e.g. "postmark") |
send(input) | async | Make the API call. Throw on failure. Return { providerMessageId? } on success. |
retry config. You don't need try/catch inside your provider — just let non-2xx responses throw.Wrap Postmark, SES, SendGrid, or any HTTP service in ~20 lines:
import type { EmailProvider } from "@notifykitjs/core"
export function postmarkProvider(opts: {
token: string
from: string
}): EmailProvider {
return {
id: "postmark",
async send(input) {
const res = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": opts.token,
},
body: JSON.stringify({
From: opts.from,
To: input.to,
Subject: input.subject,
TextBody: input.body,
}),
})
if (!res.ok) throw new Error(`Postmark: ${res.status}`)
const json = await res.json() as { MessageID: string }
return { providerMessageId: json.MessageID }
},
}
}Webhook provider
The webhook channel ships its own provider that POSTs a signed JSON envelope:
import { webhookProvider } from "@notifykitjs/core"
createNotifyKit({
// ...
providers: {
webhook: webhookProvider({
secret: process.env.WEBHOOK_SIGNING_SECRET,
}),
},
})Every request includes an x-notifykit-signature: sha256=<hex> header. Receivers verify by HMAC-SHA256-ing the raw body with the shared secret — same pattern as Stripe and GitHub.
Verifying webhooks on the receiving end
import { verifyWebhookSignature } from "@notifykitjs/core"
app.post("/webhooks/notifykit", (req, res) => {
const signature = req.headers["x-notifykit-signature"]
const valid = verifyWebhookSignature(req.rawBody, signature, SECRET)
if (!valid) return res.status(401).end()
// process the notification...
})SMS provider
Same pattern as email. Implement the SmsProvider interface:
import type { SmsProvider } from "@notifykitjs/core"
export function twilioProvider(opts: {
accountSid: string
authToken: string
from: string
}): SmsProvider {
return {
id: "twilio",
async send(input) {
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${opts.accountSid}/Messages.json`,
{
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${opts.accountSid}:${opts.authToken}`)}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
From: opts.from,
To: input.to,
Body: input.body,
}),
},
)
if (!res.ok) throw new Error(`Twilio: ${res.status}`)
const json = await res.json() as { sid: string }
return { providerMessageId: json.sid }
},
}
}Smoke-testing your provider
Before wiring a custom provider into your app, test it in isolation. This catches auth issues, payload problems, and network errors without touching the rest of the stack:
// Run: npx tsx scripts/test-provider.ts
import { postmarkProvider } from "./lib/providers/postmark"
const provider = postmarkProvider({
token: process.env.POSTMARK_TOKEN!,
from: "test@yourapp.com",
})
const result = await provider.send({
to: "your-own-email@gmail.com",
subject: "[TEST] NotifyKit provider smoke test",
body: "If you see this, the provider works.",
})
console.log("✓ Sent:", result.providerMessageId ?? "(no message ID returned)")| You see | Meaning | Fix |
|---|---|---|
✓ Sent: msg_abc123 | Provider works — safe to wire into NotifyKit | None needed |
401 Unauthorized | API key is invalid or expired | Regenerate the key in the provider dashboard |
403 / domain not verified | The "from" address uses an unverified domain | Verify the domain in provider settings (DNS records) |
422 / invalid recipient | Test address rejected — try a different to | Use a real address you own; some providers reject + aliases |
fetch failed / ENOTFOUND | Network or DNS issue | Check internet connection; verify the API URL in your provider code |
send(input) method with the same shape. If the isolated test works but the wired version doesn't, the issue is in your config (wrong env var name, missing ! assertion), not the provider.Provider input shapes
Each provider type receives a different input object:
| Provider | Input fields |
|---|---|
EmailProvider | to, subject, body |
SmsProvider | to, body |
WebhookProvider | url, headers, payload (full notification context) |
Queues & retries
The queue decides when delivery code runs. Pick based on your deployment:
| Queue | Delivery timing | Survives restart | Best for |
|---|---|---|---|
inlineQueue() | send() awaits provider | N/A | Scripts, tests, CLIs |
setTimeoutQueue() | Background via setTimeout | No — in-flight lost on crash | Web servers, single-instance apps |
| Custom (BullMQ, SQS) | External worker picks up jobs | Yes — jobs persist in Redis/SQS | Multi-instance production, serverless |
setTimeoutQueue() jobs will be lost. Use BullMQ (with Redis) or SQS, or stick with inlineQueue() and accept the latency hit.Implement the Queue interface and persist the serializable DeliveryJob. Never serialize the run callback:
import type { Queue } from "@notifykitjs/core"
const bullQueue: Queue = {
async enqueue(job) {
await queue.add("notifykit", job, { jobId: job.deliveryId })
},
async drain() {
await queue.drain()
},
}Complete BullMQ implementation
The stub above shows the contract. Here's a BullMQ starting point with Redis connection, worker setup, and graceful shutdown. Review it against your retry, retention, connection, and monitoring requirements:
import { Queue as BullQueue } from "bullmq"
import type { Queue as NotifyKitQueue } from "@notifykitjs/core"
const connection = { host: process.env.REDIS_HOST!, port: 6379 }
const deliveryQueue = new BullQueue("notifykit-deliveries", {
connection,
defaultJobOptions: {
removeOnComplete: 1000, // keep last 1000 completed jobs for debugging
removeOnFail: 5000, // keep last 5000 failed for investigation
},
})
export const bullMQQueue: NotifyKitQueue = {
async enqueue(job) {
await deliveryQueue.add("deliver", job, {
jobId: job.deliveryId, // prevents duplicate jobs on retry
})
},
async drain() {
await deliveryQueue.close()
},
}import { Worker } from "bullmq"
import { notify } from "./lib/notifykit"
const connection = { host: process.env.REDIS_HOST!, port: 6379 }
const worker = new Worker(
"notifykit-deliveries",
async (job) => {
await notify.processDeliveryJob(job.data)
},
{
connection,
concurrency: 10, // parallel deliveries
limiter: { max: 50, duration: 1000 }, // 50 jobs/sec max
},
)
worker.on("failed", (job, err) => {
console.error(`Delivery ${job?.id} failed: ${err.message}`)
})
// Graceful shutdown
process.on("SIGTERM", async () => {
await worker.close()
process.exit(0)
})| Design choice | Why | Adjust when |
|---|---|---|
jobId: job.deliveryId | Prevents duplicate queue entries if send() retries enqueue | Remove if you want BullMQ to generate IDs (rare) |
concurrency: 10 | Processes 10 deliveries in parallel per worker instance | Increase for high volume, decrease if providers rate-limit you |
removeOnComplete: 1000 | Keeps Redis memory bounded while allowing debug inspection | Decrease on memory-constrained Redis, increase for longer audit trail |
| Separate worker process | Deliveries survive web server restarts and deploys | Run in-process if you only have one server and want simplicity |
notify instance. It needs access to the same notification definitions, providers, and retry config. Extract your createNotifyKit() setup into a shared lib/notifykit.ts and import it from both the web server and the worker.Choosing your queue
Use this decision table based on what you can tolerate:
| Question | If no | If yes |
|---|---|---|
| Can you lose in-flight deliveries on crash? | Use BullMQ/SQS (durable) | Use setTimeoutQueue() |
| Does response latency matter for the send caller? | Use inlineQueue() — simplest | Use setTimeoutQueue() or BullMQ |
| Are you on serverless (Vercel, Lambda)? | Any queue works | Use inlineQueue() or external queue with a worker |
| Do you need delivery metrics and job inspection? | Any queue works | Use BullMQ — comes with Bull Board UI for free |
Retry configuration
createNotifyKit({
// ...
retry: {
maxAttempts: 5,
delayMs: (attempt) => Math.min(1000 * 2 ** (attempt - 1), 30_000),
},
})With the example configuration above, the retry timeline looks like:
Immediate. If it fails, wait 1s.
After 1s. If it fails, wait 2s.
After 2s. If it fails, wait 4s.
After 4s. If it fails, wait 8s.
After 8s. If it fails → delivery.failed hook fires, fallback triggers.
Transient vs permanent errors
Not all failures should be retried. A 429 (rate limit) or 503 (service unavailable) will likely succeed on retry. A 400 (bad request) or 422 (invalid recipient) never will. Your provider controls this:
| Error type | Provider behavior | Engine response |
|---|---|---|
| Transient (retryable) | Throw a regular Error | Retries up to maxAttempts, then fails with fallback |
| Permanent (not retryable) | Throw with permanent: true property | Immediately fails — no retries, fallback triggers right away |
import type { EmailProvider } from "@notifykitjs/core"
export function myProvider(opts): EmailProvider {
return {
id: "my-esp",
async send(input) {
const res = await fetch("https://api.provider.com/send", {
method: "POST",
headers: { Authorization: `Bearer ${opts.apiKey}` },
body: JSON.stringify({ to: input.to, subject: input.subject, body: input.body }),
})
if (res.ok) {
const json = await res.json()
return { providerMessageId: json.id }
}
// Permanent errors — retrying won't help
if (res.status === 400 || res.status === 422) {
const err = new Error(`Provider rejected: ${res.status}`)
;(err as any).permanent = true
throw err
}
// Transient errors — retry with backoff
throw new Error(`Provider error: ${res.status}`)
},
}
}| HTTP status | Classification | Examples |
|---|---|---|
400, 422 | Permanent | Invalid email address, malformed payload, recipient bounced |
401, 403 | Permanent | Bad API key, account suspended, insufficient permissions |
429 | Transient | Provider rate limit — back off and retry |
500, 502, 503 | Transient | Provider outage — usually recovers within seconds |
| Network error / timeout | Transient | DNS failure, connection reset, read timeout |
Provider failover
Channel-level fallbacks (email→inbox) change the delivery mechanism when a channel fails. Provider failover is different — it keeps the same channel (email) but switches to a backup provider (Resend→Postmark) when the primary is down.
| Mechanism | What changes | Use when |
|---|---|---|
| Channel fallback | Channel (email → inbox) | User should get the notification somewhere, even if degraded |
| Provider failover | Provider (Resend → Postmark) | Email must arrive as email — different provider, same experience |
Implement provider failover by wrapping multiple providers into one that tries each in order:
import type { EmailProvider } from "@notifykitjs/core"
export function failoverEmailProvider(
providers: EmailProvider[]
): EmailProvider {
return {
id: providers.map(p => p.id).join("+"),
async send(input) {
let lastError: Error | null = null
for (const provider of providers) {
try {
return await provider.send(input)
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
// If permanent error, don't try the next provider either
if ((lastError as any).permanent) throw lastError
// Otherwise, try the next provider
}
}
// All providers failed with transient errors
throw lastError!
},
}
}import { resendProvider } from "@notifykitjs/resend"
const notify = createNotifyKit({
// ...
providers: {
email: failoverEmailProvider([
resendProvider({ apiKey: process.env.RESEND_API_KEY!, from: "app@example.com" }),
postmarkProvider({ token: process.env.POSTMARK_TOKEN!, from: "app@example.com" }),
]),
},
})Resend gets the first attempt. If it succeeds, done.
500, timeout, or network error. Move to backup.
Postmark gets the same input. If it succeeds, the user gets their email.
The wrapper throws the last error. The engine retries the whole chain per your retry config.
| Design choice | Recommendation | Why |
|---|---|---|
| Permanent errors | Don't try backup | A 422 (bad address) will fail on any provider — no point trying Postmark |
| Provider order | Cheapest/fastest first | The backup only fires during outages — optimize for the happy path |
| Monitoring | Log which provider succeeded | Track when backups fire — sustained backup usage means your primary is degraded |
| From address | Same from on both | Recipients see a consistent sender regardless of which provider fired |
Environment-based provider switching
Most apps need different providers per environment — fake in dev, a sandbox key in staging, production keys in prod. Wire this with a simple function that reads the environment:
import { fakeEmailProvider } from "@notifykitjs/core"
import { resendProvider } from "@notifykitjs/resend"
export function emailProvider() {
switch (process.env.NODE_ENV) {
case "production":
return resendProvider({
apiKey: process.env.RESEND_API_KEY!,
from: process.env.EMAIL_FROM!,
})
case "test":
return fakeEmailProvider() // logs only, no network
default: // development
return process.env.RESEND_API_KEY
? resendProvider({ apiKey: process.env.RESEND_API_KEY, from: "dev@localhost" })
: fakeEmailProvider()
}
}
// lib/notifykit.ts
import { emailProvider } from "./providers"
export const notify = createNotifyKit({
notifications: [...] as const,
database: adapter(),
providers: { email: emailProvider() },
})| Environment | Provider | Behavior |
|---|---|---|
| Development (no key) | fakeEmailProvider() | Logs the subject, recipient, and body to the terminal. No network calls. |
| Development (with key) | Real provider in sandbox mode | Sends to your own email for visual verification. Useful for testing templates. |
| Test (CI) | fakeEmailProvider() | Deterministic — never hits the network. Tests run offline and fast. |
| Staging | Real provider, sandbox/test key | Validates the full delivery path without sending to real users. |
| Production | Real provider, live key | Full delivery to recipients. Requires verified domain. |
devMode: true as a safety net. Even if you accidentally load a real provider in development, devMode blocks all actual sends and logs what would have happened. Set it from the environment: devMode: process.env.NODE_ENV !== "production"..env.local (gitignored) for local keys and your hosting platform's secret management for staging/production. The ! assertion (process.env.RESEND_API_KEY!) will throw at startup if the var is missing — which is what you want in production.Monitoring provider health
A provider that worked at deploy time can degrade silently — rate limits tighten, API keys expire, DNS flaps. Use hooks to track delivery outcomes and surface problems before users notice:
createNotifyKit({
// ...
on: {
"delivery.sent": ({ delivery }) => {
metrics.inc("notifykit.delivery.sent", {
provider: delivery.provider,
channel: delivery.channel,
})
metrics.histogram("notifykit.delivery.latency_ms", delivery.latencyMs, {
provider: delivery.provider,
})
},
"delivery.failed": ({ delivery }) => {
metrics.inc("notifykit.delivery.failed", {
provider: delivery.provider,
channel: delivery.channel,
permanent: String(delivery.permanent),
})
// Alert if failure rate spikes
if (delivery.attempts >= 3) {
alerting.warn(`Provider ${delivery.provider} failing after ${delivery.attempts} attempts: ${delivery.error}`)
}
},
"delivery.retrying": ({ delivery }) => {
metrics.inc("notifykit.delivery.retry", {
provider: delivery.provider,
attempt: String(delivery.attempts),
})
},
},
})| Metric | Baseline | Alert when | Likely cause |
|---|---|---|---|
| Delivery success rate | Your channel/provider SLO | Sustained breach of that SLO | Provider outage, expired API key, domain verification lost |
| Delivery latency (p95) | Observed by provider, channel, and region | Sustained regression from baseline | Provider under load, network congestion, DNS resolution slow |
| Retry rate | Observed during normal traffic | Sudden or sustained increase | Provider rate limiting you, intermittent 5xx errors |
| Permanent failure rate | Observed by notification and source | Spike above baseline | Bad recipient data (bounces), payload validation changes upstream |
| Failover activations | 0 in normal operation | Any sustained failover | Primary provider degraded — investigate before backup budget drains |
After first deploy, observe metrics for 48h to establish normal ranges for your volume and provider.
Set thresholds relative to your baseline. A 10k/day app alerting at 95% catches 500+ lost emails.
delivery.failed hook alone. It catches 90% of production issues — expired keys, bounced addresses, provider outages. Add latency tracking and retry metrics when you need to distinguish "slow but working" from "about to fail."