Designing a signup verification email pipeline people don't complain about

Most missing-code tickets are decided before the send call runs. Address capture, honest UI copy, a resend cooldown, and a 503 that is really a 400.

Almost every “I never got the code” ticket is decided before your send call runs: the address was mistyped, the code expired faster than the mail flew, or your interface said “check your inbox” for an address the platform had already stopped mailing. Infrai’s send response tells you which of those happened at the moment you send — accepted_recipients and suppressed_recipients come back inline — and the pipeline below is built around actually reading them.

The second design decision is your retry policy, and it’s the one that bites hardest at launch, because a permanently invalid address currently comes back as an HTTP 503 marked retryable.

Acceptance is not delivery

A 200 means the platform took custody of the message. It does not mean a human will see it.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to":"user@gmial-nonexistent-zzz.com","subject":"Your verification code is 418-207","html":"<p>Enter <strong>418-207</strong> to finish signing in. The code expires in 15 minutes.</p>"}'
{
  "ok": true,
  "data": {
    "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7",
    "mode": "default_vendor",
    "from_used": "noreply+a1f9@send.infrai.cc",
    "accepted_recipients": ["user@gmial-nonexistent-zzz.com"],
    "suppressed_recipients": []
  }
}

That domain doesn’t exist. The send still succeeded, because acceptance happens before any MX lookup, and the failure will arrive minutes later as a bounce. Meanwhile a suppressed address — one that hard-bounced or complained earlier — also returns ok: true, with the address moved into suppressed_recipients and nothing delivered. Branch on that array, not on the status code. A signup screen that shows “we’ve sent you a code” when the array is non-empty is manufacturing its own support tickets.

The 503 that is really a 400

Send to something that isn’t an address at all and the shape changes in a way your retry code has to know about:

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "invalid recipient address: 'not-an-email'",
    "retryable": true
  }
}

We reproduced this against the live API on 2026-07-26. A malformed recipient is a permanent condition, but it arrives dressed as a transient vendor outage with retryable: true on it — so a generic “retry every 5xx with exponential backoff” worker will spend its whole budget, and several minutes of the user’s patience, on input that can never succeed. Worth flagging loudly because it’s the exact failure a launch-week queue hits first.

Two defences, and you want both. Validate the address shape at capture time with a real parser rather than a regex you wrote at midnight, and in the worker, treat a 5xx whose message contains invalid recipient address as terminal:

// classify.mjs — Node 22
export function classify(status, error) {
  const message = String(error?.message ?? "");
  if (/invalid recipient address/i.test(message)) return "permanent";
  if (status === 429 || status >= 500) return "transient";
  if (status >= 400) return "permanent";
  return "ok";
}

Give the code a longer life than your delivery p99

Codes that expire in 60 seconds generate complaints on their own. Pick the TTL from measurement instead of folklore: every event carries a timestamp, so the gap between queued and sent is something you can read rather than guess.

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_Hd029dIYk7I6cdlWLbiRQal7" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "type": "sent", "at": "2026-07-26T01:11:42.719655Z", "recipient": "user@gmial-nonexistent-zzz.com", "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7", "meta": { "vendor_message_id": "2f86043b-8177-4832-a3bf-47361f2acd89" } },
      { "type": "queued", "at": "2026-07-26T01:11:42.673284Z", "recipient": "user@gmial-nonexistent-zzz.com", "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7", "meta": { "vendor": "resend" } }
    ],
    "next_cursor": null,
    "count": 2
  }
}

Note the ordering — newest first — and note that handoff took about 46ms here. That’s the part you control. Everything after sent belongs to the receiving mail system, where greylisting routinely adds one to five minutes, which is why 15 minutes is a sane floor for a login code and 60 seconds is not.

Complaint, cause, and the design that removes it

What the user saysUsual causeThe fix at design timeThe call that proves it
”Nothing arrived”typo’d domaininline suggest on capture; bounce back into the UIGET /v1/email/event/list
”Nothing arrived”, twiceaddress is suppressedread suppressed_recipients and say soGET /v1/email/suppression/check/{email}
”The code expired”TTL shorter than delivery15-minute codes, one-time useGET /v1/email/get/{id}
”It went to spam”shared sender, no brand alignmentverified domain with DMARC, once you’re ProGET /v1/email/domain/list
”I clicked resend ten times”no cooldown, no feedback60s cooldown with a visible timeryour own store

The suppression check is one free call and belongs in the signup handler before you ever charge yourself for a send:

curl -sS "https://api.infrai.cc/v1/email/suppression/check/user@example.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{ "ok": true, "data": { "email": "user@example.com", "suppressed": false } }

The pipeline in one file

Preflight, send, classify, record. The verification row stores message_id so support can answer “where did it go” with a single lookup instead of a shrug.

// verification-mailer.mjs — Node 22, no dependencies.
import { randomInt } from "node:crypto";
import process from "node:process";
import { classify } from "./classify.mjs";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const CODE_TTL_MS = 15 * 60 * 1000;
const RESEND_COOLDOWN_MS = 60 * 1000;

const lastSentAt = new Map();

async function call(path, init = {}) {
  const res = await fetch(API + path, { headers, ...init });
  const payload = await res.json().catch(() => ({}));
  return { status: res.status, payload };
}

export async function issueCode(email) {
  const previous = lastSentAt.get(email) ?? 0;
  const wait = RESEND_COOLDOWN_MS - (Date.now() - previous);
  if (wait > 0) return { status: "cooldown", retryInMs: wait };

  const pre = await call(`/v1/email/suppression/check/${encodeURIComponent(email)}`);
  if (pre.payload?.data?.suppressed) return { status: "suppressed" };

  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  const sent = await call("/v1/email/send", {
    method: "POST",
    body: JSON.stringify({
      to: email,
      subject: `Your verification code is ${code}`,
      html: `<p>Enter <strong>${code}</strong> to finish signing in. It expires in 15 minutes.</p>`,
    }),
  });

  const verdict = classify(sent.status, sent.payload?.error);
  if (verdict === "permanent") return { status: "bad_address", detail: sent.payload?.error?.message };
  if (verdict === "transient") return { status: "retry_later" };

  const data = sent.payload.data;
  if (data.suppressed_recipients?.length) return { status: "suppressed" };

  lastSentAt.set(email, Date.now());
  return {
    status: "sent",
    code,
    messageId: data.message_id,
    fromUsed: data.from_used,
    expiresAt: new Date(Date.now() + CODE_TTL_MS).toISOString(),
  };
}

const target = process.argv[2];
if (target) console.log(await issueCode(target));

Store the hash of code, never the code. And keep message_id on the same row as the verification attempt — it’s the join key for every question anyone will ask later.

Confirm the queue is doing what you think with one free read:

curl -sS "https://api.infrai.cc/v1/email/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The sender you launch with

New accounts send from a shared address — ours came back as noreply+a1f9@send.infrai.cc — which authenticates correctly from the first minute but carries no brand. Sending from your own domain needs POST /v1/email/domain/verify, and on a standard account that answers HTTP 402 PRO_REQUIRED: custom sender domains are a paid capability. If a branded From line is a launch requirement and you don’t want a plan yet, Postmark and Amazon SES both let you verify a domain without upgrading, and that’s a legitimate reason to run signup mail on one of them.

What it costs, and where this stops

Suppression checks, message lookups and event reads are free and rate-limited. Sends are metered at $0.000115 per email, verified 2026-07-26 and flagged approximate, with $2 of free credit on a new account — call it a few thousand signups before you pay anything. Prices drift downward here, so read the current figure:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c 'import json,sys; print(next(c["billing"] for c in json.load(sys.stdin)["capabilities"] if c["id"]=="email.send"))'

The honest drawback is that there are no delivery webhooks, so “did it land” is a poll, and open tracking needs the tracking CNAME that only a verified domain gets. If your signup funnel lives or dies on open rates from day one, that’s a real gap. What you get instead is that the queue behind this worker, the cron job that expires stale codes, and the per-tenant cost of every send are on the same key and the same bill.

References

Browse more email developer guides