A Node welcome-email module: stored template plus a DKIM preflight
Keep the copy in the API, render it before a user sees it, and refuse to send when the sender domain's DKIM check isn't green. Runnable Node 22, with the honest paywall.
The simplest welcome-email setup that survives contact with a real product has two pieces: the copy lives in the API as a template with named variables, and the sender is checked before the send rather than after the complaint. Infrai’s email surface gives you both — POST /v1/email/template/create for the first, GET /v1/email/domain/get/{domain} for the second — and the whole thing fits in one Node 22 module.
Templates matter for a boring reason. Marketing will change the wording of the welcome mail four times before launch, and each edit shouldn’t be a deploy.
Store the copy where the API can render it
Variables are declared up front, in double braces:
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_v1",
"subject": "Welcome to {{product}}, {{first_name}}",
"html": "<h1>Hi {{first_name}}</h1><p>Your {{product}} workspace is ready.</p>",
"variables": ["first_name", "product"]
}'
{
"ok": true,
"data": {
"template_id": "tmpl_qDLJiHQGQWcux3sjAeV2HG5D",
"name": "welcome_v1",
"subject": "Welcome to {{product}}, {{first_name}}",
"variables": ["first_name", "product"],
"created_at": "2026-07-26T01:11:40.011055Z"
}
}
Keep template_id in config, not in code. Editing copy then means PATCH /v1/email/template/update/{id} and nothing else moves.
Render it before a user does
The preview call is the closest thing to a unit test for email copy, and its useful field is missing_vars:
curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_qDLJiHQGQWcux3sjAeV2HG5D" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"vars": {"first_name": "Sam"}}'
{
"ok": true,
"data": {
"rendered_subject": "Welcome to {{product}}, Sam",
"rendered_html": "<h1>Hi Sam</h1><p>Your Acme workspace is ready.</p>",
"missing_vars": ["product"]
}
}
An unsupplied variable isn’t an error — the placeholder is left in the output verbatim, which is how Welcome to {{product}}, Sam reaches an inbox. Assert on an empty missing_vars in CI and that class of embarrassment disappears.
The sender check nobody runs
Here’s the part that separates a setup that works from one that works today. A verified domain exposes a checks map, and DKIM can fail on its own while SPF stays green — a registrar UI that mangles a long TXT record does exactly that:
curl -sS "https://api.infrai.cc/v1/email/domain/get/mail.acme.dev" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"verification": {
"domain": "mail.acme.dev",
"status": "verified",
"checks": { "spf_dns": "verified", "dkim_dns": "verified", "dmarc_dns": "verified", "mail_loopback": "verified" },
"warm_up_state": "in_progress",
"daily_limit_current": 50000
},
"reputation": { "tier": "warming_up", "current_daily_cap": 50000, "used_today": 0, "bounce_rate_30d": 0.0 }
}
}
Cache that for a few minutes and gate the send on it. Nodemailer’s DKIM page is a good primer if you want the signing mechanics; operationally, the only question your code needs to answer is whether checks.dkim_dns is still verified.
Now the paywall, stated plainly: custom sender domains are Pro-only. On a standard account, both POST /v1/email/domain/verify and any send carrying a from on an unregistered domain answer HTTP 402 with PRO_REQUIRED. Drop from and mail goes out from a shared, pre-authenticated send.infrai.cc address — perfectly deliverable, with none of your branding on it. That’s the trade-off, and it’s the reason to know your plan before you write DNS into a runbook.
The module
// welcome.mjs — Node 22
const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
const TEMPLATE_ID = process.env.WELCOME_TEMPLATE_ID;
const SENDER_DOMAIN = process.env.SENDER_DOMAIN; // unset on a standard account
if (!key || !TEMPLATE_ID) throw new Error("INFRAI_API_KEY and WELCOME_TEMPLATE_ID are required");
async function api(path, init = {}) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { authorization: `Bearer ${key}`, "content-type": "application/json", ...(init.headers ?? {}) },
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok !== true) {
const err = new Error(`${json?.error?.code ?? res.status}: ${json?.error?.message ?? path}`);
err.status = res.status;
throw err;
}
return json.data;
}
let senderCache = { at: 0, healthy: false };
async function senderHealthy() {
if (!SENDER_DOMAIN) return true; // shared sender, nothing to check
if (Date.now() - senderCache.at < 300_000) return senderCache.healthy;
const data = await api(`/v1/email/domain/get/${SENDER_DOMAIN}`);
const checks = data.verification.checks ?? {};
const healthy = data.verification.status === "verified" && checks.dkim_dns === "verified";
senderCache = { at: Date.now(), healthy };
if (!healthy) console.error("sender unhealthy:", JSON.stringify(checks));
return healthy;
}
export async function previewWelcome(vars) {
const rendered = await api(`/v1/email/template/preview/${TEMPLATE_ID}`, {
method: "POST",
body: JSON.stringify({ vars }),
});
if (rendered.missing_vars.length) throw new Error(`missing template vars: ${rendered.missing_vars.join(", ")}`);
return rendered;
}
export async function sendWelcome(user) {
const vars = { first_name: user.firstName, product: "Acme" };
await previewWelcome(vars);
if (!(await senderHealthy())) throw new Error("refusing to send: DKIM not verified on the sender domain");
const payload = { to: user.email, template_id: TEMPLATE_ID, template_vars: vars };
if (SENDER_DOMAIN) payload.from = `hello@${SENDER_DOMAIN}`;
const data = await api("/v1/email/send", { method: "POST", body: JSON.stringify(payload) });
return { messageId: data.message_id, fromUsed: data.from_used, suppressed: data.suppressed_recipients };
}
if (process.argv[2]) {
console.log(await sendWelcome({ email: process.argv[2], firstName: "Sam" }));
}
The preflight costs one cached HTTP call per five minutes and turns a silent deliverability regression into a loud exception. When DKIM does break, the fix is usually republishing the record — and if you rotate keys, POST /v1/email/domain/rotate_dkim/{domain} issues a new pair, after which the domain’s checks.dkim_dns goes back to pending until the new TXT record resolves.
When welcome mail goes wrong
| Symptom | Likely cause | Where to look |
|---|---|---|
{{product}} visible in the subject line | Variable not passed at send time | missing_vars from the preview call |
HTTP 402 PRO_REQUIRED on send | from on a domain the plan can’t hold | Drop from, or upgrade |
| Accepted but never delivered | Address on the account suppression list | suppressed_recipients in the send response |
| Lands in spam for one provider only | DKIM record truncated by the DNS host | checks.dkim_dns in the domain record |
| Sends stop mid-import | Daily cap on a warming domain | reputation.current_daily_cap versus used_today |
Cost, and what stays free
Templates, previews, domain records and DKIM rotation are free and rate-limited. Only the send is metered: $0.00046 per email, verified 2026-07-27, marked approximate because the vendor behind the route can change. A new account holds $2 in trial credit. Deliberately not doing the division for you here — a send count printed in a paragraph is unverifiable the day the rate moves, and rates in this category do move. Read the current figure and divide it yourself:
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"] in ("email.send", "email.template.create"):
print(c["id"], c["billing"].get("price_usd", "free"))'
Limitations, and the honest alternatives
Templates here are string interpolation, not a layout language — no loops, no conditionals, no partials. Multi-block emails with repeated rows are out of scope, and you’d render those in your app and post html directly. There’s no visual editor, no A/B testing, and delivery status is polled rather than pushed to a webhook.
If your welcome mail is really the first step of a lifecycle sequence with branching and timing rules, Loops is built for exactly that and this API isn’t. Buy it if your marketing team owns the sequence and wants to edit the branching without you. Buy Postmark instead if the thing you need is a template editor a non-engineer can safely touch and a support team that reads your DMARC reports.
What you get by staying here is narrower and more useful: the sender preflight above is one leg of a flow whose other legs are already on your account. The retry after a VENDOR_DOWN is POST /v1/queue/publish; the day-3 nudge is POST /v1/cron/create; the DKIM regression you just turned into an exception belongs in POST /v1/errors/capture; the rendered receipt goes to PUT /v1/storage/object/put/{bucket}/{key}. Same key issues all four, so the welcome flow grows without another vendor contract, another rotation schedule or another invoice line to chase down.