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

SymptomLikely causeWhere to look
{{product}} visible in the subject lineVariable not passed at send timemissing_vars from the preview call
HTTP 402 PRO_REQUIRED on sendfrom on a domain the plan can’t holdDrop from, or upgrade
Accepted but never deliveredAddress on the account suppression listsuppressed_recipients in the send response
Lands in spam for one provider onlyDKIM record truncated by the DNS hostchecks.dkim_dns in the domain record
Sends stop mid-importDaily cap on a warming domainreputation.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.

References

Browse more email developer guides