DEV Community

kevindev
kevindev

Posted on

Delivery Receipts for Async Email APIs

When an app sends signup, reset, or approval mail asynchronously, the first bug report is rarely "email did not send". It is more often "support cannot tell what happened". The API returned 202, the queue looked healthy, and the user still asks where the message went. That gap is why I like exposing a delivery receipt contract in the REST API itself instead of treating email as a black box.

In backend teams, this becomes much easier to reason about when one request maps to one operation record, one worker decision, and one status surface the client or support tool can query later. The idea is not flashy, but it saves a lot of guesswork and a fair bit of noisy log diving too.

Why async email APIs become hard to support

The usual flow looks harmless:

  1. POST /signup-email
  2. Store a job
  3. Push to queue
  4. Return 202 Accepted

The problem is that 202 says "accepted for processing", not "we can explain the outcome later". In many systems, once the request leaves the API process, observability gets fragmented. Queue logs sit in one place, provider webhooks in another, and support notes in a third. By the time someone investigates, the timeline is kinda blurry.

That blur gets worse under retries. Mobile networks retry. API gateways retry. Humans click resend. If you do not keep a durable receipt row, two operations can look the same from the outside even though one was superseded. I have seen teams add more logging to fix this, but logs alone are a weak contract.

The receipt contract I expose in a REST API

My preference is simple:

  • POST /email-deliveries creates or reuses an operation
  • the response includes delivery_id
  • GET /email-deliveries/:delivery_id returns the current state
  • workers update that state as they enqueue, send, retry, or fail

That gives clients and internal tools one durable lookup key. A response can be as small as:

{
  "delivery_id": "em_01JY7QY2R5N6YQ6R6P2M",
  "status": "queued",
  "created_at": "2026-07-17T08:22:30Z"
}
Enter fullscreen mode Exit fullscreen mode

Later, the status endpoint can expose states such as queued, sent, provider_rejected, expired, or superseded. I try to keep the state machine boring on purpose. Fancy event names are cute for a week and annoying for years.

This is also where I connect backend mail flows to release-gate inbox validation. If a test or operator knows the delivery_id, the inbox check becomes a verification step, not the only source of truth.

A Node.js persistence model that makes retries boring

In Node.js, I usually persist two things:

  1. an email_deliveries row that represents the public operation
  2. an outbox or job row that represents work still to do

The first row should survive provider weirdness. The second row can be retried, replaced, or delayed without changing the external contract. A minimal table shape might be:

create table email_deliveries (
  id text primary key,
  purpose text not null,
  recipient_hash text not null,
  status text not null,
  request_id text not null,
  provider_message_id text,
  last_error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
Enter fullscreen mode Exit fullscreen mode

And a thin service layer:

type CreateDeliveryInput = {
  purpose: "signup" | "reset" | "approval";
  recipientHash: string;
  requestId: string;
};

export async function createDelivery(input: CreateDeliveryInput) {
  return db.tx(async (trx) => {
    const existing = await trx.oneOrNone(
      `select id, status
       from email_deliveries
       where request_id = $1`,
      [input.requestId]
    );

    if (existing) return existing;

    const delivery = await trx.one(
      `insert into email_deliveries
         (id, purpose, recipient_hash, status, request_id)
       values ($1, $2, $3, 'queued', $4)
       returning id, status`,
      [newDeliveryId(), input.purpose, input.recipientHash, input.requestId]
    );

    await enqueueSendJob(trx, delivery.id);
    return delivery;
  });
}
Enter fullscreen mode Exit fullscreen mode

There are fancier versions of this, but the main rule is: use one stable id for the operation the caller cares about. Do not make the client reverse-engineer queue IDs, webhook payload IDs, and provider IDs just to answer a basic support question. It sounds obvious, yet a lot of APIs still do it half way.

How I test delivery status without trusting the inbox alone

Inbox checks are useful, but they are the end of the chain, not the whole chain. My staging tests usually assert four things:

  1. the POST response returns a stable delivery_id
  2. repeated requests with the same request id do not create extra operations
  3. the worker transitions the receipt row through expected states
  4. the inbox message corresponds to that exact delivery record

That last point matters. If the inbox is the only assertion surface, a stale message can make a test pass for the wrong reason. The same mindset behind rollback email checks applies here: delivery evidence should line up with system state, not replace it.

For staging, I sometimes use a fake email address service such as tempmailso to confirm the rendered message body and links. It is handy for transport checks, but I still compare the observed email against the delivery receipt row before I call the run good. Otherwise, a recycled alias from tepm mail com notes or an old dummy e mail in a script can muddy results in ways that are frankly annoyng.

If you want one lightweight metric, start with receipt resolution time: how long it takes for a delivery to move from queued to a terminal state. Google’s SRE workbook stresses measuring user-visible completion around asynchronous work rather than only subsystem health, which is a useful framing here too (https://sre.google/workbook/implementing-slos/).

Q&A

Should the client poll the receipt endpoint?

Usually yes, at least for flows where the user is waiting on a message. Keep the polling window short, then fall back to a "still processing" UI instead of pretending the send is instant.

Do I need provider webhooks to make this useful?

Not strictly. You can start with queued and sent states from your worker. Webhooks improve accuracy later, especialy for bounces and rejects.

What if one user asks for multiple emails quickly?

That is fine if each request has a clear purpose and request id. The hard part is not concurrency itself, it is ambiguity about which operation your UI or support team is looking at.

Checklist

  • Return a stable delivery_id from the write API.
  • Keep a receipt row separate from the queue job row.
  • Make retries idempotent with a request-scoped key.
  • Treat inbox checks as confirmation, not ground truth.
  • Prefer a small, explicit state machine over provider-specific jargon.

Once you add delivery receipts, async email APIs feel much less magical and much more maintainable. Support gets answers faster, tests stop leaning on luck, and the backend contract becomes easier to evolve without breaking every surrounding tool.

Top comments (0)