Node.js transactional email templates: create, preview, then send
The three-call template loop on Infrai's email API: store HTML with named variables, render it with a missing_vars check, then send by template_id from Node 22.
A welcome email in Node has three moving parts on Infrai. POST /v1/email/template/create stores the HTML with {{named}} placeholders and a declared variable list; POST /v1/email/template/preview/{id} renders it against a sample payload and reports what you forgot; POST /v1/email/send delivers it with template_id and template_vars. Only the last of the three costs anything.
Most Node tutorials wire Handlebars into Nodemailer and compile the HTML inside the process that sends it. That works, and for a single template it’s less machinery than any API. It also means the copy lives in your bundle, so changing one sentence in a welcome email is a deploy — and nobody can render the thing without running your app.
Where the render happens, and what that costs you
| Axis | Handlebars compiled in your app | Template stored behind the API |
|---|---|---|
| Changing a paragraph | rebuild and deploy | PATCH /v1/email/template/update/{id} |
| Loops, partials, custom helpers | full Handlebars language | named variable substitution only |
| Preview | render locally, open the file | POST /v1/email/template/preview/{id} returns HTML, text and missing_vars |
| A variable you forgot | usually renders as empty string | comes back in missing_vars before send |
| Non-engineers editing copy | pull request | an API call, no redeploy |
| Cost of the render step | your CPU | free, rate-limited |
Neither column is the right answer for everyone. If your emails need {{#each line_items}} over an order, table partials, and a currency helper, the declared-variable model won’t stretch that far and you should render locally with Handlebars, then post the finished markup as html. The API accepts that shape too, so the two approaches aren’t exclusive.
Store the template once
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/template/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"signup-welcome-v3","subject":"{{app_name}}: your account is live","html":"<h1>Hi {{given_name}}</h1><p>Your {{app_name}} account is ready. <a href=\"{{dashboard_url}}\">Open the dashboard</a>.</p>","variables":["given_name","app_name","dashboard_url"]}'
The response echoes the stored record and hands back the identifier every later call needs:
{
"ok": true,
"data": {
"template_id": "tmpl_5nQ2rKpx8VaLdEfHm3Jy61Tw",
"name": "signup-welcome-v3",
"subject": "{{app_name}}: your account is live",
"variables": ["given_name", "app_name", "dashboard_url"],
"default_vars": {},
"created_at": "2026-07-25T09:12:44.118203Z"
}
}
Keep template_id in configuration, not in code. Rotating a template is then a config change and a PATCH, and the send path never moves.
Preview is an assertion, not a screenshot
The interesting field in the preview response isn’t the HTML. It’s missing_vars, which turns “someone renamed a variable” from a support ticket into a failing test.
curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_5nQ2rKpx8VaLdEfHm3Jy61Tw" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"vars":{"given_name":"Mira","app_name":"Kettle"}}'
{
"ok": true,
"data": {
"rendered_subject": "Kettle: your account is live",
"rendered_html": "<h1>Hi Mira</h1><p>Your Kettle account is ready. <a href=\"{{dashboard_url}}\">Open the dashboard</a>.</p>",
"rendered_text": "Hi Mira\n\nYour Kettle account is ready. Open the dashboard.",
"missing_vars": ["dashboard_url"]
}
}
Look at what happened to the link.
An unsupplied variable is left as the literal token — so a missed dashboard_url ships a broken href to a real person rather than an empty one, which is arguably better because it’s visible, and definitely worse if nobody looks. Preview before every send and that class of bug can’t reach an inbox.
The Node 22 module
This is the whole loop in one file: preview, refuse to send if anything is missing, send, then read the message state back. No dependencies — Node 22 has fetch built in.
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const TEMPLATE_ID = process.env.WELCOME_TEMPLATE_ID ?? "tmpl_5nQ2rKpx8VaLdEfHm3Jy61Tw";
const SENDER = process.env.MAIL_FROM ?? "hello@mail.kettle.example";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function call(path, { method = "GET", body } = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json().catch(() => ({}));
if (!res.ok || payload.ok === false) {
const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`${method} ${path} -> ${err.code}: ${err.message}`);
}
return payload.data;
}
export async function sendWelcome({ email, givenName, dashboardUrl }) {
const vars = { given_name: givenName, app_name: "Kettle", dashboard_url: dashboardUrl };
const rendered = await call(`/v1/email/template/preview/${TEMPLATE_ID}`, {
method: "POST",
body: { vars },
});
if (rendered.missing_vars?.length) {
throw new Error(`template ${TEMPLATE_ID} missing: ${rendered.missing_vars.join(", ")}`);
}
const sent = await call("/v1/email/send", {
method: "POST",
body: { to: email, from: SENDER, template_id: TEMPLATE_ID, template_vars: vars },
});
if (sent.suppressed_recipients?.length) {
console.warn(`not delivered, address is suppressed: ${sent.suppressed_recipients.join(", ")}`);
return null;
}
return sent.message_id;
}
const messageId = await sendWelcome({
email: "mira@example.com",
givenName: "Mira",
dashboardUrl: "https://kettle.example/app",
});
console.log("accepted as", messageId);
if (messageId) {
const state = await call(`/v1/email/get/${messageId}`);
console.log(state.state, state.counts);
}
Two habits in there are worth keeping whichever provider you end up on. Treat suppression as an ordinary outcome rather than an error — an address that hard-bounced in March coming back through your signup form is routine, and it returns in suppressed_recipients instead of throwing, which means your signup handler shouldn’t 500 because of it. And keep the preview call on the send path even though it doubles the round trips, because it’s free, it usually adds well under 100ms, and it’s the only thing standing between a renamed variable and 4,000 welcome emails with a broken dashboard link in them.
Confirm it actually left
GET /v1/email/get/{id} gives one state; GET /v1/email/event/list gives the timeline, which is what you want when someone asks whether a specific person received their welcome mail.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"records": [
{ "type": "delivered", "recipient": "mira@example.com", "occurred_at": "2026-07-25T09:13:02.441Z" },
{ "type": "sent", "recipient": "mira@example.com", "occurred_at": "2026-07-25T09:12:58.907Z" }
],
"total_count": 2,
"next_cursor": null
}
}
A message id that never existed answers EMAIL_NOT_FOUND rather than an empty list, which is the distinction you want in a retry loop: no events yet means keep polling, not found means stop.
What the loop costs
Create, update, preview, delete and every read above are free and rate-limited. The send is the only metered call: $0.000115 per recipient, verified 2026-07-26, and a new account starts with $2 of free credit — roughly 17,391 emails before you pay. Rates drift downward as vendor discounts land, so read today’s figure instead of trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"
Limits, and when to use something else
The template engine is variable substitution against a declared list. There’s no support for conditionals, iteration or partials in the stored HTML, so a receipt with a line-item table belongs in local Handlebars — or in email-templates, which has been doing exactly this in Node for years and still bundles a browser preview. Sending from your own domain also needs POST /v1/email/domain/verify to pass first; until it does, from falls back to the shared sending domain.
If email is the only external service your app will ever call, take a specialist. Postmark’s message history and support engineers are a real product, and Resend’s React Email components are nicer to build in than any string template.
The reason to run this loop on Infrai is what comes next. The day-3 nudge needs a scheduled job, the rendered HTML needs somewhere to live, the DNS failure needs to land in error tracking, and the per-tenant email cost needs to be a query rather than four invoices. Same key, same bill — the second question doesn’t start a new vendor onboarding.