DEV Community

Sendaro
Sendaro

Posted on

Why "sent" isn't "delivered": tracking transactional email in Node.js with webhooks

A user clicks "Forgot password", your API returns 200, and ten minutes later they open a support ticket: the email never came. You check your logs and find email sent successfully. So what happened?

Usually the answer is that "sent" only ever meant "some server accepted my request". Getting that email to the user is a separate step, and if your app doesn't track it, you're guessing.

This post walks through modelling the lifecycle of a transactional email properly in Node.js: what each status means, how to send safely with retries, and how to consume delivery webhooks without getting duplicate or forged events.

The lifecycle of one email

From the moment your code calls an email API until the message reaches a mailbox, there are a few distinct states:

Status What it actually means
queued The provider stored your request. Nothing has left yet.
sent The provider's mail servers accepted it for delivery.
delivered The recipient's mail server accepted it.
bounced The recipient's server rejected it (e.g. the address doesn't exist).
complained The recipient marked it as spam.
rejected / failed It never went out, and there should be a reason.

The jump that matters is sent → delivered. Until the receiving server says "got it", you don't know the email arrived anywhere. Even delivered isn't a promise of the inbox (spam folders exist), but it's the strongest signal you get.

So, rule one: an HTTP 202 from your email API is not delivery. Store the message id and treat the outcome as unknown until an event tells you otherwise.

Sending: store the id, use an idempotency key

I'll use Sendaro for the examples (disclosure: I work on it). The same ideas apply to any provider that has message ids and webhooks.

npm install sendaro
# .env
SENDARO_API_KEY=rf_your_api_key
Enter fullscreen mode Exit fullscreen mode
import { Sendaro, SendaroError } from "sendaro";

const sendaro = new Sendaro(); // reads SENDARO_API_KEY

export async function sendPasswordReset(user: { id: string; email: string }, resetId: string, link: string) {
  try {
    const { id } = await sendaro.send(
      {
        from: "Acme <noreply@acme.com>",
        to: user.email,
        subject: "Reset your password",
        html: `<p>Use <a href="${link}">this link</a> to choose a new password.</p>`,
        text: `Use this link to choose a new password: ${link}`,
      },
      // Same reset request -> same key -> never two emails.
      { idempotencyKey: `password-reset:${resetId}` },
    );

    await db.emails.insert({ id, userId: user.id, kind: "password_reset", status: "queued" });
    return id;
  } catch (err) {
    if (err instanceof SendaroError) {
      // err.status is the HTTP status, err.code is the API error string
      console.error("send failed", err.status, err.code);
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details are worth pointing out.

The idempotency key comes from your domain, not from Math.random(). The point is that a retried job, a double-clicked button or a timeout followed by a retry all produce the same key. With Sendaro, resending the same key with the same content within 24 hours returns the original message id with a 200 instead of sending a second email. Reusing a key with different content returns a 409, which usually means a bug on your side.

You save the id before you know the outcome. That id is what joins every later event back to the user and the reason the email was sent.

Receiving events: verify, acknowledge, process

Polling every message doesn't scale, so the provider pushes status changes to you as webhooks. Sendaro sends email.sent, email.delivered, email.bounced, email.complained, email.rejected, email.failed and email.unsubscribed, and the payload looks like this:

{
  "type": "email.delivered",
  "created_at": "2026-11-01T09:30:02.000Z",
  "data": { "id": "6f1c2a9e-4b7d-4f0a-9a51-3c2d8e7b1f04", "status": "delivered" }
}
Enter fullscreen mode Exit fullscreen mode

A webhook endpoint is a public URL that changes your database, so treat it like one. Here's a Next.js route handler:

// app/api/sendaro/route.ts
import { verifyWebhook } from "sendaro";

export async function POST(request: Request) {
  // Hash the RAW body. Parsing and re-serialising JSON breaks the signature.
  const payload = await request.text();

  const valid = verifyWebhook(
    payload,
    request.headers.get("rf-signature"),
    process.env.SENDARO_WEBHOOK_SECRET!,
    request.headers.get("rf-timestamp"),
  );
  if (!valid) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(payload);
  await recordEvent(event); // keep this fast
  return new Response(null, { status: 204 });
}
Enter fullscreen mode Exit fullscreen mode

verifyWebhook checks an HMAC-SHA256 of the timestamp and raw body against your endpoint's signing secret, uses a constant-time comparison, and rejects stale timestamps (older than 5 minutes). If you're on Express, use express.raw({ type: "application/json" }) on that route so you still have the raw bytes.

Make the handler idempotent

Webhooks get retried. If your endpoint doesn't answer with a 2xx within 8 seconds, Sendaro retries with exponential backoff, up to 5 attempts in total. Sooner or later you will receive the same event twice, so write for that:

const rank: Record<string, number> = {
  queued: 0, sent: 1, delivered: 2,
  bounced: 3, complained: 3, rejected: 3, failed: 3,
};

async function recordEvent(event: { type: string; data: { id: string } }) {
  const status = event.type.replace("email.", "");

  // 1. Append to an event log; a unique (email_id, type) makes duplicates no-ops.
  await db.emailEvents.insertIgnoreDuplicate({ emailId: event.data.id, type: status });

  // 2. Only move the current status "forward", so a late "sent" can't overwrite "delivered".
  const row = await db.emails.find(event.data.id);
  if (row && (rank[status] ?? 0) >= (rank[row.status] ?? 0)) {
    await db.emails.update(event.data.id, { status });
  }
}
Enter fullscreen mode Exit fullscreen mode

The ranking is a defensive choice on the consumer side. Networks and retries can reorder deliveries, and you don't want a stale event to roll the status back.

When a user says "I never got it"

Now you can actually answer. Look up the email row for that user:

  • delivered: their mail server accepted it. Ask them to check spam and filters.
  • bounced: the address is wrong or dead. Ask them to update it. (Sendaro also adds hard-bounced addresses to a suppression list, so you stop sending to them.)
  • sent for a long time: it's still being attempted, or the receiving server deferred it.
  • failed / rejected: it never left, and the error field says why.

To get the full history of a single message without waiting for webhooks, fetch it:

const message = await sendaro.get(id);
console.log(message.status, message.events.map((e) => e.type));
// "delivered" [ "queued", "sent", "delivered" ]
Enter fullscreen mode Exit fullscreen mode

Takeaways

  1. A 2xx from the send call means accepted, not delivered. Model the states.
  2. Derive idempotency keys from the thing that triggered the email, so retries can't double-send.
  3. Verify webhook signatures over the raw body, answer fast, and make processing idempotent.
  4. Don't let a late event overwrite a later status.

If you want to try this flow, Sendaro has a free tier (3,000 emails a month, no card), and the docs cover the full status list and webhook format. Either way, once "did the email arrive?" is a database query rather than a guess, support tickets get a lot easier.

Top comments (0)