← website
Build

Templates & AI

A template is an id, a channel, and a free-form content object. NotifKit walks that object, substitutes your data into every string it finds, and escapes each value according to the field it lands in.

Shape

await notifkit.syncTemplates({
  templates: [
    {
      id: "order-shipped",          // referenced by notify({ template })
      channel: "email",             // email | sms | push | webhook
      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 is an upsert keyed on (project, id), so the natural pattern is to 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 is now the single field that separates bulk mail from transactional mail. A template with a topic can be opted out of, and its email goes out carrying one-click unsubscribe headers. A template without one cannot be switched off and carries no unsubscribe button.

So the rule from Preferences holds with more force than before: give every template a topic except the ones a user must always receive — password resets, security alerts, receipts, legal notices. Omitting the topic on a marketing template means it ships with no way to unsubscribe, which mailbox providers penalise.

Which keys matter

NotifKit itself is indifferent to your key names — it renders whatever is there. The transport is what reads specific keys. All three first-party transports look for:

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

Anything else you add is passed through untouched and is available to a transport you write yourself — a Slack transport reading blocks, a webhook transport reading a whole nested payload.

Sending from more than one address

Password resets should come from no-reply@ and a campaign from marketing@. That choice belongs to the template rather than to the call: notify() has no from field, deliberately, because the sending identity is a property of what kind of mail this is — which is the thing a template already names. Were it a per-call argument, the same welcome email would go out from different addresses depending on which service sent it.

So put it in content, alongside the subject. The renderer already treats from, replyTo, cc and bcc as header fields, so an interpolated value in one of them has CR/LF stripped and cannot inject a second header:

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

ResendTransport reads both. Its constructor from becomes the default for templates that name none, so nothing you already wrote changes behaviour:

registerTransport(
  new ResendTransport({
    apiKey: process.env.RESEND_KEY!,
    from: "no-reply@corp.com",   // used unless a template overrides it
  }),
);

Both accept the display-name form — "Acme Offers <marketing@corp.com>" — and a value that is not a non-empty string is ignored rather than coerced, falling back to the default. A from: 123 reaching the provider as "123" would fail the send with an error naming neither the template nor the field.

a transport you write yourself

The convention is worth copying if you write your own email transport: read content.from, fall back to a constructor default, and keep passing task.deliveryOptions.headers through — that is the List-Unsubscribe pair, and dropping it costs you the one-click unsubscribe button. Treat a missing task.destination as a failure to report rather than a value to substitute.

two transports will not do this

Registering two ResendTransports with different from addresses is the obvious first idea and it does not 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 is provider failover, not sender selection — you would get your marketing address only when Resend was already erroring.

two addresses, one reputation

no-reply@corp.com and marketing@corp.com are different senders to a reader and the same sender to a mailbox provider: reputation attaches to the domain, not the local part. Splitting the address makes your mail clearer in the inbox, and does nothing to stop complaints about the sale email from depressing delivery of your password resets.

If that isolation is the goal, the split has to be at the domain: send bulk mail from a subdomain such as news.corp.com, with its own DKIM record, and keep transactional mail on the root domain. Then a bad campaign damages only the subdomain's reputation.

verify every address you send from

Providers reject a from they have no authorisation for. Each address or domain here has to be verified with the provider first, or the send fails at the API call with a provider error — visible as a failed row in the delivery log, not at template sync, since nothing validates the address when the template is stored.

Interpolation

The syntax is {{name}} and that is the entire feature set. Placeholders match word characters only, which has three consequences worth knowing up front:

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 rather than leaving the braces in the output. Non-string values are JSON-encoded, so an accidental 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 when they are substituted, never the template around them — so your own markup survives while caller data cannot break out of it. Which escaping applies 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 rather than the rendered string is what makes user-supplied data safe in an HTML email without mangling the template's own tags. It also means values are inserted into the parsed structure, so a value containing a quote cannot forge a sibling field like htmlBody.

Templates per channel

A template belongs to exactly one channel. Sending the same event over email and SMS means two templates — which is usually what you want, since a 160-character SMS and an HTML email are not 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 can take several channels. If you multicast to a channel the template was not declared for, the same content goes out on both. For meaningfully different copy per channel, send twice — or use a workflow with one 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, available to {{placeholders}} exactly like data you passed yourself.

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 prompts and request prompts are merged, with the request winning on conflicting keys. If any prompt is present the message detours through the AI worker, then rejoins the pipeline at the outbound stream — so preferences and throttling have already been applied 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. An AI prompt on a template used for a 10,000-user segment is 10,000 model calls. Generate once and pass the result as ordinary data when the copy does not need to be personalised.

Retries are classified: timeouts, 429s, and 5xx are retried; a 4xx or an unsupported model raises 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"

Templates are cached in-process by the workers for speed. Both syncing and deleting publish an invalidation over Redis pub/sub, so every worker drops its cached copy immediately — a template change is live on the next message, with no restart.

Sending without a template

template is required by the schema, but if the id does not resolve at render time NotifKit falls back to a diagnostic payload — subject "Notification" and a body containing your data as formatted JSON. Seeing that in a real inbox means the template id is wrong or was synced under a different project.