Skip to content

Tracking Email Status via Webhooks

When you send emails — especially in bulk — you usually want to record each recipient's delivery status (delivered, bounced, opened, clicked…) in your own database. The platform gives you a single identifier to join the two sides:

messageId — a unique id generated per recipient at send time. It is returned in the send response and included in every webhook event for that email.

The flow:

  1. Send → store each recipient's messageId against your own record
  2. Receive webhook → look up events[].messageId and update the status

No polling, no matching by email address.

1. Sending

Single email

email.send() returns the messageId directly:

ts
const res = await outbound.email.send({
  fromEmail: 'noreply@yourdomain.org',
  toEmail: 'user@example.com',
  emailSubject: 'Welcome!',
  htmlBody: '<p>Hello</p>',
});

// res.messageId → "d7a6c937-0aae-475e-a336-4a8a7825f28a"
await db.emails.create({ userId, messageId: res.messageId, status: 'queued' });

Bulk email

email.bulk() returns a recipients array mapping every recipient to its messageId v0.2.2+:

ts
const res = await outbound.email.bulk({
  fromEmail: 'noreply@yourdomain.org',
  emailSubject: 'March Newsletter',
  emails: users.map(u => ({ toEmail: u.email, htmlBody: render(u) })),
});

// res.recipients:
// [
//   { toEmail: "a@example.com", messageId: "d7a6c937-…", status: "queued" },
//   { toEmail: "b@example.com", messageId: "b4fd9627-…", status: "queued" },
//   { toEmail: "c@example.com", messageId: "c446ef97-…", status: "suppressed" }
// ]

for (const r of res.recipients ?? []) {
  await db.emails.create({
    email: r.toEmail,
    messageId: r.messageId,
    status: r.status, // 'queued' | 'suppressed'
  });
}

Store the mapping from the first response

  • Recipients with status: 'suppressed' are never sent and produce no webhook events — record them as final immediately.
  • When a request is replayed with the same idempotencyKey, the cached duplicate response (duplicate: true) does not include recipients. Persist the mapping from the first live response.

2. Receiving webhooks

Every event POSTed to your webhook endpoint carries the same messageId:

json
{
  "webhookId": "8d8b214b-0747-4ff8-949c-2625a9b7d220",
  "timestamp": "2026-06-11T12:05:46.599Z",
  "events": [
    {
      "eventType": "delivery",
      "messageId": "d7a6c937-0aae-475e-a336-4a8a7825f28a",
      "data": { "...": "raw SES event, includes recipient address" },
      "timestamp": "2026-06-11T12:05:48.120Z"
    }
  ]
}

A typical Express handler, using the SDK's signature verifier and the IncomingWebhookPayload type:

ts
import express from 'express';
import { Outbound, type IncomingWebhookPayload } from '@masters-union/outbound-sdk';

const app = express();

app.post(
  '/email-events',
  express.raw({ type: 'application/json' }), // raw body needed for signature check
  async (req, res) => {
    const valid = Outbound.verifyWebhookSignature(
      req.body,
      req.header('X-Webhook-Signature') ?? '',
      process.env.OUTBOUND_WEBHOOK_SECRET!,
    );
    if (!valid) return res.status(401).send('invalid signature');

    const payload: IncomingWebhookPayload = JSON.parse(req.body.toString());

    for (const event of payload.events) {
      await db.emails.update(
        { messageId: event.messageId },        // ← the join key
        { status: event.eventType, lastEventAt: event.timestamp },
      );
    }

    res.sendStatus(200); // ack fast; non-2xx responses are retried
  },
);

Webhook retries

If your endpoint is down or returns a non-2xx status, delivery is retried per your webhook's retryInterval × maxRetries settings, with retryStrategy controlling the back-off (default: exponential, waiting 5 min, then 10 min, then 20 min across 3 retries; linear waits a flat retryInterval every time). Make your handler idempotent — the same event can arrive more than once.

Event types

eventTypeMeaning
sendAccepted by the provider
deliveryDelivered to the recipient's mail server
bounceHard/soft bounce (recipient auto-suppressed on hard bounce)
complaintMarked as spam (recipient auto-suppressed)
openRecipient opened the email
clickRecipient clicked a link
rejectRejected before sending (e.g. virus detected)
rendering_failureTemplate failed to render
droppedRecipient not attempted (e.g. invalid address caught pre-send); carries data.dropped.reason
unsubscribeRecipient unsubscribed
resubscribeRecipient resubscribed

Note that events are not strictly ordered — an open can arrive after a click. If you keep a single status column, rank events (e.g. delivery < open < click) and only upgrade.

Reconciliation fallback

If you ever lose the mapping (or missed webhooks during an outage), email.status(jobId) returns every recipient of a job with its message_id, current status, and any error_message — useful for backfills:

ts
const job = await outbound.email.status(jobId);
for (const r of job.recipients) {
  await db.emails.upsert({ messageId: r.message_id, status: r.status });
}

Released under the MIT License.