Skip to content

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

ts
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

FieldTypeRequiredDescription
urlstringYesHTTPS endpoint to receive events
eventsWebhookEvent[]YesEvent types to subscribe to (see table below)
retryIntervalnumberNoSeconds between retries. Min 30, max 86400 (24 hours). Default 300 (5 minutes)
maxRetriesnumberNoMax retry attempts. Between 0 and 10. Default 3
retryStrategy'exponential' | 'linear'NoBack-off mode. exponential (default) doubles the wait after each failed attempt; linear waits a flat retryInterval every time

Retry Constraints

  • retryInterval must 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.
  • maxRetries must be an integer between 0 and 10.
  • retryStrategy must 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

EventDescription
sendEmail accepted by AWS SES
deliveryEmail delivered to recipient's mailbox
bounceEmail bounced (hard or soft)
complaintRecipient reported as spam
openRecipient opened the email
clickRecipient clicked a tracked link
rejectEmail rejected by SES before sending
rendering_failureTemplate rendering failed
droppedRecipient was not attempted (e.g. invalid recipient address caught pre-send). Carries data.dropped.reason. Replaces the former failed event.

Response

ts
{
  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.'
}
FieldDescription
webhookThe created webhook object
secretHMAC signing secret — only returned on creation. Store it securely.
warningReminder to save the secret

Possible Errors

ErrorStatusCause
BadRequestError400Missing url or events, invalid event type, retry constraint exceeded
AuthenticationError401Invalid or missing API key

List Webhooks

Retrieve all webhooks registered for your tenant.

ts
const { webhooks } = await outbound.webhooks.list();

for (const wh of webhooks) {
  console.log(`${wh.url} — ${wh.status} — events: ${wh.events.join(', ')}`);
}

Response

ts
{
  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

StatusDescription
activeWebhook is enabled and receiving events
defectiveToo 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:

ts
const { webhook } = await outbound.webhooks.update('webhook-uuid', {
  events: ['delivery', 'bounce'],
  active: true,
});

Parameters

All fields are optional:

FieldTypeDescription
urlstringNew HTTPS endpoint
eventsWebhookEvent[]Updated event list
activebooleanEnable or disable the webhook
retryIntervalnumberSeconds between retries. Min 30, max 86400 (24 hours)
maxRetriesnumberMax retry attempts. Between 0 and 10
retryStrategy'exponential' | 'linear'Back-off mode: exponential doubles the wait each attempt, linear waits retryInterval every time

Response

ts
{
  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

ts
// 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

ErrorStatusCause
NotFoundError404Webhook ID doesn't exist or belongs to another tenant
BadRequestError400Invalid event type, retry constraint exceeded

Delete Webhook

Permanently remove a webhook. This action is irreversible.

ts
const result = await outbound.webhooks.delete('webhook-uuid');
// { message: 'Webhook deleted', id: 'webhook-uuid' }

Possible Errors

ErrorStatusCause
NotFoundError404Webhook 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

  1. The platform signs the raw request body with HMAC-SHA256(body, your_secret)
  2. Sends the hex-encoded signature in the X-Webhook-Signature header
  3. 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

ts
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)

ts
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):

ts
{
  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:

ts
{
  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

eventstatusdetails
sendsent
deliverydelivered
bouncebouncedbounceType, bounceSubType, smtpStatus, diagnosticCode, failedReason, suppressed
complaintcomplainedcomplaintType, suppressed
openopeneduserAgent, ip
clickclickedlink, userAgent, ip
rejectrejectedfailedReason
rendering_failurerendering_failedfailedReason
failedfailedfailedReason
unsubscribeunsubscribed
resubscriberesubscribed

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:

ts
{ 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:

  1. First attempt — immediate
  2. Retry 1 — after retryInterval seconds (default: 5 minutes)
  3. Retry 2 — after retryInterval × 2 seconds (default: 10 minutes)
  4. Retry 3 — after retryInterval × 4 seconds (default: 20 minutes), and so on up to maxRetries

Linear — every retry waits a flat retryInterval:

  1. First attempt — immediate
  2. Retry 1 — after retryInterval seconds
  3. Retry 2 — after another retryInterval seconds
  4. 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 messageId as an idempotency key to deduplicate events
  • Monitor webhook status — check outbound.webhooks.list() periodically for defective webhooks

Complete Example: Webhook Lifecycle

ts
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);

Released under the MIT License.