Notification emails in English, Spanish and German without template chaos
One stored template per locale, a naming scheme your code can compute, and a preview pass that fails CI when a translation drops a variable.
Keep one stored template per locale, name them so a human can read the language off the name, and render every locale in CI before release. On Infrai that is three routes — POST /v1/email/template/create stores each translation, POST /v1/email/template/preview/{id} renders it and reports what’s missing, POST /v1/email/send delivers it by template_id. The chaos never comes from three languages. It comes from the parts nobody automated.
Three languages isn’t hard on its own. Three languages times nine notification types, each edited by a different person in a different week, with no check that the German copy still contains the same placeholders as the English, is where it goes wrong.
Per-locale templates beat one template with a language switch
The two designs people reach for are a single template that branches on a locale variable, and one template per locale. Only one of them survives a translator who isn’t an engineer.
| Axis | One template, language switch inside | One template per locale |
|---|---|---|
| Who can edit the Spanish copy | someone who can read the whole conditional | anyone with the Spanish record |
| Adding Portuguese | edit a shared template every locale depends on | create a new record, nothing else changes |
| Blast radius of a bad edit | all three languages | one language |
| Subject line per language | needs the same branching | it’s just the record’s subject |
| Render check | one preview covers nothing in particular | one preview per locale, comparable results |
| Works on Infrai’s stored templates | no — there are no conditionals | yes |
That last row settles it here. Infrai’s stored templates do named variable substitution and nothing else, so there is no {{#if}} to branch on in the first place. That’s a limitation, and in this specific case it pushes you toward the design you wanted anyway.
The name is for humans; the id is for machines
Store the German copy as its own record with a name that carries the notification key and the language tag:
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":"report-ready.de","subject":"Dein Bericht {{report_name}} ist fertig","html":"<p>Hallo {{first_name}}, dein Bericht {{report_name}} ist fertig.</p>","variables":["first_name","report_name","link"]}'
{
"ok": true,
"data": {
"template_id": "tmpl_cyxdoVCEDNSvOIns42Ec2B48",
"name": "report-ready.de",
"subject": "Dein Bericht {{report_name}} ist fertig",
"html": "<p>Hallo {{first_name}}, dein Bericht {{report_name}} ist fertig.</p>",
"variables": ["first_name", "report_name", "link"],
"created_at": "2026-07-26T01:09:09.625512Z",
"updated_at": "2026-07-26T01:09:09.625512Z"
}
}
Now the part that surprises people: the email namespace publishes create, PATCH /v1/email/template/update/{id}, preview and DELETE /v1/email/template/delete/{id}, but no route that lists templates or looks one up by name. So report-ready.de is a label you read in a code review, not something the API will resolve for you. The locale-to-id map has to live on your side — a JSON file in the repo, a config table, environment variables, whatever you already deploy.
Ours is a plain object, checked in next to the mailer:
{
"report-ready": {
"en": "tmpl_1y15JR26TvGBrzIjh6W4YVQa",
"es": "tmpl_vQFpLT5YvwJCsenSGUK6roWn",
"de": "tmpl_cyxdoVCEDNSvOIns42Ec2B48"
}
}
Preview is your translation-completeness test
missing_vars is the field that earns its keep. Supply a full variable set, and any placeholder the renderer couldn’t fill comes back in that array — which means a translator who typed {{reportname}} in the Spanish subject fails a test instead of shipping a literal brace to a customer.
curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_cyxdoVCEDNSvOIns42Ec2B48" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"vars":{"first_name":"Lena","report_name":"Umsätze Q3"}}'
{
"ok": true,
"data": {
"rendered_subject": "Dein Bericht Umsätze Q3 ist fertig",
"rendered_html": "<p>Hallo Lena, dein Bericht Umsätze Q3 ist fertig.</p>",
"missing_vars": []
}
}
Accented characters round-trip fine, so Umsätze and ¿Está listo? need no escaping beyond ordinary JSON.
Run it over every locale in the same job that runs your unit tests. This script previews all three, compares the placeholder sets the renderer actually resolved, and exits non-zero when a language has drifted from the reference:
// check-locales.mjs — Node 22, no dependencies. Run in CI.
import { readFileSync } from "node:fs";
import process from "node:process";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const REGISTRY = JSON.parse(readFileSync(new URL("./templates.json", import.meta.url), "utf8"));
const SAMPLE = { first_name: "Lena", report_name: "Q3 revenue", link: "https://app.example.com/r/91" };
const REFERENCE_LOCALE = "en";
async function preview(templateId, vars) {
const res = await fetch(`https://api.infrai.cc/v1/email/template/preview/${templateId}`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ vars }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok || payload.ok === false) {
throw new Error(`${templateId}: ${payload.error?.code ?? `HTTP_${res.status}`}`);
}
return payload.data;
}
const placeholders = (s) => new Set([...String(s).matchAll(/\{\{\s*([a-z0-9_]+)\s*\}\}/gi)].map((m) => m[1]));
let failures = 0;
for (const [key, byLocale] of Object.entries(REGISTRY)) {
const reference = new Set();
for (const [locale, id] of Object.entries(byLocale)) {
const rendered = await preview(id, SAMPLE);
const leftovers = new Set([
...placeholders(rendered.rendered_subject),
...placeholders(rendered.rendered_html),
]);
if (rendered.missing_vars.length || leftovers.size) {
console.error(`FAIL ${key}.${locale}: unresolved ${[...new Set([...rendered.missing_vars, ...leftovers])].join(", ")}`);
failures++;
continue;
}
const used = new Set(Object.keys(SAMPLE).filter((v) => rendered.rendered_subject.includes(SAMPLE[v]) || rendered.rendered_html.includes(SAMPLE[v])));
if (locale === REFERENCE_LOCALE) { for (const v of used) reference.add(v); continue; }
const dropped = [...reference].filter((v) => !used.has(v));
if (dropped.length) { console.error(`FAIL ${key}.${locale}: dropped vs ${REFERENCE_LOCALE}: ${dropped.join(", ")}`); failures++; }
else console.log(`ok ${key}.${locale}`);
}
}
process.exit(failures ? 1 : 0);
The second half of that loop exists because of a real gap in the check. missing_vars is derived from placeholders that appear in the stored copy, not from the declared variables array — we verified that against the live API on 2026-07-26 with a template declaring link and never using it, and the preview came back clean. So a translator who quietly deleted the {{link}} sentence from the German version produces a perfectly valid render and an email with no call to action in it. Comparing resolved variables across locales is the only thing that catches that class of loss, and it’s cheap, because preview costs nothing.
Sending: pick the locale, then the id
Resolve the recipient’s language from stored user preference first, Accept-Language second, reference locale last. Then it’s an ordinary send with the id you looked up:
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"lena@example.com","from":"reports@example.com","template_id":"tmpl_cyxdoVCEDNSvOIns42Ec2B48","template_vars":{"first_name":"Lena","report_name":"Q3 Umsätze","link":"https://app.example.com/r/91"}}'
Two things your application still owns, because substitution won’t do them: plural forms and formatting. German cardinal agreement, Spanish gendered adjectives and “1 report” versus “2 reports” all have to be resolved before the value goes into template_vars — Intl.PluralRules and Intl.NumberFormat in Node 22 handle it in a couple of lines. Dates too. Pass a preformatted string, never a timestamp.
Limits, and when a specialist wins
There’s no conditional logic, no iteration and no partials, so a receipt with a line-item table doesn’t belong in a stored template at all — render it locally and post the finished markup as html. There’s also no list route, which means an orphaned template id is invisible until you look in your own registry.
If translation workflow is the centre of your product, stick with a specialist. Postmark’s template model, with layouts and a side-by-side editor, is a nicer place for a copywriter to live, and SendGrid’s dynamic templates support Handlebars conditionals outright — either is a fair pick if that’s the axis you’re optimising.
What the multilingual loop costs
Create, update, preview and delete are free and rate-limited, so previewing 27 locale-notification pairs on every CI run is free. Only the send is metered: $0.000115 per email, verified 2026-07-26 and flagged approximate because the vendor mix underneath moves, with $2 of free credit on a new account. Rates here drift downward, so read today’s number rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c 'import json,sys
for c in json.load(sys.stdin)["capabilities"]:
if c["id"].startswith("email.template") or c["id"] == "email.send":
print(c["id"], c["billing"].get("price_usd", "free"), c["billing"].get("unit"))'
The argument for keeping this on Infrai isn’t the rate. It’s that the locale registry, the CI job that checks it, the error you capture when a preview fails and the per-tenant cost of the sends all sit behind one key — the second question doesn’t start another vendor onboarding.