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.
| Decision | Default | Change it when | Route |
|---|---|---|---|
| Where the send runs | after commit, retry-safe, outside the HTTP response path | never — this one has no good exception | POST /v1/email/send |
| Sender identity | shared platform sender to start | the brand matters, or the recipient is a consumer | POST /v1/email/domain/verify |
| Where the copy lives | stored template, referenced by id | the message is genuinely one-off | POST /v1/email/template/create |
| What you record | message id, vendor, region, cost, in your own store | never | GET /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 things worth knowing from our own testing. The request schema also accepts an idempotency_key field in the JSON body, which behaves identically — pick whichever your HTTP client makes cleaner, and be consistent about it. And the replay is not a second charge: three identical sends behind one key moved the email.send row in GET /v1/account/usage by one call and one unit of cost.
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 crosses a plan boundary. Custom sender domains are a Pro capability: on a standard account POST /v1/email/domain/verify answers 402 PRO_REQUIRED, and so does any send carrying a custom from — the check fires before any DNS lookup, so publishing records early buys you nothing. You can see the boundary before you write code, because the catalogue declares it: GET /v1/discovery/email.domain.verify reports minimum_tier: "pro". Budget for the plan in the same sprint you budget for the DNS change.
Resend and Postmark both put domain verification on their entry tier. If a branded sender on day one is non-negotiable and you don’t want a subscription conversation yet, buy one of those instead — that’s the honest recommendation, and it’s the single strongest argument against consolidating this workload early.
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. An invalid recipient address comes back as 400 INVALID_RECIPIENT carrying retryable: false, so a client that reads the flag gives up on the first attempt instead of spending its whole backoff budget on an address that can never work. Retry 5xx, never 4xx — the flag says which one you’re holding. Cap the attempts anyway, and record the permanent failures against the user.
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-27 was $0.00046 — $0.46 per thousand — against $2 of starting credit, which the same billing block scores at 4,347 sends. Rates move, 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.