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 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:
| Key | Also accepted as | Used for |
|---|---|---|
subject | — | Email subject line; push notification title. |
text | body | Plain-text body; push notification body; SMS content. |
html | htmlBody | HTML 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.
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 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.
[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.
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:
| Works | Does not work | Why |
|---|---|---|
{{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.
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.
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." }
}
]
}'
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.
| Guard | Default | Behaviour |
|---|---|---|
maxPromptsPerNotification | 5 | Extra prompts beyond the cap are ignored with a warning. |
maxOutputTokens | 1000 | Hard ceiling per generation. |
timeoutMs | 30000 | Wall-clock budget per prompt before it is aborted. |
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.