Skip to content

Designing a transactional email API you will not regret

Decisions that make an email-sending API pleasant for years: async acceptance, idempotency, explicit errors, stable fields and a clean event model.

Koltrix Team4 min read
A laptop screen displaying colorful code
Photo by Mohammad Rahmani on Unsplash
On this page(9 sections)
  1. Accept asynchronously, deliver later
  2. Make retries safe with idempotency keys
  3. Validate strictly, fail explicitly
  4. Keep the request shape small and stable
  5. Design the event model alongside the send
  6. Enforce policy centrally
  7. Make it observable
  8. A design checklist
  9. Key takeaways

An email-sending API is one of the first integrations a product team builds and one of the last they get to redesign. Every billing flow, notification and security email ends up depending on it.

A few design decisions made early determine whether it stays pleasant to use for years or becomes something people work around.

Accept asynchronously, deliver later

SMTP delivery involves DNS lookups, TLS handshakes, remote servers that may be slow or deferring, and retries that can last days. None of that belongs inside an HTTP request.

A good send API validates the request, records it, puts it on a queue and returns immediately:

POST /v1/emails HTTP/1.1
Authorization: Bearer <key>
Idempotency-Key: 9f1c3c0e-7d1b-4a3a-9a57-2f0d6a5e2b11
Content-Type: application/json

{"from": "[email protected]", "to": ["[email protected]"], "subject": "Your receipt", "body_text": "..."}
HTTP/1.1 202 Accepted
Content-Type: application/json

{"id": "msg_01J8Z...", "status": "queued"}

202 Accepted is the honest status code: the request was accepted for processing, not completed. Callers learn the outcome later through a status endpoint or webhooks.

The benefit is not just speed. Asynchronous acceptance isolates your callers from remote failures, lets you retry without the client's involvement, and lets you smooth bursts with rate limiting on the worker side.

Make retries safe with idempotency keys

Networks fail between the moment you accept a request and the moment the client receives your response. The client cannot tell whether the send happened, so it retries. Without protection, the customer receives two receipts.

An Idempotency-Key header solves this. The client generates a unique key per logical send and reuses it on retries. The server records the key with the response and, on a repeat, returns the original response instead of sending again.

The detail that matters most: claim the key atomically before doing any work, not after. Otherwise two concurrent retries can both see "no record yet" and both send. Return a distinct response, such as 409 Conflict, for a retry that arrives while the first request is still in progress.

Validate strictly, fail explicitly

Callers integrate once and then forget the details. Make mistakes obvious at integration time:

  • Reject unknown fields or at least warn about them, so a typo like bodyhtml does not silently send an empty message.
  • Validate the sender against verified domains and addresses, and say exactly which check failed.
  • Validate recipients for syntax and enforce sensible limits on count.
  • Require at least one body part, and encourage both HTML and text.
  • Return structured errors with a stable machine-readable code and a human message:
{
  "error": {
    "code": "sender_not_verified",
    "message": "from address [email protected] is not on a verified domain",
    "field": "from"
  }
}

Choose status codes deliberately: 400 for malformed requests, 401 for bad credentials, 403 for missing permissions, 422 for semantically invalid content if you distinguish it, 429 for rate limits with a Retry-After header, and 5xx only for genuine server-side failures, which tells clients a retry may help.

Keep the request shape small and stable

Every field you add is a field you must support forever. Start with the essentials: from, to, cc, bcc, subject, HTML body, text body, reply-to, custom headers, and perhaps tags or metadata for correlating events. Add attachments and templates deliberately.

Some practices that age well:

  • Arrays for recipients from day one. Changing to from a string to an array later breaks callers.
  • Explicit names. body_html and body_text are clearer than body plus a type flag.
  • Metadata as an opaque object that you echo back in events, so callers can attach their own IDs without you adding fields.
  • Versioning in the path (/v1/), and a contract test that fails when someone removes or renames a field.

Design the event model alongside the send

Sending is half the API. The other half is telling callers what happened. A clean event model has:

  • A message ID returned at acceptance and present on every event.
  • A small set of clear event types, such as queued, sent (accepted by the receiving server), bounced, complained, and optionally opened, clicked and unsubscribed.
  • Webhooks signed with HMAC so receivers can verify authenticity.
  • Retries with backoff for webhook deliveries, and at-least-once semantics clearly documented.
  • A status endpoint for callers that prefer polling or need to reconcile.

Avoid the temptation to call "accepted by the receiving server" delivered to the inbox. SMTP acceptance says nothing about spam folder placement, and users will hold you to the word.

Enforce policy centrally

The API is the right place to enforce rules that protect every caller:

  • Suppression checks so hard-bounced and complained addresses are not mailed again.
  • Per-key and per-account rate limits.
  • Scoped API keys, so a key used for notifications cannot manage domains.
  • Sender verification, so no caller can send from a domain you have not verified.
  • Content limits, such as maximum message size.

Each of these is far easier to build once in the API than to rely on every caller implementing correctly.

Make it observable

Your callers will ask "did it send?" every week. Give them the tools to answer it themselves: searchable logs by message ID, recipient and time; the SMTP response from the receiving server; and the history of events for each message. Internally, keep metrics on acceptance rate, queue depth, time to send and bounce rates per sending domain.

A design checklist

  • Return 202 Accepted with a message ID; deliver asynchronously.
  • Support Idempotency-Key, claimed atomically before work begins.
  • Validate strictly and return structured, specific errors.
  • Use arrays for recipients and explicit field names; version the API.
  • Publish signed webhooks and a status endpoint with clearly defined event types.
  • Enforce suppression, rate limits, scopes and sender verification in the API.
  • Expose logs and SMTP responses so callers can debug alone.

Key takeaways

  • Accept quickly and deliver asynchronously; SMTP has no place inside an HTTP request.
  • Idempotency keys make client retries safe, but only if claimed before work starts.
  • Strict validation and structured errors save every caller time.
  • A small, stable request shape and a clear event model are what keep an email API pleasant for years.

Start with Koltrix

Your domain, one inbox, and an API that sends.

A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.

SharePost on XLinkedIn