Picking a password-reset email API: retries, DKIM, US and EU

Four properties decide a reset-mail provider for a Node SaaS: sending before DNS, the domain-verify gate, retry duplication, and delivery evidence.

A password reset is one HTTP call, so providers all look interchangeable in the quickstart. They stop looking interchangeable at four points: whether you can send at all before DNS exists, what the custom-domain gate costs you, what a retried request does, and how you prove a specific user’s mail arrived. We tested those four against Infrai’s POST /v1/email/send on a live account.

All four hold up, and the one that decides reset mail is the retry. POST /v1/email/send honours an idempotency key: replay the identical request and the same message_id comes back, one message reaches the inbox, one charge lands on the bill. The catch is that you have to supply the key — a keyless call is a fresh send every time, and a signup handler that gets redelivered will happily prove it.

The four properties, side by side

PropertyInfraiResendPostmarkAmazon SES
Send before you own DNSYes, shared send.infrai.cc senderTest domain for development onlyVerify a sender signature firstVerify identity, then leave the sandbox
Custom sender domainPro plan; the catalogue declares minimum_tier: "pro"IncludedIncludedIncluded
Retry with the same keySame message_id, one send, one chargeDocumented idempotency windowIdempotency on some endpointsIdempotency on SendEmail
Delivery evidencePoll GET /v1/email/event/listWebhooks or dashboardWebhooks plus message searchSNS notifications
Same key reaches other servicesAI, storage, queues, SMS, authEmail onlyEmail onlyThe rest of AWS

Read that table as a shape, not a scoreboard. If reset mail is the only outbound message your product will ever send, Postmark is a defensible choice and its delivery reporting is better than what’s described below.

Day one, with no domain and no DNS records

The three-field send is the fastest path from an empty project to a real inbox. from is optional — leave it out and the platform picks a sender on its own domain.

curl -sS -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?export your_infrai_api_key first}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.net",
    "subject": "Reset your Ledgerly password",
    "html": "<p>Use this link within 15 minutes: <a href=\"https://app.example.net/reset?t=abc\">Reset password</a></p>"
  }'

The response tells you which sender was used and which recipients were dropped by the account suppression list before anything left the building:

{
  "ok": true,
  "data": {
    "message_id": "msg_gbzfikhMXYq89JsoTvvto0jH",
    "mode": "default_vendor",
    "from_used": "noreply+a1f9@send.infrai.cc",
    "accepted_recipients": ["dana@example.net"],
    "suppressed_recipients": [],
    "vendor_message_id": "b4a45acd-0f3f-45b6-ac99-66d815ec05a2"
  }
}

A shared sender is fine for a private beta and wrong for a product with paying users, because the From: address doesn’t match your brand and the reputation isn’t yours. It buys you the first two weeks, not the first two years.

The retry, and the key that earns its keep

Here’s the property worth the whole page. The send endpoint takes an idempotency_key, in the JSON body or as an Idempotency-Key header, and either form collapses the replay.

BODY='{"to":"dana@example.net","subject":"Reset your Ledgerly password",
       "html":"<p>link</p>","idempotency_key":"reset-tok-8f21"}'

curl -sS -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?export your_infrai_api_key first}" \
  -H "Content-Type: application/json" -d "$BODY"

curl -sS -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?export your_infrai_api_key first}" \
  -H "Content-Type: application/json" -d "$BODY"

Two identical bodies, same key, seconds apart. The second response repeats the first message_id, flips idempotent_replay to true in its metadata block, and the email.send row in GET /v1/account/usage moves by one call rather than two. That’s the behaviour reset mail needs: a double-clicked button puts one live link in someone’s inbox instead of two, so there’s no stale second link still working after the password has already changed.

Derive the key from the reset token, not from the request. A client retry, a queue redelivery and a restarted worker all recompute the same value that way, which is the whole point.

// reset-mailer.mjs — Node 22 ESM, no dependencies.
// `store` is a local short-circuit so a retry inside one process never leaves it;
// the idempotency key is what makes the call safe across processes. Swap the Map
// for your real table: one row per reset token, unique on token_id.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is unset (use your_infrai_api_key)");

const store = new Map(); // token_id -> { message_id, sent_at }

async function post(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    const code = json?.error?.code ?? `http_${res.status}`;
    const err = new Error(`send failed: ${code}`);
    err.retryable = json?.error?.retryable === true;
    throw err;
  }
  return json.data;
}

export async function sendResetOnce({ tokenId, email, resetUrl }) {
  const already = store.get(tokenId);
  if (already) return { ...already, deduped: true };

  let data;
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      data = await post("/v1/email/send", {
        to: email,
        subject: "Reset your Ledgerly password",
        html: `<p>Use this link within 15 minutes: <a href="${resetUrl}">Reset password</a></p>`,
        idempotency_key: `reset:${tokenId}`,
      });
      break;
    } catch (err) {
      if (!err.retryable || attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 400 * attempt));
    }
  }

  const record = { message_id: data.message_id, sent_at: Date.now() };
  store.set(tokenId, record);
  return { ...record, deduped: false };
}

const out = await sendResetOnce({
  tokenId: "tok_8f21",
  email: "dana@example.net",
  resetUrl: "https://app.example.net/reset?t=abc",
});
console.log(out);

Note the retry loop only re-sends on retryable: true. That flag is doing real work. A malformed address comes back as 400 INVALID_RECIPIENT with retryable: false, so the loop stops on the first attempt rather than spending its budget on a string that will never be a mailbox. Retry 5xx, never 4xx — and branch on the flag rather than on the status code, because the flag is the part the API promises.

Custom domain, SPF and DKIM

Once real users arrive you want no-reply@yourdomain.com. Custom sender domains sit on the Pro plan, and the catalogue tells you before you write a line of code — GET /v1/discovery/email.domain.verify reports minimum_tier: "pro". On a standard key the call answers accordingly:

curl -sS -X POST https://api.infrai.cc/v1/email/domain/verify \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?export your_infrai_api_key first}" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.example.net"}'
{
  "ok": false,
  "error": {
    "code": "PRO_REQUIRED",
    "http_status": 402,
    "message": "custom sender domains are Pro-only; standard accounts have 0 custom sender domains",
    "retryable": false
  }
}

That’s a real limitation on the entry tier, and you should price the upgrade in before you commit a reset flow to it. On a Pro account the same call returns the DNS records to publish, and GET /v1/email/domain/list shows the state of every domain you’ve registered:

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

Four records come back per domain: an SPF TXT at the apex (v=spf1 include:_spf.infrai.cc ~all), a DKIM TXT at cf._domainkey.<domain>, a tracking CNAME, and a DMARC TXT at _dmarc.<domain> starting at p=none. Publish all four with the recommended 3600-second TTL, then re-call verify until status reads verified. Reset mail is exactly the traffic that gets junked when DKIM is missing, so this isn’t optional polish.

US, EU, and what you can’t pin

Sending is available in both western and China regions; domain verification currently runs through the western vendor only. There’s no request parameter that pins processing to an EU data centre — the response metadata reports the region that handled the call, and that’s the visibility you get. If your compliance posture requires EU-resident processing of recipient addresses under a signed DPA, confirm that in writing before you migrate a reset flow, or stick with a provider that sells an EU region as a product feature.

Keep the reset token itself short-lived (15 minutes is a reasonable default) so the copy of it sitting in a mail log ages out fast.

Proving a specific reset arrived

No webhook setup, no tunnel during development. The event feed is a plain read, newest first:

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_FjDRVM4y1dx7xcElLubSlJMF" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?export your_infrai_api_key first}"

You get queued, then sent, then delivered or bounced, each with a timestamp and the vendor’s own message id in meta. Support can answer “did the reset email go out?” from one call.

Cost, and the honest reason to consolidate

Sends bill at $0.00046 per email — that’s the rate we read on 2026-07-27, $0.46 per thousand, and GET /v1/discovery returns the current one for every route in the namespace:

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

Domain, template, suppression and event reads are free and rate-limited; only the send costs money. The billing block reports its own trial arithmetic — new_account_trial_uses currently reads 4,347, so the $2 starting credit is worth roughly 4,300 reset emails. Treat that as today’s reading rather than a constant: prices move, usually downward, so pull the figure before you build a forecast on it.

The durable argument isn’t the rate. It’s that the key you just used for reset mail also reaches the queue that schedules the reminder, the error tracker that catches the failed send, and the SMS route you’ll want for step-up authentication later — one account, one invoice, one usage query per tenant. If you genuinely only need email and nothing else, a specialist is a perfectly good answer.

References

Browse more email developer guides