BackProduction

Email and webhook providers

Send through Resend, any SMTP server, or signed webhooks, or write a provider of your own.

Choosing

A provider is what actually sends an email, SMS, or webhook. The inbox needs none: it is a row in your database. You pass one provider per channel to createNotifyKit(), and NotifyKit refuses to start if a notification uses a channel that has no provider.

ChannelReady to useOr
Email@notifykitjs/resend, or @notifykitjs/nodemailer for any SMTP serverWrap Postmark, SES, or SendGrid in a few lines
SMSNone built inWrap Twilio, Vonage, or any HTTP API
WebhookwebhookProvider() from the coreRarely needed; the built-in one is generic
Development and testsfakeEmailProvider(), fakeSmsProvider(), fakeWebhookProvider()They record each message in a sent array instead of sending it

A provider only makes the request. Retries, fallback channels, preferences, and rate limits all happen in the engine, so a custom provider is usually a single fetch.

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: "Acme <no-reply@acme.com>",
			replyTo: "support@acme.com", // optional
		}),
	},
})
OptionDefaultWhat it does
apiKeyRequiredYour Resend API key
fromRequiredThe sender, with the domain verified in Resend
replyToNoneOne address or a list
timeoutMs10000How long to wait for Resend before the attempt fails
baseUrlhttps://api.resend.comFor a proxy or a test server
fetchGlobal fetchA custom fetch, for tests

The body is sent as HTML. A 400, 401, 403, or 422 from Resend is marked as a permanent error and is not retried; anything else, including a timeout, is retried.

SMTP

@notifykitjs/nodemailer sends through any SMTP server, which covers Postmark, SES, SendGrid, Mailgun, and your own mail server without writing a provider.

nodemailerProvider({
	host: "smtp.postmarkapp.com",
	port: 587,
	auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
	from: "Acme <no-reply@acme.com>",
})

port defaults to 587, and secure to true only on port 465. Sends time out after timeoutMs (10 seconds by default). SMTP 550 to 554 replies, and errors about an invalid address or a user that does not exist, are marked permanent.

Webhooks

The webhook channel POSTs a JSON envelope to the URL in the notification’s definition. Give the provider a secret and every request is signed:

lib/notifykit.ts
import { webhookProvider } from "@notifykitjs/core"

createNotifyKit({
	// ...
	providers: {
		webhook: webhookProvider({ secret: process.env.WEBHOOK_SIGNING_SECRET }),
	},
})

The body the receiver gets:

{
	"notificationId": "deploy_failed",
	"recipientId": "user_123",
	"tenantId": "org_acme",
	"payload": { "service": "api" },
	"sentAt": "2026-04-02T14:03:11.000Z"
}
  • With a secret, the x-notifykit-signature header is sha256= followed by the hex HMAC-SHA256 of the raw body. Without one, NotifyKit warns at startup.
  • Requests time out after timeoutMs (10 seconds), and redirects are refused rather than followed.
  • Webhook URLs that point at private or internal addresses (localhost, 10.x, 192.168.x, .internal, cloud metadata) are blocked, including hostnames that resolve to one. See Security.
  • Any non-2xx response is an error, and is retried.

Verifying on the receiving end

Verify against the raw body text, before parsing it. verifyWebhookSignature() does a constant-time comparison:

app/api/hooks/notifykit/route.ts
import { verifyWebhookSignature } from "@notifykitjs/core"

export async function POST(request: Request) {
	const body = await request.text()
	const signature = request.headers.get("x-notifykit-signature")
	if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SIGNING_SECRET!)) {
		return new Response("Invalid signature", { status: 401 })
	}
	const event = JSON.parse(body)
	// ...
	return new Response("OK")
}

Custom Providers

A provider is an object with an id, used in delivery records and the timeline, and a send() that throws on failure and may return the provider’s message id:

Typesend() receives
EmailProviderto, subject, body (HTML unless the channel sets html: false)
SmsProviderto, body
WebhookProviderurl, headers, and the envelope as payload
lib/providers/postmark.ts
import type { EmailProvider } from "@notifykitjs/core"

export function postmarkProvider(opts: {
	token: string
	from: string
}): EmailProvider {
	return {
		id: "postmark",
		async send({ to, subject, body }) {
			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: to,
					Subject: subject,
					HtmlBody: body,
				}),
			})
			if (!res.ok) throw new Error(`Postmark: ${res.status}`)
			const json = (await res.json()) as { MessageID: string }
			return { providerMessageId: json.MessageID }
		},
	}
}

Before wiring a new provider in, call its send() once from a script with your own address. If that works and the wired version does not, the problem is in the configuration, not the provider.

Errors and Retries

Any error a provider throws is recorded in the timeline and retried. An error with permanent: true skips the remaining attempts and fails the delivery at once, so the fallback channel, if there is one, fires right away. Mark an error permanent only when the same request can never succeed:

if (res.status === 400 || res.status === 422) {
	throw Object.assign(new Error(`Rejected: ${res.status}`), { permanent: true })
}
throw new Error(`Provider error: ${res.status}`) // 429, 5xx, network: retried

By default a delivery gets 3 attempts: the first immediately, the second after 250 ms, the third after another second. Change either part with retry:

createNotifyKit({
	// ...
	retry: {
		maxAttempts: 5, // including the first
		// before attempt 2, 3, ...
		delayMs: (attempt) => Math.min(1000 * 2 ** (attempt - 2), 30_000),
	},
})

After the last attempt the delivery is marked failed, the delivery.failed hook runs, and any fallback for that channel fires.

Queues

The queue decides when provider calls run. Retries happen inside the job either way, so every queue gets the same retry and fallback behaviour.

QueueWhen delivery runsSurvives a restart
inlineQueue()Inside send(), which waits for it. The defaultNothing is left in flight when send() returns
setTimeoutQueue()Right after send() returns, in the same processNo: in-flight deliveries are lost if the process exits
Your ownIn a worker that reads from Redis, SQS, or similarYes, if the queue is durable

A custom queue implements enqueue(job, run) and drain(). Store the job, which is plain JSON; never try to serialize run. The worker hands the job back to notify.processDeliveryJob():

lib/queue.ts
import { Queue as BullQueue } from "bullmq"
import type { Queue } from "@notifykitjs/core"

const deliveries = new BullQueue("notifykit", { connection })

export const bullQueue: Queue = {
	async enqueue(job) {
		await deliveries.add("deliver", job, { jobId: job.deliveryId })
	},
	async drain() {},
}
worker.ts
import { Worker } from "bullmq"
import { notify } from "./lib/notifykit"

new Worker("notifykit", (job) => notify.processDeliveryJob(job.data), {
	connection,
	concurrency: 10,
})

The worker must import the same notify setup as your app, so it has the same definitions, providers, and retry policy. processDeliveryJob() checks the job against its stored delivery record and does nothing if that delivery is no longer pending, so a queue that delivers a job twice does not call the provider twice. See Production readiness for what this does and does not guarantee.

Provider Failover

A fallback channel changes how the message arrives, for example email to inbox. To keep sending email but through a second provider when the first is down, wrap both in one provider:

lib/providers/failover.ts
import type { EmailProvider } from "@notifykitjs/core"

export function failover(providers: EmailProvider[]): EmailProvider {
	return {
		id: providers.map((p) => p.id).join("+"),
		async send(input) {
			let last: unknown
			for (const provider of providers) {
				try {
					return await provider.send(input)
				} catch (err) {
					// a bad address fails everywhere
					if ((err as { permanent?: boolean }).permanent) throw err
					last = err
				}
			}
			throw last
		},
	}
}

One pass through the list counts as one attempt. If every provider fails, the engine retries the whole list, so two providers and three attempts can mean six calls.

Per Environment

Pick the provider from the environment, so tests never touch the network:

lib/providers.ts
import { fakeEmailProvider } from "@notifykitjs/core"
import { resendProvider } from "@notifykitjs/resend"

export function emailProvider() {
	if (process.env.NODE_ENV === "test" || !process.env.RESEND_API_KEY) {
		return fakeEmailProvider() // records to .sent, sends nothing
	}
	return resendProvider({
		apiKey: process.env.RESEND_API_KEY,
		from: process.env.EMAIL_FROM!,
	})
}

For a real provider in development without emailing real people, use mode: "development", which blocks every send except to an allowlist. See Testing and dev mode.