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.000115 per email, verified 2026-07-26, marked approximate because the vendor behind the route can change. A new account holds $2 in credit, about 17,000 welcome messages. These rates drift down over time and discount campaigns run, so treat the figure as an illustration and read the current one:
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. If you want a template editor a marketer can safely touch, Postmark’s is better. What you get by staying here is that the follow-up work — scheduling the day-3 nudge, retrying a failed send, storing the rendered receipt, attributing cost per tenant — runs on the same credential and shows up on one bill, instead of four accounts with four rotation schedules.