A SaaS welcome-email integration: four decisions, with defaults

Reference pattern for welcome mail on Node 22: retry-safe sends via an idempotency header, shared vs custom sender, stored copy, and the record you keep yourself.

Choosing a transactional email API for welcome mail is four decisions, not one: where the send runs relative to the signup request, whose domain the message comes from, where the copy lives, and what you write down afterwards. Infrai answers all four over one REST surface with one key, and we’ve tested each answer against the live API rather than the documentation.

Here are our defaults for a SaaS with fewer than 50,000 signups a month, and the conditions under which each default is wrong.

DecisionDefaultChange it whenRoute
Where the send runsafter commit, retry-safe, outside the HTTP response pathnever — this one has no good exceptionPOST /v1/email/send
Sender identityshared platform sender to startthe brand matters, or the recipient is a consumerPOST /v1/email/domain/verify
Where the copy livesstored template, referenced by idthe message is genuinely one-offPOST /v1/email/template/create
What you recordmessage id, vendor, region, cost, in your own storeneverGET /v1/email/get/{id}

Decision one: a welcome send must survive a retry

Signup handlers get retried. A mobile client times out and re-posts, a queue redelivers, a deploy restarts a worker mid-flight — and the user gets two welcome emails, which is the single most common complaint about this workload.

The fix is an idempotency key on the send, and here’s the part we’d rather you learn from us than from your users: pass it as the Idempotency-Key HTTP header.

curl -s -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H 'Idempotency-Key: welcome:user_8812' \
  -H 'content-type: application/json' \
  -d '{
        "to": "user@example.com",
        "subject": "Welcome to Example",
        "html": "<p>Your workspace is ready. No action needed — this is your confirmation.</p>"
      }'

Replay the identical request and you get the same message back, flagged:

{
  "ok": true,
  "data": {
    "message_id": "msg_FjDRVM4y1dx7xcElLubSlJMF",
    "mode": "default_vendor",
    "from_used": "noreply+a1f9@send.infrai.cc",
    "accepted_recipients": ["user@example.com"],
    "suppressed_recipients": []
  },
  "metadata": {
    "idempotent_replay": true,
    "vendor": "resend",
    "vendor_region": "western"
  }
}

Same message_id, one email delivered. Two caveats from our own testing, both worth knowing: the request schema also accepts an idempotency_key field in the JSON body, and in our testing that field did not deduplicate — two identical bodies carrying the same value produced two distinct message ids and two emails. Use the header. And the replay still showed up as a call in our usage record, so treat idempotency as protection against duplicate mail rather than duplicate billing.

Key it on something stable and meaningful. welcome:user_8812 is right; a fresh UUID per attempt defeats the whole mechanism.

Decision two: whose domain is in the From header

You can send on day one with no DNS at all — omit from and the platform uses its own authenticated sender, returning what it used in from_used. For an internal tool, a beta, or a B2B product where the first email follows a sales conversation, that’s a perfectly reasonable place to stay.

It stops being reasonable the moment a stranger receives it. A welcome email from an address the recipient has never seen, on a domain that isn’t yours, is indistinguishable from the phishing they’ve been trained to report.

The upgrade path has a wall in it. On a standard account, POST /v1/email/domain/verify returns 402 PRO_REQUIRED, and so does any send carrying a custom from — the check fires before any DNS lookup, so publishing records early doesn’t help. Custom sender domains are a paid tier, and the capability catalogue lists the route as free and available with no tier field, which is misleading enough that we’d rather say it plainly than let you find out on launch day.

Resend, by contrast, puts domain verification on its free tier; if a branded sender on day one is non-negotiable and you don’t want a subscription conversation, that’s the honest recommendation.

Decision three: where the copy lives

Inline HTML in your application code is fine for exactly one message. The second locale, or the first request from marketing to change a sentence, and you’ll want the copy stored where it can be changed without a deploy.

curl -s -X POST https://api.infrai.cc/v1/email/template/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H 'content-type: application/json' \
  -d '{
        "name": "welcome-en-2026-07",
        "subject": "Welcome to {{product}}, {{first_name}}",
        "html": "<p>Hi {{first_name}}, your {{product}} workspace is ready.</p><p><a href=\"{{app_url}}\">Open it</a></p>",
        "variables": { "first_name": "string", "product": "string", "app_url": "string" }
      }'

Then send with template_id and template_vars instead of subject and html. The render happens server-side, and POST /v1/email/template/preview/{id} returns missing_vars so a missing substitution fails in your test suite instead of in someone’s inbox. The full pipeline — including localisation and a CI check — is covered in the password-reset template piece.

Decision four: the record you keep

This is the decision that gets skipped, and it’s the one that matters for compliance questions six months later. Every response carries a metadata block naming the vendor and the region that handled the call. Write it down at send time, in your own database, keyed to your own user id.

Do that and “which processor handled the welcome email we sent this EU customer” is a query against your data. Skip it and it’s a support ticket to a vendor, answered from logs that may have rotated.

import process from "node:process";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY missing");

const ENDPOINT = "https://api.infrai.cc/v1/email/send";

/**
 * Sends the welcome message for a freshly created account.
 * Safe to call twice: the idempotency header collapses the replay.
 */
export async function sendWelcome({ userId, email, firstName }) {
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      authorization: `Bearer ${KEY}`,
      "content-type": "application/json",
      "idempotency-key": `welcome:${userId}`,
    },
    body: JSON.stringify({
      to: email,
      subject: "Welcome to Example",
      html: `<p>Hi ${firstName}, your workspace is ready.</p>`,
    }),
  });

  const payload = await response.json();

  if (!response.ok) {
    const { code, message, retryable } = payload.error ?? {};
    if (retryable === false) {
      // Permanent: a bad address or a policy rejection. Record and move on.
      return { ok: false, permanent: true, code, message };
    }
    throw new Error(`welcome send failed: ${code ?? response.status} ${message ?? ""}`);
  }

  return {
    ok: true,
    userId,
    messageId: payload.data.message_id,
    fromUsed: payload.data.from_used,
    vendor: payload.metadata.vendor,
    region: payload.metadata.vendor_region,
    costUsd: payload.metadata.cost_usd,
    replay: payload.metadata.idempotent_replay === true,
  };
}

const record = await sendWelcome({ userId: "user_8812", email: "user@example.com", firstName: "Sam" });
console.log(JSON.stringify(record));

That retryable === false branch matters more than it looks. One rough edge we hit: an invalid recipient address currently comes back as a 503 marked retryable, so a client that retries every 5xx will spend its entire backoff budget on an address that can never work. Branch on the flag, cap the attempts, and record the permanent failures.

Confirming a batch of signups actually went out

The account-wide listing is the fastest daily sanity check — no message id required:

curl -s "https://api.infrai.cc/v1/email/list?limit=3" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "items": [
      { "message_id": "msg_G9CJD8olw9Om4aQaTC6p3Gm2", "state": "sent", "channel": "email", "to": "user@example.com", "vendor": "resend", "created_at": 1785028312.2295365 }
    ],
    "next_cursor": null,
    "count": 1
  }
}

Per-message detail is GET /v1/email/get/{id}, and the event timeline is GET /v1/email/event/list with a message_id query parameter — required, not optional. There are no webhooks on this surface, which is a limitation if you want push notification of a bounce four hours later and a simplification if you’d rather not operate a public receiver.

What the pattern costs

Everything except the send is free: template create and preview, domain reads, message state, the event feed, the listing above. The send is metered per email, and the catalogue rate we read on 2026-07-26 was $0.000115, against $2 of starting credit. Rates drift downward over time, so read it live rather than trusting a page:

curl -s https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  | jq '.capabilities[] | select(.id == "email.send") | {unit: .billing.unit, price: .billing.price_usd, trial: .billing.new_account_trial_uses}'

Your metered reality is in GET /v1/account/usage, which reports cost and calls per capability — divide one by the other before forecasting.

The argument for putting this on Infrai isn’t the rate. It’s that the same key already runs the cron job that schedules the day-two email, the object store holding the attachment, and the error tracker that catches the failure — with per-tenant cost attribution as a query rather than a reconciliation across four invoices. If welcome mail is genuinely the only thing you need, a specialist with a free custom domain will serve you better, and Resend’s Node quickstart is the shortest path to it.

References

Browse more email developer guides