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 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:
| 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 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.
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.
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.
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.
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:
| 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 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.
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 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." }
}
]
}'
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.
| 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. 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.