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.

Two of the four went the way the docs implied. One did not, and it’s the one that matters most for reset mail — retrying a send does not deduplicate, even with an idempotency key attached. Infrai accepts the key and charges you twice. Design for that.

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; standard keys get 402IncludedIncludedIncluded
Retry with the same keySends again, bills againDocumented 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 is where reset flows go wrong

Here’s the finding worth the whole page. The send endpoint accepts an idempotency_key, which reads like a promise. It isn’t one — at least not on this route today.

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. We got two different message_id values, idempotent_replay: false on both, and two charges. For a marketing blast that’s an annoyance; for a password reset it means a double-clicked button puts two live reset links in someone’s inbox, and the second one keeps working after the user has already changed their password.

So put the guard in your own code, keyed on the reset token rather than on the request.

// reset-mailer.mjs — Node 22 ESM, no dependencies.
// Swap `store` 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>`,
      });
      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 matters more than it looks: an unroutable recipient address comes back as a 503 VENDOR_DOWN marked retryable, so a naive “retry all 5xx” loop will happily bill you three times for an address that can never receive mail.

Custom domain, SPF and DKIM

Once real users arrive you want no-reply@yourdomain.com. On a standard key that call is gated:

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 and you should price it in before you commit. 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.000115 per email — that’s the rate we read on 2026-07-26, 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 $2 trial credit covers roughly 17,391 emails, and these rates trend downward as vendor pricing does, so treat the figure as a ceiling rather than a fixed fact.

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