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.

ChannelBuilt-inCustom
Email@notifykitjs/resendPostmark, SES, SendGrid — ~20 lines
SMSTwilio, Vonage — implement SmsProvider
WebhookwebhookProvider() in coreN/A (generic by design)
Every provider is just a 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:

ProviderBest forNotifyKit integration
ResendDeveloper-first transactional email with a small HTTP API.First-party @notifykitjs/resend package
PostmarkTransactional email and provider-managed delivery tooling.Custom HTTP provider or SMTP through Nodemailer
AWS SESTeams already operating AWS email and identity infrastructure.Custom SDK provider or SMTP through Nodemailer
SendGridTeams 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/resend
lib/notifykit.ts
import { 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:

FieldTypePurpose
idstringIdentifier for logs and timeline (e.g. "postmark")
send(input)asyncMake the API call. Throw on failure. Return { providerMessageId? } on success.
Error contract. Throw any error to trigger retries. NotifyKit catches it, records it in the timeline, and retries per your 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:

lib/providers/postmark.ts
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:

lib/notifykit.ts
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

routes/webhooks.ts
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:

lib/providers/twilio.ts
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:

scripts/test-provider.ts
// 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 seeMeaningFix
✓ Sent: msg_abc123Provider works — safe to wire into NotifyKitNone needed
401 UnauthorizedAPI key is invalid or expiredRegenerate the key in the provider dashboard
403 / domain not verifiedThe "from" address uses an unverified domainVerify the domain in provider settings (DNS records)
422 / invalid recipientTest address rejected — try a different toUse a real address you own; some providers reject + aliases
fetch failed / ENOTFOUNDNetwork or DNS issueCheck internet connection; verify the API URL in your provider code
Test before you commit. A provider that passes this script will work with NotifyKit — the engine calls the same 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:

ProviderInput fields
EmailProviderto, subject, body
SmsProviderto, body
WebhookProviderurl, headers, payload (full notification context)

Queues & retries

The queue decides when delivery code runs. Pick based on your deployment:

QueueDelivery timingSurvives restartBest for
inlineQueue()send() awaits providerN/AScripts, tests, CLIs
setTimeoutQueue()Background via setTimeoutNo — in-flight lost on crashWeb servers, single-instance apps
Custom (BullMQ, SQS)External worker picks up jobsYes — jobs persist in Redis/SQSMulti-instance production, serverless
Serverless needs a durable queue. Vercel/Lambda functions die after the response. 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()
  },
}
Retries live in the engine, not the queue. A queue's only job is to decide when a worker runs. Every queue implementation gets retries and fallback channels for free.

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:

lib/queue.ts
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()
  },
}
worker.ts
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 choiceWhyAdjust when
jobId: job.deliveryIdPrevents duplicate queue entries if send() retries enqueueRemove if you want BullMQ to generate IDs (rare)
concurrency: 10Processes 10 deliveries in parallel per worker instanceIncrease for high volume, decrease if providers rate-limit you
removeOnComplete: 1000Keeps Redis memory bounded while allowing debug inspectionDecrease on memory-constrained Redis, increase for longer audit trail
Separate worker processDeliveries survive web server restarts and deploysRun in-process if you only have one server and want simplicity
The worker must import the same 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:

QuestionIf noIf 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() — simplestUse setTimeoutQueue() or BullMQ
Are you on serverless (Vercel, Lambda)?Any queue worksUse inlineQueue() or external queue with a worker
Do you need delivery metrics and job inspection?Any queue worksUse 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:

1
Attempt 1

Immediate. If it fails, wait 1s.

2
Attempt 2

After 1s. If it fails, wait 2s.

3
Attempt 3

After 2s. If it fails, wait 4s.

4
Attempt 4

After 4s. If it fails, wait 8s.

5
Attempt 5 (final)

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 typeProvider behaviorEngine response
Transient (retryable)Throw a regular ErrorRetries up to maxAttempts, then fails with fallback
Permanent (not retryable)Throw with permanent: true propertyImmediately 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 statusClassificationExamples
400, 422PermanentInvalid email address, malformed payload, recipient bounced
401, 403PermanentBad API key, account suspended, insufficient permissions
429TransientProvider rate limit — back off and retry
500, 502, 503TransientProvider outage — usually recovers within seconds
Network error / timeoutTransientDNS failure, connection reset, read timeout
When in doubt, let it retry. Only mark errors as permanent when you're certain the same input will never succeed. Unnecessary retries add provider traffic and latency, while skipping retries on a transient failure can lose the notification entirely.

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.

MechanismWhat changesUse when
Channel fallbackChannel (email → inbox)User should get the notification somewhere, even if degraded
Provider failoverProvider (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:

lib/providers/failover.ts
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!
    },
  }
}
lib/notifykit.ts
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" }),
    ]),
  },
})
1
Try primary

Resend gets the first attempt. If it succeeds, done.

2
Primary fails (transient)

500, timeout, or network error. Move to backup.

3
Try backup

Postmark gets the same input. If it succeeds, the user gets their email.

4
Both fail

The wrapper throws the last error. The engine retries the whole chain per your retry config.

Design choiceRecommendationWhy
Permanent errorsDon't try backupA 422 (bad address) will fail on any provider — no point trying Postmark
Provider orderCheapest/fastest firstThe backup only fires during outages — optimize for the happy path
MonitoringLog which provider succeededTrack when backups fire — sustained backup usage means your primary is degraded
From addressSame from on bothRecipients see a consistent sender regardless of which provider fired
Failover happens inside a single retry attempt. If Resend fails and Postmark succeeds, that counts as one successful attempt — no retry consumed. If both fail, the engine retries the whole failover chain on the next attempt. This means with 2 providers and 3 retry attempts, you get up to 6 total provider calls before giving up.

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:

lib/providers.ts
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() },
})
EnvironmentProviderBehavior
Development (no key)fakeEmailProvider()Logs the subject, recipient, and body to the terminal. No network calls.
Development (with key)Real provider in sandbox modeSends to your own email for visual verification. Useful for testing templates.
Test (CI)fakeEmailProvider()Deterministic — never hits the network. Tests run offline and fast.
StagingReal provider, sandbox/test keyValidates the full delivery path without sending to real users.
ProductionReal provider, live keyFull delivery to recipients. Requires verified domain.
Use 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".
Never commit API keys. Use .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:

lib/notifykit.ts
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),
      })
    },
  },
})
MetricBaselineAlert whenLikely cause
Delivery success rateYour channel/provider SLOSustained breach of that SLOProvider outage, expired API key, domain verification lost
Delivery latency (p95)Observed by provider, channel, and regionSustained regression from baselineProvider under load, network congestion, DNS resolution slow
Retry rateObserved during normal trafficSudden or sustained increaseProvider rate limiting you, intermittent 5xx errors
Permanent failure rateObserved by notification and sourceSpike above baselineBad recipient data (bounces), payload validation changes upstream
Failover activations0 in normal operationAny sustained failoverPrimary provider degraded — investigate before backup budget drains
1
Baseline

After first deploy, observe metrics for 48h to establish normal ranges for your volume and provider.

2
Alert on deviation

Set thresholds relative to your baseline. A 10k/day app alerting at 95% catches 500+ lost emails.

3
Diagnose with timeline

When alerts fire, use timeline to see exact error sequences and explain() to dry-run the failing payload.

Start with the 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."