Webhooks
Receive real-time notifications when email events occur — deliveries, bounces, opens, clicks, and more. Subscribe your endpoint, and the platform pushes events to you as they happen.
All examples assume you've set the API key in the constructor. For multi-tenant usage, pass { apiKey } as the last argument to any method. See Configuration.
Create Webhook
const { webhook, secret } = await outbound.webhooks.create({
url: 'https://myapp.com/webhooks/outbound',
events: ['delivery', 'bounce', 'complaint', 'open', 'click'],
retryInterval: 300,
maxRetries: 3,
retryStrategy: 'exponential',
});
// ⚠️ Store `secret` securely — it's only returned once!
console.log(webhook.id); // 'uuid'
console.log(secret); // '64-char hex string'Parameters
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS endpoint to receive events |
events | WebhookEvent[] | Yes | Event types to subscribe to (see table below) |
retryInterval | number | No | Seconds between retries. Min 30, max 86400 (24 hours). Default 300 (5 minutes) |
maxRetries | number | No | Max retry attempts. Between 0 and 10. Default 3 |
retryStrategy | 'exponential' | 'linear' | No | Back-off mode. exponential (default) doubles the wait after each failed attempt; linear waits a flat retryInterval every time |
Retry Constraints
retryIntervalmust be an integer between 30 seconds and 86,400 seconds (24 hours). The bound applies to the configured base interval, not to the computed exponential waits.maxRetriesmust be an integer between 0 and 10.retryStrategymust be'exponential'or'linear'.
The fields are independent. With linear, the total retry span is retryInterval × maxRetries; with exponential, it is retryInterval × (2^maxRetries − 1), and individual waits are not capped: maxRetries alone bounds the ladder. Violating any bound returns a 400 Bad Request.
Available Events
| Event | Description |
|---|---|
send | Email accepted by AWS SES |
delivery | Email delivered to recipient's mailbox |
bounce | Email bounced (hard or soft) |
complaint | Recipient reported as spam |
open | Recipient opened the email |
click | Recipient clicked a tracked link |
reject | Email rejected by SES before sending |
rendering_failure | Template rendering failed |
dropped | Recipient was not attempted (e.g. invalid recipient address caught pre-send). Carries data.dropped.reason. Replaces the former failed event. |
Response
{
webhook: {
id: '550e8400-e29b-41d4-a716-446655440000',
url: 'https://myapp.com/webhooks/outbound',
events: ['delivery', 'bounce', 'complaint', 'open', 'click'],
retry_interval: 300,
max_retries: 3,
retry_strategy: 'exponential',
active: true,
status: 'active'
},
secret: 'a1b2c3d4e5f6...64-char-hex-string',
warning: 'Store this secret securely. It will not be shown again.'
}| Field | Description |
|---|---|
webhook | The created webhook object |
secret | HMAC signing secret — only returned on creation. Store it securely. |
warning | Reminder to save the secret |
Possible Errors
| Error | Status | Cause |
|---|---|---|
BadRequestError | 400 | Missing url or events, invalid event type, retry constraint exceeded |
AuthenticationError | 401 | Invalid or missing API key |
List Webhooks
Retrieve all webhooks registered for your tenant.
const { webhooks } = await outbound.webhooks.list();
for (const wh of webhooks) {
console.log(`${wh.url} — ${wh.status} — events: ${wh.events.join(', ')}`);
}Response
{
webhooks: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
url: 'https://myapp.com/webhooks/outbound',
events: ['delivery', 'bounce', 'complaint', 'open', 'click'],
retry_interval: 300,
max_retries: 3,
retry_strategy: 'exponential',
active: true,
status: 'active',
created_at: '2026-03-08T10:00:00.000Z'
}
]
}Webhook Status Reference
| Status | Description |
|---|---|
active | Webhook is enabled and receiving events |
defective | Too many consecutive delivery failures — webhook is auto-disabled |
TIP
A defective webhook can be re-enabled by updating it with active: true. This resets the status back to active.
Update Webhook
Only pass the fields you want to change:
const { webhook } = await outbound.webhooks.update('webhook-uuid', {
events: ['delivery', 'bounce'],
active: true,
});Parameters
All fields are optional:
| Field | Type | Description |
|---|---|---|
url | string | New HTTPS endpoint |
events | WebhookEvent[] | Updated event list |
active | boolean | Enable or disable the webhook |
retryInterval | number | Seconds between retries. Min 30, max 86400 (24 hours) |
maxRetries | number | Max retry attempts. Between 0 and 10 |
retryStrategy | 'exponential' | 'linear' | Back-off mode: exponential doubles the wait each attempt, linear waits retryInterval every time |
Response
{
message: 'Webhook updated',
webhook: {
id: '550e8400-e29b-41d4-a716-446655440000',
url: 'https://myapp.com/webhooks/outbound',
events: ['delivery', 'bounce'],
retry_interval: 300,
max_retries: 3,
retry_strategy: 'exponential',
active: true,
status: 'active',
created_at: '2026-03-08T10:00:00.000Z'
}
}Re-enable a Defective Webhook
// If a webhook was auto-disabled due to failures, reactivate it:
await outbound.webhooks.update('webhook-uuid', { active: true });
// status resets from 'defective' → 'active'Possible Errors
| Error | Status | Cause |
|---|---|---|
NotFoundError | 404 | Webhook ID doesn't exist or belongs to another tenant |
BadRequestError | 400 | Invalid event type, retry constraint exceeded |
Delete Webhook
Permanently remove a webhook. This action is irreversible.
const result = await outbound.webhooks.delete('webhook-uuid');
// { message: 'Webhook deleted', id: 'webhook-uuid' }Possible Errors
| Error | Status | Cause |
|---|---|---|
NotFoundError | 404 | Webhook doesn't exist or belongs to another tenant |
Testing your endpoint
There's no SDK/API method to send a test event — testing is a dashboard-only action (Webhooks page → "Send test event"). It dispatches a synthetic ping event (event: 'ping', status: 'ping') so you can confirm your endpoint receives and verifies deliveries before going live.
Verify Webhook Signature
When your endpoint receives a webhook, always verify the signature to confirm it came from the Outbound platform. The platform signs every payload using HMAC-SHA256 with your webhook secret.
How Signatures Work
- The platform signs the raw request body with
HMAC-SHA256(body, your_secret) - Sends the hex-encoded signature in the
X-Webhook-Signatureheader - Also sends
X-Webhook-Id,X-Webhook-Timestamp, and (v2)X-Webhook-Delivery-Id
Sign the raw body
Verify against the exact bytes received, not a re-serialized object. Use a raw-body parser (e.g. express.raw) so the bytes match what was signed.
Verification with the SDK
import { Outbound } from '@masters-union/outbound-sdk';
app.post('/webhooks/outbound', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const isValid = Outbound.verifyWebhookSignature(
req.rawBody, // the exact bytes received
signature,
process.env.WEBHOOK_SECRET // the secret from webhooks.create()
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Signature verified — process the event
const event = req.body;
console.log(`Event: ${event.type} for ${event.email}`);
res.status(200).send('OK');
});Manual Verification (without the SDK)
import crypto from 'crypto';
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}WARNING
Always verify webhook signatures in production to prevent spoofed events. Use crypto.timingSafeEqual (not ===) to prevent timing attacks.
Webhook Payload Format
A delivery is a batch — always iterate events. The v2 envelope (IncomingWebhookPayloadV2):
{
webhookId: '1f3a…',
webhookDeliveryId: 'd8c1…', // also in X-Webhook-Delivery-Id; stable across retries
version: 2,
timestamp: '2026-06-21T10:00:00.000Z',
events: [ /* IncomingWebhookEventV2[] */ ]
}Each event:
{
eventId: '9b2e…', // stable per-event idempotency key
messageId: '019ee6ec-…', // per-recipient correlation id
campaignId: 'AUTM-000449', // or null
event: 'bounce', // verb
status: 'bounced', // mapped status
email: 'user@example.com',
subject: 'Welcome',
metadata: { lead_id: '98765' },
details: { /* event-specific, below */ },
timestamp: '2026-06-21T09:59:58.000Z', // when it occurred
receivedAt: '2026-06-21T10:00:00.100Z' // when we received it
}details by event
event | status | details |
|---|---|---|
send | sent | — |
delivery | delivered | — |
bounce | bounced | bounceType, bounceSubType, smtpStatus, diagnosticCode, failedReason, suppressed |
complaint | complained | complaintType, suppressed |
open | opened | userAgent, ip |
click | clicked | link, userAgent, ip |
reject | rejected | failedReason |
rendering_failure | rendering_failed | failedReason |
failed | failed | failedReason |
unsubscribe | unsubscribed | — |
resubscribe | resubscribed | — |
suppressed: true (bounce/complaint) means the address was auto-added to your suppression list. unsubscribe/resubscribe are platform events from the unsubscribe page (not provider events).
Types are exported: IncomingWebhookPayloadV2, IncomingWebhookEventV2, WebhookEventDetails, WebhookEventStatus, and AnyIncomingWebhookPayload (a union of v1 and v2 — discriminate on version).
Legacy v1 payload
Webhooks created before v2 deliver the raw provider event. Discriminate by the absence of version:
{ webhookId, timestamp, events: [{ eventType, messageId, data /* raw SES event */, timestamp }] }Retry Behavior
If your endpoint returns a non-2xx status code or times out, the platform retries delivery. The wait between attempts depends on the webhook's retryStrategy:
Exponential (default) — the wait doubles after each failed attempt:
- First attempt — immediate
- Retry 1 — after
retryIntervalseconds (default: 5 minutes) - Retry 2 — after
retryInterval × 2seconds (default: 10 minutes) - Retry 3 — after
retryInterval × 4seconds (default: 20 minutes), and so on up tomaxRetries
Linear — every retry waits a flat retryInterval:
- First attempt — immediate
- Retry 1 — after
retryIntervalseconds - Retry 2 — after another
retryIntervalseconds - Retry 3 — final attempt (based on
maxRetries)
A delivery that fails all of its retries is marked exhausted. When maxRetries consecutive deliveries (minimum 1) each exhaust their retries, the webhook status is set to defective and no further events are sent until you re-enable it. Any successful delivery in between resets the streak.
Best Practices
- Return 200 quickly — process events asynchronously after responding
- Handle duplicates — the same event may be delivered more than once during retries
- Use the
messageIdas an idempotency key to deduplicate events - Monitor webhook status — check
outbound.webhooks.list()periodically fordefectivewebhooks
Complete Example: Webhook Lifecycle
import { Outbound } from '@masters-union/outbound-sdk';
import express from 'express';
const outbound = new Outbound({ apiKey: 'mu_outbound_...' });
const app = express();
app.use(express.json());
// 1. Create a webhook
const { webhook, secret } = await outbound.webhooks.create({
url: 'https://myapp.com/webhooks/outbound',
events: ['delivery', 'bounce', 'complaint', 'open', 'click'],
});
// Save the secret to your config/env
console.log('Webhook secret:', secret);
// 2. Handle incoming events
app.post('/webhooks/outbound', (req, res) => {
const signature = req.headers['x-outbound-signature'];
if (!Outbound.verifyWebhookSignature(JSON.stringify(req.body), signature, secret)) {
return res.status(401).send('Invalid signature');
}
const event = req.body;
switch (event.type) {
case 'delivery':
console.log(`Delivered to ${event.email}`);
break;
case 'bounce':
console.log(`Bounced: ${event.email} (${event.bounceType})`);
break;
case 'complaint':
console.log(`Complaint from ${event.email}`);
break;
case 'open':
console.log(`Opened by ${event.email}`);
break;
case 'click':
console.log(`Clicked by ${event.email}: ${event.link}`);
break;
}
res.status(200).send('OK');
});
// 3. List your webhooks
const { webhooks } = await outbound.webhooks.list();
console.log(`You have ${webhooks.length} webhook(s)`);
// 4. Update events
await outbound.webhooks.update(webhook.id, {
events: ['delivery', 'bounce', 'complaint'],
});
// 5. Delete when no longer needed
await outbound.webhooks.delete(webhook.id);