Star
Build

Templates & AI

A template is an id, a channel and a free-form content object. notifkit fills your data into every string in it, escaping each value for the field it lands in.

Shape

await notifkit.syncTemplates({
  templates: [
    {
      id: "order-shipped",          // referenced by notify({ template })
      channel: "email",             // email | sms | push | webhook | slack | whatsapp | telegram | discord
      topic: ["transactional"],     // optional; drives topic opt-outs
      content: {                    // free-form — the transport decides what it reads
        subject: "Order {{orderId}} is on its way",
        text: "Hi {{name}}, your order ships today. Track it: {{trackingUrl}}",
        html: "<h1>On its way</h1><p>Hi {{name}}, track it <a href='{{trackingUrl}}'>here</a>.</p>",
      },
      aiPrompts: {                  // optional; see below
        tip: "One friendly sentence about caring for a {{productType}}.",
      },
    },
  ],
});
curl -X PUT http://localhost:3000/v1/templates \
  -H "Authorization: Bearer $NK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templates": [
      {
        "id": "order-shipped",
        "channel": "email",
        "topic": ["transactional"],
        "content": {
          "subject": "Order {{orderId}} is on its way",
          "text": "Hi {{name}}, your order ships today. Track it: {{trackingUrl}}",
          "html": "<h1>On its way</h1><p>Hi {{name}}, track it here.</p>"
        },
        "aiPrompts": {
          "tip": "One friendly sentence about caring for a {{productType}}."
        }
      }
    ]
  }'

syncTemplates upserts on (project, id), so keep templates in your repository and push the whole set on every deploy. The REST equivalent is PUT /v1/templates.

Topic decides more than opt-outs

topic is optional, but it's the one field separating bulk from transactional mail. A template with a topic can be opted out of, and its email carries one-click unsubscribe headers. One without can't be switched off and has no unsubscribe button.

So the rule from Preferences matters even more: give every template a topic except ones a user must always receive (password resets, security alerts, receipts, legal notices). A marketing template without a topic ships with no way to unsubscribe, which mailbox providers penalise.

Which keys matter

notifkit renders whatever keys are there; the transport reads specific ones. All three first-party transports look for:

KeyAlso accepted asUsed for
subject—Email subject line; push notification title.
textbodyPlain-text body; push notification body; SMS content.
htmlhtmlBodyHTML email body. Falls back to <p>{text}</p> if absent.
from—Email only. Overrides the transport's own sender for this template; see below. Optional.
replyTo—Email only. Sent only when present. Optional.

Anything else passes through untouched for a transport you write: a Slack transport reading blocks, a webhook transport reading a nested payload.

Sending from more than one address

Password resets should come from no-reply@, a campaign from marketing@. That's the template's choice, not the call's: notify() deliberately has no from field, because the sender depends on what kind of mail this is, which the template names. As a per-call argument, the same welcome email could go out from different addresses depending on which service sent it.

Put it in content beside the subject. The renderer treats from, replyTo, cc and bcc as header fields, so interpolated values have CR/LF stripped and can't inject a second header:

await notifkit.syncTemplates({
  templates: [
    {
      id: "password-reset",
      channel: "email",
      // No topic: transactional, cannot be unsubscribed from.
      content: {
        from: "[email protected]",
        subject: "Reset your password",
        text: "Use this link within 15 minutes: {{resetUrl}}",
      },
    },
    {
      id: "spring-sale",
      channel: "email",
      topic: "promotions",
      content: {
        from: "Acme Offers <[email protected]>",
        replyTo: "[email protected]",
        subject: "{{headline}}",
        html: "<h1>{{headline}}</h1><p>{{body}}</p>",
      },
    },
  ],
});

ResendTransport reads both. Its constructor from is the default for templates that name none, so existing templates behave as before:

registerTransport(
  new ResendTransport({
    apiKey: process.env.RESEND_KEY!,
    from: "[email protected]",   // used unless a template overrides it
  }),
);

Both accept the display-name form ("Acme Offers <[email protected]>"). A value that isn't a non-empty string is ignored, not coerced, and the default is used; a from: 123 reaching the provider as "123" would fail with an error naming neither the template nor the field.

A transport you write yourself

Copy the convention in your own email transport: read content.from, fall back to a constructor default, and pass task.deliveryOptions.headers through. That's the List-Unsubscribe pair; dropping it loses the one-click unsubscribe button. Treat a missing task.destination as a failure to report, not a value to substitute.

Two transports will not do this

Two ResendTransports with different from addresses is the obvious idea, and it doesn't work. The registry keys on channel alone, so the higher-priority one wins every send and the other is reached only when the first fails. That's provider failover, not sender selection: your marketing address would appear only when Resend was already erroring.

Two addresses, one reputation

[email protected] and [email protected] are different senders to a reader but the same sender to a mailbox provider: reputation attaches to the domain, not the local part. Splitting addresses clarifies the inbox but won't stop complaints about the sale email hurting delivery of password resets.

For that isolation, split at the domain: send bulk mail from a subdomain like news.corp.com with its own DKIM record, and keep transactional mail on the root domain. A bad campaign then damages only the subdomain.

Verify every address you send from

Providers reject a from they haven't authorised. Verify each address or domain with the provider first, or the send fails at the API call (a failed row in the delivery log). Template sync won't catch it, since nothing validates the address when it's stored.

Interpolation

The syntax is {{name}}, and that's all. Placeholders match word characters only, with three consequences:

WorksDoes not workWhy
{{name}}{{user.name}}No dotted paths. Flatten your data before sending.
{{orderId}}{{#if premium}}No conditionals or loops. Pick a different template instead.
{{total}}{{formatCurrency total}}No helpers. Format values in your application.

A placeholder with no matching key renders as an empty string, so braces never reach the output. Non-string values are JSON-encoded, so a stray object shows up as {"a":1}: pass primitives.

// Flatten and format before sending.
await notifkit.notify({
  user: "usr_123",
  template: "order-shipped",
  channels: ["email"],
  data: {
    name: user.firstName,
    orderId: order.id,
    trackingUrl: `https://acme.com/t/${order.trackingCode}`,
    total: formatCurrency(order.totalCents),   // "$42.00", not 4200
  },
});
# Flatten and format before sending.
curl -X POST http://localhost:3000/v1/notify \
  -H "Authorization: Bearer $NK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_123",
    "template": "order-shipped",
    "channels": ["email"],
    "data": {
      "name": "Alice",
      "orderId": "ord_8814",
      "trackingUrl": "https://acme.com/t/1Z999",
      "total": "$42.00"
    }
  }'

Escaping is decided by the destination field

Values are escaped as they're substituted, never the template around them, so your markup survives and caller data can't break out of it. The escaping depends on the key the string sits under.

one value name = <b>Al</b> supplied in data escape mode from the key name "subject": "Welcome {{name}}" → header mode · line breaks stripped, markup left alone Welcome <b>Al</b> "html": "<p>Hi {{name}}</p>" → html mode · the value is entity-escaped <p>Hi &lt;b&gt;Al&lt;/b&gt;</p> "text": "Hi {{name}}" → text mode · inserted verbatim Hi <b>Al</b>
HTML fields: html, htmlBody, bodyHtml, htmlContent: matched case- and separator-insensitively. Header fields: subject, title, from, replyTo, cc, bcc, preheader, preview. Nested objects inherit the mode of their parent key, so a value under html.blocks[0] is still HTML-escaped.
Why

Escaping the substituted value, not the rendered string, makes user data safe in HTML email without mangling the template's tags. Values go into the parsed structure, so one containing a quote can't forge a sibling field like htmlBody.

Templates per channel

A template belongs to one channel. The same event over email and SMS means two templates, which is usually right: a 160-character SMS and an HTML email aren't the same copy.

await notifkit.syncTemplates({
  templates: [
    {
      id: "otp-email",
      channel: "email",
      content: { subject: "Your code is {{code}}", text: "Code: {{code}}. Expires in 10 minutes." },
    },
    {
      id: "otp-sms",
      channel: "sms",
      content: { text: "{{code}} is your Acme code. Expires in 10 min." },
    },
  ],
});
curl -X PUT http://localhost:3000/v1/templates \
  -H "Authorization: Bearer $NK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templates": [
      {
        "id": "otp-email",
        "channel": "email",
        "content": {
          "subject": "Your code is {{code}}",
          "text": "Code: {{code}}. Expires in 10 minutes."
        }
      },
      {
        "id": "otp-sms",
        "channel": "sms",
        "content": { "text": "{{code}} is your Acme code. Expires in 10 min." }
      }
    ]
  }'
Gotcha

notify() takes one template id but several channels. Multicast to a channel the template wasn't declared for and the same content goes out on both. For different copy per channel, send twice or use a workflow with a step per channel.

Letting a model write part of it

aiPrompts turns a prompt into a template variable. Each key becomes a variable of that name for {{placeholders}}, just like data you pass.

Configure a model

import { NotifkitServer } from "notifkit";
import { anthropic } from "@ai-sdk/anthropic";

const server = new NotifkitServer({
  services: ["all"],
  aiModel: anthropic("claude-sonnet-5"),   // any Vercel AI SDK LanguageModel
  providers: [/* … */],
});

Prompt from the template

{
  id: "plant-care",
  channel: "email",
  content: {
    subject: "Your {{productType}} has shipped",
    text: "Hi {{name}}, it ships today.\n\n{{tip}}",
  },
  aiPrompts: {
    // Prompts are interpolated with the same data before running.
    tip: "Write one friendly sentence of care advice for a {{productType}}. No greeting.",
  },
}
{
  "id": "plant-care",
  "channel": "email",
  "content": {
    "subject": "Your {{productType}} has shipped",
    "text": "Hi {{name}}, it ships today.\n\n{{tip}}"
  },
  "aiPrompts": {
    "tip": "Write one friendly sentence of care advice for a {{productType}}. No greeting."
  }
}

Or per send

await notifkit.notify({
  user: "usr_123",
  template: "plant-care",
  channels: ["email"],
  data: { name: "Alice", productType: "fiddle-leaf fig" },
  aiPrompts: {
    tip: "Write one sentence of winter care advice for a {{productType}}.",  // overrides the template
  },
});
curl -X POST http://localhost:3000/v1/notify \
  -H "Authorization: Bearer $NK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_123",
    "template": "plant-care",
    "channels": ["email"],
    "data": { "name": "Alice", "productType": "fiddle-leaf fig" },
    "aiPrompts": {
      "tip": "Write one sentence of winter care advice for a {{productType}}."
    }
  }'

Template and request prompts merge, with the request winning on conflicts. Any prompt routes the message through the AI worker before it rejoins at the outbound stream, so preferences and throttling apply before a single token is billed.

GuardDefaultBehaviour
maxPromptsPerNotification5Extra prompts beyond the cap are ignored with a warning.
maxOutputTokens1000Hard ceiling per generation.
timeoutMs30000Wall-clock budget per prompt before it is aborted.
Cost

Prompts run per recipient, not per send: a prompt on a template sent to a 10,000-user segment is 10,000 model calls. If the copy needn't be personal, generate once and pass it as ordinary data.

Timeouts, 429s and 5xx are retried; a 4xx or unsupported model is a permanent error, so the notification fails once instead of being re-billed on every attempt.

Managing the set

# List everything in the project
curl -s http://localhost:3000/v1/templates -H "Authorization: Bearer $NK_KEY"

# Fetch one
curl -s http://localhost:3000/v1/templates/order-shipped -H "Authorization: Bearer $NK_KEY"

# Delete one
curl -X DELETE http://localhost:3000/v1/templates/order-shipped -H "Authorization: Bearer $NK_KEY"

Workers cache templates in-process. Syncing and deleting publish an invalidation over Redis pub/sub, so every worker drops its copy at once: changes are live on the next message, no restart.

Sending without a template

The schema requires template, but if the id doesn't resolve at render time, notifkit sends a diagnostic payload: subject "Notification" and your data as formatted JSON. Seeing that in an inbox means the template id is wrong or was synced under another project.