Channels
A channel is a delivery mechanism. Each notification definition lists the channels it should be sent through. NotifyKit ships four channel types — inbox, email, SMS, and webhook.
| Channel | Type | How it delivers |
|---|---|---|
| Inbox | Pull | Writes a row — user fetches it via hook or API |
| Push | Sends via your email provider (Resend, Postmark, etc.) | |
| SMS | Push | Sends via your SMS provider (Twilio, Vonage, etc.) |
| Webhook | Push | POSTs a signed JSON payload to a URL |
At a glance
Minimal working config for each channel type. Copy the one you need, then scroll down for options and advanced patterns:
Inbox
Pull channel — writes a row, user fetches it.
inbox({
title: "{{actorName}} mentioned you",
body: "In {{postTitle}}",
actionUrl: "{{postUrl}}",
})Push channel — queued to your email provider.
email({
subject: "{{actorName}} mentioned you",
body: "Open {{postUrl}}\n\n{{_unsubscribeUrl}}",
})SMS
Push channel — sent via Twilio/Vonage/etc.
sms({
body: "Your code is {{code}}",
})Webhook
Push channel — signed POST to a URL.
webhook({
url: "https://hooks.slack.com/...",
})Inbox
The inbox channel writes a row to your database. It's user-pulled (the recipient fetches it via the React hook or REST API), so it's never subject to quiet hours or delivery failures.
| Property | Required | Description |
|---|---|---|
title | Yes | Main text shown in the inbox list |
body | No | Secondary text or preview |
actionUrl | No | Link destination when the item is clicked |
import { channel } from "@notifykitjs/core"
const inbox = channel.inbox()
inbox({
title: "{{actorName}} mentioned you",
body: "In {{postTitle}}",
actionUrl: "{{postUrl}}",
})Inbox items support read/unread state, archiving, and deletion. All mutations publish realtime events when a realtime adapter is configured.
The email channel queues a delivery job that your configured email provider sends.
| Property | Required | Description |
|---|---|---|
subject | Yes | Email subject line (supports template variables) |
body | Yes | Plain text body (supports template variables) |
const email = channel.email()
email({
subject: "{{actorName}} mentioned you in {{postTitle}}",
body: "Open {{postUrl}} to reply.\n\nUnsubscribe: {{_unsubscribeUrl}}",
}){{_unsubscribeUrl}} is injected automatically — it links to the one-click unsubscribe handler. Always include it in email bodies.SMS
The SMS channel sends a text message through your configured SMS provider.
| Property | Required | Description |
|---|---|---|
body | Yes | Message text (max ~160 chars recommended) |
const sms = channel.sms()
sms({
body: "Your verification code is {{code}}",
})phone. If the recipient doesn't have a phone number set, the SMS channel resolves to "unavailable" and is skipped.Webhook
The webhook channel POSTs a signed JSON envelope to a URL. Use it for Slack integrations, custom destinations, or forwarding to external services.
| Property | Required | Description |
|---|---|---|
url | Yes | Destination endpoint |
headers | No | Additional HTTP headers |
const webhook = channel.webhook()
webhook({
url: "https://hooks.slack.com/services/T.../B.../xxx",
headers: { "Content-Type": "application/json" },
})Signature verification
When a secret is configured on the webhook provider, every request includes an x-notifykit-signature header.
HMAC-SHA256 of the raw JSON body with your shared secret.
x-notifykit-signature: sha256=<hex> included on every POST.
Compute HMAC-SHA256 of the raw body with the same secret. Compare to header value.
Webhook payload format
Every webhook delivery POSTs a JSON envelope with a consistent shape. Your receiver can rely on this structure regardless of the notification type:
// What your endpoint receives:
{
"event": "notification.delivered",
"notificationId": "deploy_completed",
"recipientId": "user_123",
"payload": {
"projectName": "api-gateway",
"status": "succeeded",
"sha": "abc123f"
},
"metadata": {
"sentAt": "2026-06-27T14:30:00.000Z",
"deliveryId": "del_abc123",
"attempt": 1
}
}| Field | Type | Description |
|---|---|---|
event | string | Always "notification.delivered" for webhook deliveries |
notificationId | string | The notification definition ID — use for routing on the receiver |
recipientId | string | Who this notification is for |
payload | object | Your full typed payload, as passed to send() |
metadata.sentAt | ISO 8601 | When the delivery was dispatched |
metadata.deliveryId | string | Unique delivery ID — use for idempotency on the receiver |
metadata.attempt | number | Retry attempt (1 = first try, 2+ = retry) |
Verifying signatures (receiver code)
When your webhook provider has a secret configured, verify the x-notifykit-signature header before processing:
// Your webhook receiver (any framework)
import { createHmac, timingSafeEqual } from "node:crypto"
const WEBHOOK_SECRET = process.env.NOTIFYKIT_WEBHOOK_SECRET!
function verifySignature(rawBody: string, signatureHeader: string): boolean {
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex")
const received = signatureHeader.replace("sha256=", "")
return timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
)
}
// Express / Node.js example:
app.post("/webhooks/notifykit", (req, res) => {
const signature = req.headers["x-notifykit-signature"]
if (!signature || !verifySignature(req.rawBody, signature)) {
return res.status(401).json({ error: "Invalid signature" })
}
const { notificationId, payload, metadata } = req.body
// Process the webhook...
res.status(200).json({ received: true })
})
// Next.js Route Handler:
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get("x-notifykit-signature")
if (!signature || !verifySignature(rawBody, signature)) {
return Response.json({ error: "Invalid signature" }, { status: 401 })
}
const body = JSON.parse(rawBody)
// Process...
return Response.json({ received: true })
}| Security detail | Why |
|---|---|
timingSafeEqual | Prevents timing attacks — a naive === comparison leaks info about which bytes matched |
| Verify on raw body | The HMAC is computed over the raw string, not a parsed-and-re-serialized object |
| Return 401 on failure | NotifyKit retries on 5xx but not 4xx — a 401 tells it to stop immediately |
Check metadata.deliveryId | Use as an idempotency key on the receiver to handle retries gracefully |
json() middleware), configure a raw body parser for your webhook route. Parsing and re-serializing can change key ordering or whitespace, breaking the signature.Formatting for external services
External services like Slack and Discord expect specific payload shapes. Use render() on the webhook channel to transform your payload into the format each service expects:
// Slack: expects { text } or { blocks }
notification({
id: "deploy_completed",
payload: {
projectName: "string",
status: "string",
sha: "string",
actorName: "string",
},
channels: [
inbox({ title: "Deploy {{status}}: {{projectName}}" }),
webhook({
url: "https://hooks.slack.com/services/T.../B.../xxx",
render: (payload) => ({
body: JSON.stringify({
text: `Deploy *${payload.status}*: ${payload.projectName} (${payload.sha.slice(0, 7)})`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${payload.projectName}* deployed by ${payload.actorName}`,
},
},
{
type: "context",
elements: [
{ type: "mrkdwn", text: `Status: ${payload.status} | SHA: \`${payload.sha.slice(0, 7)}\`` },
],
},
],
}),
}),
}),
],
})
// Discord: expects { content } or { embeds }
webhook({
url: "https://discord.com/api/webhooks/123/abc",
render: (payload) => ({
body: JSON.stringify({
content: `Deploy **${payload.status}**: ${payload.projectName}`,
embeds: [{
title: payload.projectName,
description: `Deployed by ${payload.actorName}`,
color: payload.status === "succeeded" ? 0x00ff00 : 0xff0000,
fields: [
{ name: "SHA", value: `\`${payload.sha.slice(0, 7)}\``, inline: true },
{ name: "Status", value: payload.status, inline: true },
],
}],
}),
}),
})| Service | Required field | Rich formatting |
|---|---|---|
| Slack | text (fallback) | blocks array with Block Kit elements |
| Discord | content (plain text) | embeds array with title, description, color, fields |
| Microsoft Teams | text or @type: MessageCard | Adaptive Cards via attachments |
| Custom endpoint | Whatever your API expects | Full control via render() |
url: "{{webhookUrl}}" with a condition that skips when no URL is set. See conditional channels below.Channel behavior during send
| Channel | Delivery | Quiet hours | Preferences | Retries | Fallback |
|---|---|---|---|---|---|
| Inbox | Immediate (DB write) | Not affected | Respects opt-out | N/A (can't fail) | N/A |
| Via queue + provider | Deferred until window ends | Respects opt-out | Up to 5 with backoff | Supported | |
| SMS | Via queue + provider | Deferred until window ends | Respects opt-out | Up to 5 with backoff | Supported |
| Webhook | Via queue + provider | Deferred until window ends | Respects opt-out | Up to 5 with backoff | Supported |
Retry count and backoff are configured globally via Providers & queues. Fallback channels are configured per-notification — see Fallback channels.
Choosing channels for your notifications
Not every notification needs every channel. Match urgency to intrusiveness — higher urgency means more channels and less user control:
Critical
Act now. User must see this immediately regardless of context. Multiple channels ensure delivery.
| Channels | Email + SMS + Inbox |
| Config | required: true + fallback |
| Examples | Security alert, 2FA, payment failed |
Important
Act soon. User should know within minutes but can choose how they're reached.
| Channels | Email + Inbox |
| Config | Default — user can opt out |
| Examples | Team invite, mention, task assigned |
Informational
FYI. Nice to know but not worth an interruption. User checks on their own time.
| Channels | Inbox only |
| Config | defaultChannels: { email: false } |
| Examples | New follower, post liked, deploy OK |
System-to-system
Machine consumer. No human reads this directly — it triggers automation or syncs state.
| Channels | Webhook (+ Inbox as audit) |
| Config | Rate-limited, signed payload |
| Examples | Slack alert, CI webhook, analytics |
Conditional channels
Sometimes a channel should only fire based on runtime data — SMS only for high-severity alerts, webhook only when a URL is configured, or email only for external users. Use a condition function on any channel to gate delivery at send time:
notification({
id: "incident_alert",
payload: {
severity: "string", // "low" | "medium" | "high" | "critical"
title: "string",
dashboardUrl: "string",
},
channels: [
// Inbox: always fires
inbox({
title: "Incident: {{title}}",
actionUrl: "{{dashboardUrl}}",
}),
// Email: fires for medium+ severity
email({
condition: (payload) => payload.severity !== "low",
subject: "[{{severity}}] {{title}}",
body: "View dashboard: {{dashboardUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
}),
// SMS: fires only for critical
sms({
condition: (payload) => payload.severity === "critical",
body: "CRITICAL: {{title}} — check dashboard immediately",
}),
],
})When a condition returns false, the channel is skipped with reason: "condition_false" in the result — no delivery attempt, no retry, no fallback trigger.
| Pattern | Condition | Use case |
|---|---|---|
| Severity escalation | (p) => p.severity === "critical" | Only page oncall for critical incidents, not every warning |
| Feature flag | (p) => p.smsEnabled === true | Gradually roll out SMS to users who opted into the beta |
| Payload presence | (p) => !!p.webhookUrl | Only POST webhook if the user has configured an endpoint |
| User type | (p) => p.userType === "external" | Email external collaborators but only inbox internal team |
// Dynamic webhook URL from payload:
notification({
id: "deploy_completed",
payload: {
projectName: "string",
status: "string",
webhookUrl: "string", // empty string if not configured
},
channels: [
inbox({
title: "Deploy {{status}}: {{projectName}}",
}),
webhook({
condition: (payload) => !!payload.webhookUrl,
url: "{{webhookUrl}}", // dynamic — comes from payload
}),
],
})condition_false skip is intentional routing, not a failure. Fallbacks only fire on delivery failures (channel.failed) or missing destinations (missing_address). If you need a backup for a conditionally-skipped channel, define the backup as a separate channel with the inverse condition.Putting it together
Here's a single notification that uses three channels. Each renders the same payload differently based on the medium:
notification({
id: "task_assigned",
payload: {
assignerName: "string",
taskTitle: "string",
taskUrl: "string",
projectName: "string",
},
channels: [
inbox({
title: "{{assignerName}} assigned you a task",
body: "{{taskTitle}} in {{projectName}}",
actionUrl: "{{taskUrl}}",
}),
email({
subject: "New task: {{taskTitle}}",
body: "{{assignerName}} assigned you '{{taskTitle}}' in {{projectName}}.\n\nOpen it: {{taskUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
}),
webhook({
url: "https://hooks.slack.com/services/T.../B.../xxx",
}),
],
// Pipeline options (all optional):
required: false, // user can opt out per channel
defaultChannels: { inbox: true, email: true, webhook: true },
})| What happens at send time | Inbox | Webhook | |
|---|---|---|---|
| Preferences checked | User opted out? → skip | User opted out? → skip | User opted out? → skip |
| Destination resolved | Always available (DB write) | Needs email on recipient | URL hardcoded in config |
| Quiet hours | Delivers immediately | Deferred if in window | Deferred if in window |
| Delivery | Instant DB write | Queued → provider | Queued → POST |
Template syntax
Channel strings support {{variable}} interpolation. Variables come from the payload you pass to send(), plus a few built-in variables injected by the engine.
// Definition:
notification({
id: "comment_mentioned",
payload: { actorName: "string", postTitle: "string", postUrl: "string" },
channels: [
inbox({ title: "{{actorName}} mentioned you", body: "In {{postTitle}}", actionUrl: "{{postUrl}}" }),
email({ subject: "{{actorName}} mentioned you in {{postTitle}}", body: "Open {{postUrl}}" }),
],
})
// Send:
await notify.send({
recipientId: user.id,
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
})
// → inbox title resolves to "Rey mentioned you"
// → email subject resolves to "Rey mentioned you in Launch Plan"| Variable type | Source | Examples |
|---|---|---|
| Payload fields | Your payload object | {{actorName}}, {{orderNumber}}, {{postUrl}} |
| Built-in: unsubscribe | Engine (requires unsubscribe config) | {{_unsubscribeUrl}} — HMAC-signed one-click link |
| Built-in: recipient | Recipient record fields | {{_recipient.name}}, {{_recipient.email}} |
{{foo}} but don't pass foo in the payload, the template outputs "" — no error is thrown at runtime. Use payload validation (typed schemas) to catch missing fields at send time.Built-in variables
Variables prefixed with _ are injected by the engine and don't need to be in your payload schema:
| Variable | Available in | Value |
|---|---|---|
{{_unsubscribeUrl}} | Email only | Signed URL to unsubscribe from this notification. Requires unsubscribe config. |
{{_recipient.name}} | All channels | Recipient's name field from upsertRecipient() |
{{_recipient.email}} | All channels | Recipient's email address |
{{_notificationId}} | All channels | The notification's ID (useful in webhook payloads for routing) |
// Using built-in variables in an email:
email({
subject: "Hi {{_recipient.name}}, {{actorName}} mentioned you",
body: `Open {{postUrl}} to reply.
Unsubscribe: {{_unsubscribeUrl}}`,
})render() function on your channel config instead — it receives the full typed payload and returns the final string.Using render() for complex templates
When {{variable}} interpolation isn't enough — HTML emails, conditional sections, loops, or i18n — use a render() function instead. It receives the typed payload and returns the final string for each field.
| Approach | Supports | Best for |
|---|---|---|
Template strings ({{var}}) | Variable substitution only | Simple notifications — title, subject, short body |
| render() function | Conditionals, loops, HTML, i18n, any JS logic | HTML emails, dynamic content, multi-language, complex formatting |
Basic render()
Pass a render function that returns the channel fields. It receives the full typed payload:
notification({
id: "order_shipped",
payload: {
orderNumber: "string",
itemCount: "number",
trackingUrl: "string",
expedited: "boolean",
},
channels: [
inbox({
render: (payload) => ({
title: `Order ${payload.orderNumber} shipped`,
body: payload.expedited
? `${payload.itemCount} items — expedited shipping`
: `${payload.itemCount} items on the way`,
actionUrl: payload.trackingUrl,
}),
}),
email({
render: (payload) => ({
subject: `Your order ${payload.orderNumber} has shipped`,
body: [
`Hi! Your order with ${payload.itemCount} item${payload.itemCount > 1 ? "s" : ""} is on the way.`,
payload.expedited ? "Expedited delivery — arriving in 1-2 days." : "",
`Track your package: ${payload.trackingUrl}`,
"",
`Unsubscribe: {{_unsubscribeUrl}}`,
].filter(Boolean).join("\n"),
}),
}),
],
})render() return value, you can still use {{_unsubscribeUrl}} — built-in variables are interpolated after your function returns. Only payload variables need to be handled in the function.HTML emails
For rich HTML emails, use render() with any templating approach — template literals, React email, or a library like mjml:
import { renderEmailHtml } from "@/lib/email-templates"
notification({
id: "weekly_digest",
payload: {
recipientName: "string",
highlights: "string",
unreadCount: "number",
weekOf: "string",
},
channels: [
email({
render: (payload) => ({
subject: `Your weekly digest — ${payload.weekOf}`,
body: renderEmailHtml({
heading: `Hey ${payload.recipientName}, here's your week`,
sections: [
{ title: "Highlights", content: payload.highlights },
{ title: "Unread", content: `${payload.unreadCount} notifications waiting` },
],
footer: { unsubscribeUrl: "{{_unsubscribeUrl}}" },
}),
html: true,
}),
}),
],
})Internationalization (i18n)
Use render() with your i18n library to send notifications in the recipient's language:
import { t } from "@/lib/i18n"
notification({
id: "comment_mentioned",
payload: {
actorName: "string",
postTitle: "string",
postUrl: "string",
locale: "string",
},
channels: [
inbox({
render: (payload) => ({
title: t(payload.locale, "mention.title", { actor: payload.actorName }),
body: t(payload.locale, "mention.body", { post: payload.postTitle }),
actionUrl: payload.postUrl,
}),
}),
email({
render: (payload) => ({
subject: t(payload.locale, "mention.email_subject", {
actor: payload.actorName,
post: payload.postTitle,
}),
body: t(payload.locale, "mention.email_body", {
actor: payload.actorName,
url: payload.postUrl,
}),
}),
}),
],
})| Pattern | How to pass locale | Trade-off |
|---|---|---|
| Locale in payload | Include locale: user.locale at send time | Simple — works with any i18n library. Payload grows slightly. |
| Locale on recipient | Store on the recipient record, access via {{_recipient.locale}} | Cleaner payloads, but requires a custom recipient field. |
async — no database queries or API calls inside it. Load all the data you need into the payload at send time, then render from that. This keeps the delivery pipeline fast and predictable.Testing channels
Template strings and render() functions are the most common source of notification bugs — a typo in a variable name produces a blank field silently. Test at three levels:
| Level | What you verify | How |
|---|---|---|
| Unit | render() output for edge-case payloads | Call the function directly in a test file |
| Integration | Full template resolution including built-in variables | explain() with a real payload — no delivery |
| E2E | Actual delivery reaches the destination | Dev mode with test providers (console sink, Mailpit, etc.) |
Unit-testing render() functions
Extract render() functions so they're importable, then test edge cases directly:
// notifications/order-shipped.ts
export const orderShippedInbox = (payload) => ({
title: `Order ${payload.orderNumber} shipped`,
body: payload.itemCount === 1
? "1 item on the way"
: `${payload.itemCount} items on the way`,
actionUrl: payload.trackingUrl,
})
// notifications/order-shipped.test.ts
import { orderShippedInbox } from "./order-shipped"
test("singular item text", () => {
const result = orderShippedInbox({
orderNumber: "ABC-123",
itemCount: 1,
trackingUrl: "/track/abc",
expedited: false,
})
expect(result.body).toBe("1 item on the way")
})
test("plural item text", () => {
const result = orderShippedInbox({
orderNumber: "ABC-123",
itemCount: 5,
trackingUrl: "/track/abc",
expedited: false,
})
expect(result.body).toBe("5 items on the way")
})Integration testing with explain()
explain() dry-runs the full pipeline — template resolution, preference checks, channel evaluation — without writing any records or triggering providers. Use it to verify the final rendered output:
import { notify } from "./notifykit"
test("comment_mentioned resolves all template variables", async () => {
const result = await notify.explain({
recipientId: "user_test",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
})
// Verify no unresolved {{variables}} remain
const inboxChannel = result.channels.find(c => c.name === "inbox")
expect(inboxChannel.rendered.title).toBe("Rey mentioned you")
expect(inboxChannel.rendered.title).not.toMatch(/\{\{/)
// Verify channel was not skipped
expect(inboxChannel.status).toBe("would_deliver")
}){{ — this catches payload/template mismatches before they reach users. Pattern: expect(rendered.title).not.toMatch(/\{\{|^$/)Dev mode: inspect without real providers
In dev mode, channel deliveries are logged to the console instead of hitting real providers. This lets you iterate on templates visually:
// notifykit.config.ts
import { createNotifyKit } from "@notifykitjs/core"
import { memoryAdapter } from "@notifykitjs/core"
export const notify = createNotifyKit({
database: memoryAdapter(),
devMode: process.env.NODE_ENV !== "production",
// In dev mode:
// - Email renders to console (subject + body)
// - SMS renders to console (body)
// - Webhook logs the full payload without POSTing
// - Inbox writes to the in-memory DB as normal
})Sample dev mode console output:
// Console when devMode is enabled:
// ┌─────────────────────────────────────────────────
// │ 📧 EMAIL → user_123
// │ Subject: Rey mentioned you in Launch Plan
// │ Body: Open /posts/42 to reply.
// │
// │ Unsubscribe: http://localhost:3000/unsubscribe?token=dev_xxx
// └─────────────────────────────────────────────────
// ┌─────────────────────────────────────────────────
// │ 📥 INBOX → user_123
// │ Title: Rey mentioned you
// │ Body: In Launch Plan
// │ Action: /posts/42
// └─────────────────────────────────────────────────Debugging channel delivery
When a notification doesn't arrive, work through these checks in order. Most issues resolve at step 1 or 2:
result.skippedWas the channel skipped entirely? reason tells you why: preferences, missing address, rate limit, or dedup match.
result.deferredChannelsWas it deferred by quiet hours? The delivery will fire when the window ends — call flushScheduledSends() to release manually.
result.deliveriesWas it attempted but failed? Look at status and error. Provider errors (5xx, timeout) are retried automatically.
result.digested / result.rateLimitedWas the send absorbed? Digested sends deliver later; rate-limited sends are silently dropped.
explain()Dry-run the same send. Shows the full resolution trail — preferences, quiet hours, channel evaluation — without writing any records.
The table below maps specific symptoms to their root cause:
| Symptom | Channel | Likely cause | Fix |
|---|---|---|---|
| Item not in inbox | Inbox | Recipient opted out via preferences | Check result.skipped — look for reason: "preference_opt_out" |
| Email never arrives | No email on recipient record | Check result.skipped for reason: "missing_destination" | |
| Email delayed by hours | Recipient is in quiet hours window | Check result.deferredChannels — delivery will happen when window ends | |
| SMS not delivered | SMS | Provider returned error (invalid number, region blocked) | Check result.deliveries for the SMS entry — error field has provider message |
| Webhook returns 4xx/5xx | Webhook | Endpoint down, auth expired, or payload rejected | Check result.deliveries — retries happen automatically up to 5 times |
| Nothing sent at all | All | Deduplication key matched a recent send | Check result.idempotent — the original send's result is returned |
| Send returns but no delivery records | All | Notification was digested | Check result.digested === true — it will deliver when the digest window fires |
Inspecting the result programmatically
const result = await notify.send({
recipientId: user.id,
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/posts/1" },
})
// Was anything skipped?
for (const skip of result.skipped) {
console.log(`Channel ${skip.channel} skipped: ${skip.reason}`)
// → "Channel email skipped: missing_destination"
// → "Channel sms skipped: preference_opt_out"
}
// Did any delivery fail after retries?
const failures = result.deliveries.filter(d => d.status === "failed")
for (const fail of failures) {
console.log(`${fail.channel} failed: ${fail.error}`)
// → "email failed: 550 mailbox not found"
}
// Was it deferred by quiet hours?
if (result.deferredChannels.length > 0) {
console.log(`Deferred: ${result.deferredChannels.join(", ")}`)
// → "Deferred: email, sms"
}delivery.failed hook for monitoring. Instead of checking every send result manually, set up a hook that fires on failures and routes them to your error tracker (Sentry, Datadog, etc.).{{variable}} is missing from the payload, the template renders an empty string — no error is thrown. If your messages look blank or incomplete, verify the payload fields match your template variables exactly.