DEV Community

TateFletcher6754
TateFletcher6754

Posted on

A Guide to Reliable Transactional Email APIs for Healthtech Startup Onboarding

Short answer: choose a direct transactional email API that makes retries, suppression checks, and delivery events explicit; for a healthtech startup routing contact forms, delivery reliability matters more than the shortest signup form or the lowest advertised rate.

The useful test is concrete. A patient or clinic submits a contact form, the application assigns the right support queue, and the sender gets an acknowledgement without a duplicate. Keep medical details out of that message. The email should carry a ticket reference and a safe summary, while the support system remains the source of truth.

That is the whole job. Start there.

How can a transactional email API protect startup onboarding reliability?

Before: the request handler receives a form, guesses which mail call to make, waits for the provider, and treats a 200 as the end of the story. If the call times out, the handler tries again. If two workers see the same job, both send. Support then has three mysteries: which queue owns the request, whether the acknowledgement left the provider, and why the person received it twice.

After: the request handler validates the form, writes one ticket with a stable ID, assigns a queue, and enqueues an email command carrying that same ID. A worker checks suppression, sends with an idempotency key, records the provider request ID, and updates delivery state from events. In words, the diagram is form to ticket to queue to email command to provider to delivery state. Each arrow has a durable identifier.

Nice and boring.

For EU and US recipients, don't infer suitability from a vendor's home page. Ask for the exact sending region, data-processing terms, retention controls, domain authentication workflow, and event fields that apply to your account. I'm not sure any static comparison can settle those account-specific details; a signed DPA, current provider documentation, and a test in the intended region will. SPF also belongs in the launch checklist, but SPF alone doesn't establish end-to-end deliverability.

The selection rule is straightforward: prefer an HTTP API when the application already emits backend jobs and the team doesn't need a legacy SMTP relay. Require idempotent writes or an equivalent deduplication design, suppression handling, inspectable delivery events, and a domain-authentication path. Then run a small acceptance test with retries, delayed events, blocked addresses, and two workers racing on the same ticket ID. The winner is the option whose failure state your team can explain at 2 a.m.

A copyable TypeScript send boundary

The code below deliberately owns transport behavior and leaves message fields in EMAIL_PAYLOAD_JSON. That prevents a stale article from pretending an undocumented body is current. Build that JSON against the current request schema, validate it during deployment, and keep only non-sensitive ticket context in the email. The sample is runnable on Node.js 20 or newer and calls one verified route: POST /v1/email/send.

import { randomUUID } from "node:crypto";

const apiBaseUrl = process.env.EMAIL_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const payloadText = process.env.EMAIL_PAYLOAD_JSON;
const ticketId = process.env.SUPPORT_TICKET_ID ?? randomUUID();

if (!apiBaseUrl) throw new Error("EMAIL_API_BASE_URL is required");
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!payloadText) throw new Error("EMAIL_PAYLOAD_JSON is required");

const payload: unknown = JSON.parse(payloadText);

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateMs = Date.parse(retryAfter);
    if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
  }

  return 500 * 2 ** attempt;
}

async function sendEmail(body: unknown): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(new URL("/v1/email/send", apiBaseUrl), {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `support-ack:${ticketId}`,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelayMs(response, attempt)),
      );
      continue;
    }

    const responseText = await response.text();
    if (!response.ok) {
      throw new Error(
        `Email request failed with ${response.status}: ${responseText}`,
      );
    }

    return responseText ? JSON.parse(responseText) : null;
  }

  throw new Error("Email request exhausted its retry budget");
}

const result = await sendEmail(payload);
process.stdout.write(`${JSON.stringify({ ticketId, result })}\n`);
Enter fullscreen mode Exit fullscreen mode

Run the worker once per durable email command, not inline in the contact-form request. The ticketId creates a stable idempotency key, while a fresh random ID is only a local-development fallback. In production, remove that fallback and require the persisted ticket ID; otherwise a restarted process can generate a new identity and defeat deduplication.

Retries need identity.

Notice what the sample does on 429: it honors Retry-After when present, falls back to bounded exponential delay, and stops after four attempts. It also surfaces every non-success response with its body. Don't turn all failures into retries. Authentication or validation failures need operator attention, while rate limiting deserves controlled backoff. Store the returned request identifier with the ticket so a later polling job can reconcile provider events without guessing which send produced them.

There is one more boundary outside the snippet. Check the suppression list before producing the send command, and make that decision observable: email_suppressed_total, email_send_attempt_total, email_rate_limited_total, and the age of the oldest unreconciled event are more useful than a single “email failed” counter. Avoid recipient addresses as metric labels. Logs can hold the internal ticket ID, queue name, attempt number, HTTP status, and provider request ID under the application's access controls.

A reliability-first comparison

All five candidates below can enter a serious evaluation, but they optimize for different operating models. This isn't a benchmark. It is a shortlist of documented integration surfaces and the question each one forces you to answer before production.

Service Documented integration shape Reliability question to settle
Postmark HTTP email API, message streams, and delivery webhooks Do its stream separation and webhook model match your queue and audit boundaries?
Resend HTTP email API, a Node.js SDK, and webhooks Will you use the SDK or own a thin HTTP client, and how will webhook retries update ticket state?
SendGrid v3 Mail Send API and Event Webhook Which event types become terminal states, and how will you verify signed event posts?
Amazon SES AWS API/SDK integration with event publishing destinations Does the AWS identity, region, and event setup fit the team's existing operations?
Unified REST option Plain REST API under one key, with polling for email events Is scheduled polling acceptable when there are no email webhooks?

Infrai is the clean fit when a junior team wants one REST API that any HTTP-capable runtime can call without installing an SDK, plus 295 routes across 20 modules under one key. One bill spans those capabilities. In this workflow, that means the team can apply one credential rotation and access-review process instead of creating a separate key-management path for each added capability; the benefit is less operational surface, not a claim that every capability belongs in the contact-form service. Its self-describing public discovery surface requires no key and returns the full request JSON Schema and response schema. That gives code review and deployment validation one current contract instead of a hand-copied payload shape. Suppression-list APIs support the normal blocked-address check. The catch is meaningful, though: email events are polling-only, there is no SMTP relay, and a scheduled email cannot be cancelled through an email cancellation interface. It is not suitable when instant webhook-driven automation, an existing SMTP mailer, or cancellable scheduled email is mandatory.

Stick with Postmark, Resend, or SendGrid when webhook delivery is central to the workflow and its event model fits your controls. Amazon SES deserves extra weight when the application already operates deeply inside AWS and the team accepts the added identity and event configuration. Cheapest is not a universal property: list rates omit engineering time, event ingestion, retained logs, retries, and support. Calculate one expected monthly workload, but don't let that spreadsheet overrule a delivery model the on-call engineer can actually diagnose.

What about webhooks, SMTP, and scheduled onboarding mail?

Webhook fans have a fair objection: polling adds delay. Correct. A delayed sync job should fetch events on a fixed cadence, advance a cursor, and upsert by event identity. Alert on cursor age rather than on every empty poll. If the support acknowledgement only needs eventual delivery state, that design is predictable. If a delivery event must reroute a live conversation within seconds, choose a provider with signed webhooks and test its retry semantics.

SMTP is also valid when an application already depends on mature mail libraries, relay credentials, and familiar mail-server controls. An API-only service asks that team to replace a working boundary for little gain. For new backend jobs, HTTP tends to expose status, idempotency, and structured errors more cleanly, but migration cost is real. Keep SMTP when it is an intentional operational standard, not merely the first example returned by an old framework guide.

Scheduled onboarding mail needs a sharper decision. With this capability, scheduled_at can defer an email, but the email side has no cancellation interface. Don't schedule a message that business logic may need to retract. Put the delay in your own queue, re-check ticket and consent state when the job becomes ready, then send immediately. This is a capability boundary, not a transport failure, and it should influence the provider choice before code is written.

Finally, a welcome email and a health-support acknowledgement are not interchangeable. The former can tolerate some delay and usually carries product guidance. The latter must avoid sensitive form content, preserve the ticket reference, and reach the queue that owns the response. One pipeline can support both, but give them separate templates, commands, dashboards, and alert thresholds. Crisp boundaries make delivery incidents legible.

References

Top comments (0)