One stored template per email: create, preview, patch, send in Node 22

Store subject, HTML and a text part together so every transactional send renders alike: a Node 22 template registry, previews asserted in CI, copy fixes with no deploy.

Transactional copy drifts when it lives in three places: an HTML string in the mailer module, a plain-text fallback someone wrote once and never updated, and a subject line built by concatenation at the call site. Infrai’s template routes collapse that into a single stored record — subject, HTML, optional text part, declared variables and brand defaults — that every send renders from. The consistency you’re after is mostly a storage decision, not a rendering one.

Three calls carry the whole lifecycle. POST /v1/email/template/create stores the record and returns a template_id, POST /v1/email/template/preview/{id} renders it against sample values, and PATCH /v1/email/template/update/{id} changes the copy without touching your deploy pipeline. Only the send that follows costs anything.

What belongs in the stored record

Piece of the emailStored templateYour applicationWhy
Subject line, with placeholdersyesSubject and body change together or not at all
HTML bodyyesCopy edits shouldn’t need a release
Plain-text alternative (body_text)yesA missing text part is a spam-filter signal
Brand constants (product name, support address)default_varsRenaming the product is one PATCH
Per-recipient values (name, links, amounts)template_vars at send timeThey’re request data, not copy
Conditional or repeated sectionsrender locally, send as htmlThe stored renderer substitutes; it doesn’t branch

That last row is the real boundary, and it’s worth testing before you design around it.

Create it once, with the text part

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": "welcome-v4",
    "subject": "Welcome to {{app_name}}, {{first_name}}",
    "html": "<p>Hi {{first_name}},</p><p><a href=\"{{dashboard_url}}\">Open the dashboard</a></p>",
    "body_text": "Hi {{first_name}}, welcome to {{app_name}}. Open {{dashboard_url}}",
    "variables": {"first_name": "string", "app_name": "string", "dashboard_url": "string"},
    "default_vars": {"app_name": "Kettle"}
  }'

The reply echoes the stored record, including the identifier you’ll pin in configuration:

{
  "ok": true,
  "data": {
    "template_id": "tmpl_QxMgF5zmgsJcslhG4RmKV19m",
    "name": "welcome-v4",
    "subject": "Welcome to {{app_name}}, {{first_name}}",
    "body_text": "Hi {{first_name}}, welcome to {{app_name}}. Open {{dashboard_url}}",
    "variables": { "first_name": "string", "app_name": "string", "dashboard_url": "string" },
    "default_vars": { "app_name": "Kettle" },
    "created_at": "2026-07-26T05:06:31.197165Z"
  }
}

variables is a typed declaration — string, int, bool, array or object — and it’s documentation for the next person rather than a schema the renderer enforces. default_vars is different: values there are filled in when a send omits them, which is how a product rename becomes one API call instead of a search across every send site.

Preview is where the consistency check happens

Preview renders subject, HTML and (if you stored body_text) the text alternative, and reports anything the copy still wants:

curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_QxMgF5zmgsJcslhG4RmKV19m" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"vars": {"first_name": "Dana", "dashboard_url": "https://kettle.example/app"}}'
{
  "ok": true,
  "data": {
    "rendered_subject": "Welcome to Kettle, Dana",
    "rendered_html": "<p>Hi Dana,</p><p><a href=\"https://kettle.example/app\">Open the dashboard</a></p>",
    "rendered_text": "Hi Dana, welcome to Kettle. Open https://kettle.example/app",
    "missing_vars": []
  }
}

Note what didn’t need supplying. app_name came from default_vars, so it isn’t reported missing and both the subject and the text part picked it up — one edit, three surfaces consistent.

rendered_text only appears when the stored record has a body_text. Skip that field at create time and every send goes out HTML-only, which is a deliverability handicap you won’t notice until a filter does. In our testing the preview round trip ran in roughly 70 ms, so putting it on the send path costs you very little.

Now the limitation. The renderer handles {{variable}} substitution and nothing else: section tags like {{#team_name}}…{{/team_name}} and inverted sections come back verbatim in the rendered HTML rather than being evaluated, so a template that leans on them will mail literal braces to a customer. If your receipt needs a line-item loop or an if/else, render it with Handlebars in your own process and post the finished markup as html on the send instead. That path is fully supported; it just isn’t the stored-template path.

A template registry your repo owns

Storing copy behind an API doesn’t mean losing it from version control. Keep the source of truth as a manifest in the repo and make a script reconcile it — create what’s missing, patch what changed, and write the ids back for the app to import.

// sync-templates.mjs — Node 22 ESM, no dependencies.
import { readFile, writeFile } from "node:fs/promises";
import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function call(path, method, body) {
  const res = await fetch(API + path, {
    method,
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(10_000),
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? {};
    throw new Error(`${method} ${path} -> ${e.code ?? res.status}: ${e.message ?? "request failed"}`);
  }
  return payload.data;
}

/** manifest: { "welcome-v4": {subject, html, body_text, variables, default_vars}, ... } */
async function sync(manifestFile, lockFile) {
  const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
  let lock = {};
  try { lock = JSON.parse(await readFile(lockFile, "utf8")); } catch { lock = {}; }

  for (const [name, spec] of Object.entries(manifest)) {
    const known = lock[name];
    if (!known) {
      const created = await call("/v1/email/template/create", "POST", { name, ...spec });
      lock[name] = created.template_id;
      console.log(`created ${name} -> ${created.template_id}`);
      continue;
    }
    await call(`/v1/email/template/update/${known}`, "PATCH", {
      subject: spec.subject,
      html: spec.html,
      body_text: spec.body_text,
      default_vars: spec.default_vars,
    });
    console.log(`patched ${name} (${known})`);

    const rendered = await call(`/v1/email/template/preview/${known}`, "POST", { vars: spec.sample ?? {} });
    if (rendered.missing_vars.length) {
      throw new Error(`${name} still wants ${rendered.missing_vars.join(", ")} after the patch`);
    }
  }
  await writeFile(lockFile, JSON.stringify(lock, null, 2) + "\n");
  return lock;
}

const ids = await sync("templates.json", "templates.lock.json");
console.log(Object.keys(ids).length, "templates in sync");

Run it in CI on merge to main. The preview assertion at the end is the part that earns its keep: a translator who deletes a sentence containing a link, or a designer who renames a placeholder, fails the build rather than the inbox. (For the subtler version of that failure — where the preview looks clean and the email is still broken — see the companion piece at https://docs.infrai.cc/en/guides/email/answers/password-reset-email-malformed-template-variables-missi/.)

Sending by id

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.com",
    "template_id": "tmpl_QxMgF5zmgsJcslhG4RmKV19m",
    "template_vars": {"first_name": "Dana", "dashboard_url": "https://kettle.example/app"}
  }'

Leave from out and the message goes from the platform’s own authenticated sender — noreply+<tag>@send.infrai.cc — with no DNS work at all; a custom sender domain is a paid-tier feature and answers HTTP 402 PRO_REQUIRED on a standard account. Sends default to message_class: "transactional", which is what keeps an unsubscribe footer off a receipt. Passing "marketing" currently answers HTTP 501, so drip campaigns aren’t what this surface is for.

Then confirm with the free reads: GET /v1/email/get/{id} for state, GET /v1/email/event/list?message_id=… for the timeline.

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_FjDRVM4y1dx7xcElLubSlJMF" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

An id the account never accepted answers HTTP 404 EMAIL_NOT_FOUND instead of an empty list, which is the difference between “no events yet” and “you’re polling a typo”.

What the loop costs

Create, preview, patch and every read above are free and rate-limited. Only the send is metered: $0.000115 per recipient, verified 2026-07-26, with $2 of free credit on a new account. Read today’s number instead of trusting the sentence:

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([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('email.template') or c['id'] == 'email.send'])"

Rates trend down as upstream discounts land, so the live figure may well be lower.

Where a specialist template system wins

SendGrid’s dynamic templates give you Handlebars proper — conditionals, loops, a versioned editor and per-version test data — and if your emails are genuinely programmatic documents, that’s the better tool. Postmark’s layouts plus its spam-score endpoint are hard to beat when deliverability review is a formal step in your process, and Resend’s React Email components suit teams who’d rather write JSX than store HTML.

Infrai’s version is deliberately smaller: substitution, defaults, a text part and a preview you can assert on. It pays off when the welcome email isn’t the only thing you’re building — the day-3 nudge is a cron job, the render fan-out is a queue, the bounce is an event read, and it’s all the same key and the same bill instead of a fourth vendor to onboard.

References

Browse more email developer guides