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:
- Send → store each recipient's
messageIdagainst your own record - Receive webhook → look up
events[].messageIdand update the status
No polling, no matching by email address.
1. Sending
Single email
email.send() returns the messageId directly:
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+:
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 includerecipients. Persist the mapping from the first live response.
2. Receiving webhooks
Every event POSTed to your webhook endpoint carries the same messageId:
{
"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:
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
eventType | Meaning |
|---|---|
send | Accepted by the provider |
delivery | Delivered to the recipient's mail server |
bounce | Hard/soft bounce (recipient auto-suppressed on hard bounce) |
complaint | Marked as spam (recipient auto-suppressed) |
open | Recipient opened the email |
click | Recipient clicked a link |
reject | Rejected before sending (e.g. virus detected) |
rendering_failure | Template failed to render |
dropped | Recipient not attempted (e.g. invalid address caught pre-send); carries data.dropped.reason |
unsubscribe | Recipient unsubscribed |
resubscribe | Recipient 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:
const job = await outbound.email.status(jobId);
for (const r of job.recipients) {
await db.emails.upsert({ messageId: r.message_id, status: r.status });
}