Transactional email over HTTPS, not an SMTP relay, for a Node SaaS

The accept-then-report contract an HTTP email API gives you, why edge runtimes rule out SMTP, and the domain-auth, suppression and event-polling calls that follow.

Ruling out an SMTP relay changes the contract more than most comparison tables admit. An HTTPS email API accepts the message, hands back an identifier, and turns everything downstream — delivered, bounced, complained — into a read you can make whenever you want. Infrai’s POST /v1/email/send works exactly that way, and the same key also authenticates your sending domain, holds the suppression list and serves the event feed.

That last part is the reason we’d put an aggregated API ahead of a point solution for a SaaS backend: the reset email is never the only thing the request handler needs. But start with the transport question, because it decides your code shape.

Relay or API: what actually changes in your code

ConcernSMTP relayHTTPS JSON APISelf-hosted MTA
TransportLong-lived TCP on 587/2525, STARTTLSOne fetch per messageYou run Postfix and the queue
Edge/serverless runtimesNeeds a raw socket — not available in most of themWorks anywhere fetch worksNot applicable
Message handleParse the queue id out of the 250 response textmessage_id in the JSON bodyYours to generate
Failure signalA 4xx/5xx SMTP code, then silenceHTTP status plus a typed error codeBounce mailbox you parse
Credential rotationUsername/password in config, per appOne bearer tokenSSH and config management
SuppressionYours to buildGET /v1/email/suppression/listYours to build

RFC 5321 is a fine protocol and there’s nothing wrong with it. The problem is where modern SaaS code runs. Edge runtimes don’t hand you Node’s net module, so a Nodemailer-shaped client isn’t an option there at all, and even on plain Node the relay leaves you owning connection reuse, TLS retries and a queue for the moments the relay is slow.

The API version of that job is one HTTP call with an idempotent retry policy you already have code for.

Authenticate the sender before anything else

You can’t send from your own domain until it’s verified, and the verify route is the thing that tells you which records to publish.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/domain/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"domain":"notify.example.com"}'
{
  "ok": true,
  "data": {
    "domain": "notify.example.com",
    "domain_id": "dom_7Qk2ZrmVn3sxYp",
    "status": "pending_dns",
    "dns_records": [
      { "purpose": "spf", "type": "TXT", "name": "notify.example.com", "value": "v=spf1 include:spf.example-vendor.net ~all" },
      { "purpose": "dkim", "type": "TXT", "name": "cf._domainkey.notify.example.com", "value": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQ..." },
      { "purpose": "dmarc", "type": "TXT", "name": "_dmarc.notify.example.com", "value": "v=DMARC1; p=none; rua=mailto:dmarc@notify.example.com" }
    ],
    "warm_up_state": "new",
    "daily_limit_current": 500,
    "daily_limit_target": 50000
  }
}

Publish what the response gives you rather than what a blog post gives you — the SPF include host and the DKIM key are specific to your account, and copying someone else’s is the most common way this fails. Then call the same route again; it’s idempotent and moves status to verified once DNS resolves. DMARC ships at p=none, which reports without enforcing, and moving it to p=quarantine and then p=reject is a decision you make in your own zone once the aggregate reports look clean. Google’s bulk sender guidelines are the practical bar to aim at.

Sending, and the two arrays in the reply

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to":"dev@example.com","from":"alerts@notify.example.com","subject":"Your export is ready","html":"<p>Download it within 24 hours.</p>"}'
{
  "ok": true,
  "data": {
    "message_id": "msg_4bTaRk9uWQ2mHvXeLpc7",
    "from_used": "alerts@notify.example.com",
    "mode": "live",
    "accepted_recipients": ["dev@example.com"],
    "suppressed_recipients": []
  }
}

Branch on both arrays. An address that hard-bounced before comes back under suppressed_recipients and nothing goes out, which is the suppression list protecting your domain rather than a bug to route around.

Polling the event feed

No webhook endpoint means no signature verification, no public URL, no replay handling, and no 3am page because your receiver was down during a delivery burst. It also means the freshest data you have is as old as your last poll, so pick an interval you can live with.

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_4bTaRk9uWQ2mHvXeLpc7&limit=50" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "records": [
      { "type": "delivered", "recipient": "dev@example.com", "occurred_at": "2026-07-26T09:14:41Z" },
      { "type": "opened", "recipient": "dev@example.com", "occurred_at": "2026-07-26T09:31:02Z" }
    ],
    "total_count": 2,
    "next_cursor": null
  }
}

Keep paging while next_cursor is non-null, and store the last cursor you consumed so a restarted worker doesn’t replay a week of history.

The whole loop in Node 22

import { setTimeout as sleep } from "node:timers/promises";
import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function call(path, init = {}) {
  const res = await fetch(API + path, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
    throw new Error(`${path} -> ${err.code}: ${err.message}`);
  }
  return payload.data;
}

export async function notify(recipient, subject, html) {
  const sent = await call("/v1/email/send", {
    method: "POST",
    body: JSON.stringify({
      to: recipient,
      from: "alerts@notify.example.com",
      subject,
      html,
    }),
  });

  if (sent.suppressed_recipients.length > 0) {
    return { messageId: null, outcome: "suppressed" };
  }

  let state = "queued";
  for (let attempt = 0; attempt < 5 && (state === "queued" || state === "sent"); attempt++) {
    await sleep(3000 * 2 ** attempt);
    ({ state } = await call(`/v1/email/get/${sent.message_id}`));
  }
  return { messageId: sent.message_id, outcome: state };
}

const result = await notify("dev@example.com", "Your export is ready", "<p>Download it within 24 hours.</p>");
console.log(result);

Five attempts with doubling backoff is about 93 seconds of patience. Anything still queued past that is a throttling question, not a delivery question, and reputation.current_daily_cap on the domain is where the answer lives. A message_id the API has never seen returns EMAIL_NOT_FOUND, which in a worker almost always means you polled the wrong environment’s id.

The bill, and how to read today’s number

Sends are billed per recipient at $0.000115, verified 2026-07-26, and a new account’s $2 credit covers roughly 17,391 of them. Domain verification, message reads, event listing and suppression are free and rate-limited rather than metered — the whole deliverability surface costs nothing to operate, which is why we’d rather you poll it hourly than quarterly. Rates in this market drift downward and discount campaigns land without warning, so read the live figure instead of trusting a page:

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

Where we’d point you elsewhere

Amazon SES is cheaper per message and will stay cheaper; if raw unit price is the deciding axis and you’re happy wiring SNS topics to a consumer for bounces, take SES. Postmark’s transactional-only reputation policy is a genuine advantage if your mail is high-stakes and low-volume, and its webhook story is more mature than a polled feed.

Three limitations to weigh before you commit here. Custom sending domains need a paid plan — POST /v1/email/domain/verify returns HTTP 402 PRO_REQUIRED on a standard account. There are no outbound webhooks, so event data arrives when you ask for it. And the live vendor behind the send route is currently Resend, with SES and a China-region path wired but not yet serving, so if you need a contractual EU-only processing region today you’d be better off with a provider that sells one explicitly.

What you get back is that the export job that produced this email, the object it wrote, the retry that failed and the tenant you bill for it all sit on the same credential and the same invoice.

References

Browse more email developer guides