# Webhooks

> Learn how to use webhooks in Broadcast to receive real-time notifications about email events, subscriber activities, and broadcast campaign status changes.

Source: https://sendbroadcast.net/docs/webhooks

Webhooks allow you to receive real-time notifications when events occur in your Broadcast account, such as when emails are sent, opened, when subscribers are added or removed, or when broadcast campaigns complete or fail.

## What are Webhooks?

Webhooks are HTTP callbacks that Broadcast sends to your application when specific events occur. Instead of repeatedly checking for updates, your application can receive instant notifications when something happens.

Common use cases for webhooks include:

- Syncing subscriber data with your CRM
- Updating analytics dashboards in real-time
- Triggering automated workflows based on email interactions
- Logging email events for compliance purposes
- Monitoring broadcast campaign completion and success rates
- Alerting your team when broadcasts fail or encounter issues
- Integrating broadcast status updates with project management tools
- Tracking subscriber re-engagement metrics and lifecycle events

## Available Event Types

Broadcast supports the following webhook event types:

### Email Events
- `email.sent` - Email was successfully sent. Not sent for broadcasts while parallel sending is on (see below)
- `email.send_delayed` - Broadcast has not sent a transactional email yet, because its email server is at the hourly limit or the email provider paused sending. Sent once, at the first wait. Broadcast keeps trying every minute for up to 24 hours. This is not `email.delivery_delayed`, which means the provider accepted the email and the recipient's server delays it
- `email.delivered` - Email was delivered to the recipient's inbox
- `email.delivery_delayed` - Email delivery was delayed
- `email.bounced` - Email bounced (hard or soft bounce)
- `email.complained` - Recipient marked email as spam
- `email.opened` - Recipient opened the email
- `email.clicked` - Recipient clicked a link in the email
- `email.failed` - Email failed to send. Not sent for broadcasts while parallel sending is on (see below)

With parallel sending on (`PARALLEL_BROADCAST_SENDING=true`), broadcasts send `broadcast.batch_completed` instead of per-recipient `email.sent` and `email.failed` events. Sequential broadcasts (the default), sequences and transactional emails still send `email.sent` and `email.failed`. Delivery, open, click, bounce and complaint events are the same in both modes.

### Subscriber Events
- `subscriber.created` - New subscriber was added
- `subscriber.updated` - Subscriber information was updated
- `subscriber.preferences_updated` - One or more [Subscriber Topics](https://sendbroadcast.net/docs/subscriber-topics) values changed. Sent together with `subscriber.updated`; built for consent records
- `subscriber.deleted` - Subscriber was deleted. Not sent for subscribers removed by a purge; see `subscribers.purged`
- `subscriber.subscribed` - Subscriber opted in or was reactivated
- `subscriber.unsubscribed` - Subscriber opted out
- `subscriber.bounced` - Subscriber email bounced
- `subscriber.complained` - Subscriber marked email as spam

### Subscriber List Events
- `subscribers.purged` - Every subscriber in the channel was removed by **Purge All Subscribers**. Sent once for the whole purge, not once per subscriber
- `subscribers.purge_failed` - A purge stopped before it finished. Some subscribers may already be gone; running the purge again is safe and removes the rest

### Broadcast Events
- `broadcast.scheduled` - Broadcast was handed to the sending job (**Send now**, or a scheduled send whose time came). Scheduling a broadcast for a later time sends no event
- `broadcast.queueing` - Broadcast is being queued for sending
- `broadcast.sending` - Broadcast is currently sending to recipients
- `broadcast.sent` - Broadcast completed successfully
- `broadcast.failed` - Broadcast failed completely
- `broadcast.partial_failure` - Broadcast partially failed (some recipients failed)
- `broadcast.aborted` - Broadcast was manually aborted
- `broadcast.paused` - Broadcast was paused
- `broadcast.batch_completed` - One batch run of a parallel broadcast finished, or stopped early. Only sent when parallel sending is on

### Sequence Events
- `sequence.subscriber_added` - Subscriber was enrolled in a sequence
- `sequence.subscriber_completed` - Subscriber finished all sequence steps
- `sequence.subscriber_moved` - Subscriber was moved to another sequence
- `sequence.subscriber_removed` - Subscriber was removed from sequence (deactivated, redacted, manually stopped, or deleted)
- `sequence.subscriber_paused` - Subscriber's sequence was paused
- `sequence.subscriber_resumed` - Subscriber's paused sequence was resumed
- `sequence.subscriber_error` - This subscriber's enrolment stopped being able to progress. Sent once, when the enrolment is marked as errored, with the reason in `error_message`. A single step failing to send is reported as `email.failed` instead: the enrolment carries on.

(Requires Broadcast 2.51 or later)

Two sequence events can come from the error recovery actions on the sequence subscriber list (see [Email Sequences](https://sendbroadcast.net/docs/email-sequences)):

- **Retry step** and **Retry all** send `sequence.subscriber_resumed` with `"reason": "retry"`. The enrolment was in error, not paused, so no `sequence.subscriber_paused` comes before it. A resume of a paused enrolment has no `reason`.
- **Dismiss all** sends `sequence.subscriber_removed` with `"reason": "dismissed"`. A manual stop still sends `"reason": "manual"`.

### System Events
- `message.attempt.exhausted` - All delivery attempts failed
- `test.webhook` - Test webhook event

## Setting Up Webhooks

To set up a webhook endpoint:

1. Go to **Webhooks** in your Broadcast dashboard sidebar
2. Click **New Endpoint**
3. Enter your webhook URL (HTTPS is recommended)
4. Select the event types you want to receive
5. Optionally add a description
6. Click **Create Webhook**

![Webhook endpoints list page showing configured endpoints](https://sendbroadcast.net/assets/docs/webhooks/webhooks-index-05e949aa.png)

### Creating a New Webhook Endpoint

Click the **New Endpoint** button to access the webhook creation form:

![Webhook endpoint creation form with URL, event types, and configuration options](https://sendbroadcast.net/assets/docs/webhooks/webhooks-new-form-e1aa6737.png)

### Configuration Options

**Maximum retry attempts** (default: 6)
- Broadcast stores this value, but delivery does not use it today
- Every endpoint follows the same fixed retry schedule, whatever the value (see [Delivery and Retries](#delivery-and-retries))

### Use HTTPS

Use an HTTPS URL for your webhook endpoint. Broadcast also accepts HTTP URLs, but then the payloads travel unencrypted.

## Webhook Payload Structure

All webhook payloads follow this structure:

```json
{
  "id": 123,
  "type": "email.sent",
  "created_at": "2024-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    // Event-specific data
  }
}
```

- `id`, `type` and `created_at` identify the event.
- `data` holds the event-specific fields shown in the examples below.
- `delivery` describes this delivery attempt. The signature covers it like the rest of the body.

### The delivery object

| Field | Meaning |
|-------|---------|
| `attempt` | The attempt number for this delivery. Retries advance it. Probes sent while the endpoint was down don't. |
| `delayed` | `true` when this delivery was held before being sent. |
| `held_since` | When the delivery was first held, or `null`. |
| `reason` | Why it was held: `endpoint_unavailable` (the endpoint was Delayed) or `queue_failure` (it couldn't be queued at first). `null` when it wasn't held. |

A delivery that was held and sent late looks like this:

```json
"delivery": { "attempt": 1, "delayed": true, "held_since": "2026-09-28T15:00:00Z", "reason": "endpoint_unavailable" }
```

Use `delayed` and `held_since` to tell a late event from a fresh one, for example before acting on an `email.opened` that happened hours ago.

### Email Event Payload Example

```json
{
  "id": 123,
  "type": "email.opened",
  "created_at": "2024-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "receipt_id": 456,
    "email": "subscriber@example.com",
    "identifier": "unique-email-id",
    "receipted_type": "Broadcast",
    "receipted_id": 12,
    "delivered": true,
    "opened": true,
    "opened_at": "2024-01-15T10:30:00Z",
    "clicked": false,
    "clicked_at": null,
    "bounced": false,
    "bounced_reason": null,
    "complaint": false,
    "complaint_reason": null,
    "unsubscribed": false
  }
}
```

### Subscriber Event Payload Example

**Subscriber Created Event:**

```json
{
  "id": 789,
  "type": "subscriber.created",
  "created_at": "2024-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "subscriber_id": 101,
    "email": "newuser@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "subscribed_at": "2024-01-15T10:30:00Z",
    "unsubscribed_at": null,
    "is_active": true,
    "custom_data": {
      "company": "Example Corp"
    },
    "source": "api"
  }
}
```

Every subscriber event has `change_source`: where the change came from. Values: `admin` (the Broadcast app), `api`, `opt_in_form`, `import`, `unsubscribe_link`, `one_click_unsubscribe` (the Gmail or Yahoo button), `preference_page`, or `system` (sequence steps and other background work).

**Subscriber Preferences Updated Event:**

A change to a topic value sends this event as well as `subscriber.updated`. `topics` lists each topic that changed, with `from` and `to`: `true`, `false`, or `null` for no value (a tag topic is `true` or `null`). Topic values given when a subscriber is created count as a change from `null`, so the sign-up consent is recorded too.

```json
{
  "id": 812,
  "type": "subscriber.preferences_updated",
  "created_at": "2026-10-06T14:03:11Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "subscriber_id": 101,
    "email": "newuser@example.com",
    "changed_at": "2026-10-06T14:03:11Z",
    "change_source": "preference_page",
    "topics": [
      { "key": "sub_webinars", "name": "Webinars", "storage": "custom_data", "from": true, "to": false },
      { "key": "sub_offers", "name": "Offers", "storage": "custom_data", "from": null, "to": true }
    ],
    "custom_data": { "sub_webinars": false, "sub_offers": true, "origin": "partner" },
    "first_name": "John",
    "last_name": "Doe",
    "subscribed_at": "2024-01-15T10:30:00Z",
    "unsubscribed_at": null,
    "is_active": true,
    "source": "api"
  }
}
```

**Subscriber Updated Event (a topic unsubscribe):**

```json
{
  "id": 811,
  "type": "subscriber.updated",
  "created_at": "2026-10-06T14:03:11Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "subscriber_id": 101,
    "email": "newuser@example.com",
    "change_source": "unsubscribe_link",
    "changes": {
      "custom_data": [ { "sub_webinars": true }, { "sub_webinars": false } ]
    },
    "custom_data": { "sub_webinars": false },
    "is_active": true,
    "source": "api"
  }
}
```

### Subscriber List Event Payload Examples

A purge sends one event for the whole list. The payload has counts, not the
removed email addresses.

**Purge Completed Event:**

```json
{
  "id": 812,
  "type": "subscribers.purged",
  "created_at": "2024-01-15T10:32:10Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "broadcast_channel_id": 5,
    "broadcast_channel_name": "Weekly Newsletter",
    "subscribers_deleted": 1520,
    "unsubscribed_emails_deleted": 48,
    "started_at": "2024-01-15T10:30:00Z",
    "completed_at": "2024-01-15T10:32:10Z"
  }
}
```

- `subscribers_deleted` - Subscribers removed by the purge
- `unsubscribed_emails_deleted` - Entries removed from the channel's unsubscribe (suppression) list

**Purge Failed Event:**

```json
{
  "id": 813,
  "type": "subscribers.purge_failed",
  "created_at": "2024-01-15T10:31:05Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "broadcast_channel_id": 5,
    "broadcast_channel_name": "Weekly Newsletter",
    "error": "ActiveRecord::LockWaitTimeout",
    "subscribers_remaining": 0,
    "unsubscribed_emails_remaining": 48,
    "started_at": "2024-01-15T10:30:00Z",
    "failed_at": "2024-01-15T10:31:05Z"
  }
}
```

- `error` - The kind of error that stopped the purge
- `subscribers_remaining` / `unsubscribed_emails_remaining` - What was still in the channel when the purge stopped. Running the purge again removes it

### Broadcast Event Payload Example

```json
{
  "id": 456,
  "type": "broadcast.sent",
  "created_at": "2024-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "broadcast_id": 123,
    "name": "Weekly Newsletter",
    "subject": "Your Weekly Update - January 2024",
    "status": "sent",
    "total_recipients": 1500,
    "broadcast_recipients_count": 1500,
    "percentage_complete": 100.0,
    "sent_at": "2024-01-15T10:30:00Z",
    "scheduled_send_at": "2024-01-15T10:00:00Z",
    "created_at": "2024-01-15T09:00:00Z",
    "updated_at": "2024-01-15T10:30:00Z",
    "broadcast_channel_id": 5,
    "segment_ids": [1, 2],
    "tags": ["newsletter", "weekly"]
  }
}
```

### Batch Completed Event Payload Example

When parallel sending is on (`PARALLEL_BROADCAST_SENDING=true`), a broadcast is split into batches of up to 500 recipients. Each batch run sends one `broadcast.batch_completed` event when it finishes the batch or stops early. A run stops early when the broadcast is halted by a channel error, paused, or the run fails. It also stops early when the email server reaches its hourly limit.

- `outcome` is `completed` when the batch finished, or `stopped` when this run ended before the batch was finished. A run stops when the broadcast was paused or halted, and resuming the broadcast sends the rest. A run also stops when the email server reached its hourly limit, and then the rest is sent automatically when the hour has room again. The run that sends the rest sends its own event
- `recipients` lists the recipients this run recorded, each with `status` `sent` or `failed`. A later run, after a resume or after a wait for room, doesn't repeat recipients that were already mailed, so each recipient appears in exactly one event
- `started_at` and `completed_at` are for this run

With parallel sending on (`PARALLEL_BROADCAST_SENDING=true`), broadcasts send `broadcast.batch_completed` instead of per-recipient `email.sent` and `email.failed` events. Sequential broadcasts (the default), sequences and transactional emails still send `email.sent` and `email.failed`. Delivery, open, click, bounce and complaint events are the same in both modes.

```json
{
  "id": 457,
  "type": "broadcast.batch_completed",
  "created_at": "2024-01-15T10:12:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "broadcast_id": 123,
    "broadcast_name": "Weekly Newsletter",
    "broadcast_subject": "Your Weekly Update - January 2024",
    "broadcast_channel_id": 5,
    "batch_id": 9041,
    "batch_number": 2,
    "total_batches": 3,
    "outcome": "completed",
    "sent_count": 499,
    "failed_count": 1,
    "recipients": [
      { "subscriber_id": 1001, "email": "jane@example.com", "status": "sent" },
      { "subscriber_id": 1002, "email": "sam@example.com", "status": "failed" }
    ],
    "started_at": "2024-01-15T10:10:30Z",
    "completed_at": "2024-01-15T10:12:00Z"
  }
}
```

The `recipients` array above is shortened. A full batch lists every recipient the run recorded.

### Exhausted Event Payload Example

When all delivery attempts fail, a `message.attempt.exhausted` event is sent with complete failure details:

```json
{
  "id": 789,
  "type": "message.attempt.exhausted",
  "created_at": "2025-01-15T16:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "original_event_id": 123,
    "original_event_type": "email.sent",
    "webhook_endpoint_id": 45,
    "total_attempts": 7,
    "first_attempt_at": "2025-01-15T10:30:00Z",
    "last_attempt_at": "2025-01-15T16:30:00Z",
    "final_error": "Connection timeout after 30 seconds",
    "final_response_status": 500,
    "original_event_data": {
      // Complete original event payload for recovery
    }
  }
}
```

## Event Context Examples

**Important**: `email.sent`, `email.failed` and `email.send_delayed` have different payload structures depending on the email source (broadcast, sequence, or transactional). The delivery, open, click, bounce and complaint events (`email.delivered`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained`) always have the same receipt structure, shown in the [Email Event Payload Example](#email-event-payload-example). Use its `receipted_type` (`Broadcast`, `SequenceStep` or `Transactional`) and `receipted_id` to tell the source.

### Sequence Email Events

When emails are sent from automated sequences, the payload includes sequence context:

**Sequence Email Sent:**

```json
{
  "id": 123,
  "type": "email.sent",
  "created_at": "2025-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "sequence_id": 45,
    "sequence_name": "Welcome Series",
    "sequence_step_id": 67,
    "sequence_step_label": "Welcome Email 1",
    "sequence_step_action": "send_email",
    "subscriber_id": 89,
    "subscriber_email": "user@example.com",
    "subscriber_sequence_id": 12,
    "broadcast_channel_id": 1,
    "sent_at": "2025-01-15T10:30:00Z"
  }
}
```

**Sequence Email Opened:**

The receipt structure, with `receipted_type` set to `SequenceStep` and `receipted_id` set to the step's ID:

```json
{
  "id": 124,
  "type": "email.opened",
  "created_at": "2025-01-15T11:15:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "receipt_id": 789,
    "email": "user@example.com",
    "identifier": "unique-email-id",
    "receipted_type": "SequenceStep",
    "receipted_id": 67,
    "delivered": true,
    "opened": true,
    "opened_at": "2025-01-15T11:15:00Z",
    "clicked": false,
    "clicked_at": null,
    "bounced": false,
    "bounced_reason": null,
    "complaint": false,
    "complaint_reason": null,
    "unsubscribed": false
  }
}
```

### Transactional Email Events

Transactional emails include different context fields:

**Transactional Email Sent:**

```json
{
  "id": 456,
  "type": "email.sent",
  "created_at": "2025-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "transactional_id": 78,
    "recipient_email": "customer@example.com",
    "recipient_name": "John Doe",
    "subject": "Order Confirmation",
    "broadcast_channel_id": 1,
    "subscriber_id": 123,
    "status": "sent",
    "sent_at": "2025-01-15T10:30:00Z"
  }
}
```

**Transactional Email Opened:**

The receipt structure, with `receipted_type` set to `Transactional` and `receipted_id` set to the transactional email's ID:

```json
{
  "id": 457,
  "type": "email.opened",
  "created_at": "2025-01-15T11:00:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "receipt_id": 101,
    "email": "customer@example.com",
    "identifier": "unique-email-id",
    "receipted_type": "Transactional",
    "receipted_id": 78,
    "delivered": true,
    "opened": true,
    "opened_at": "2025-01-15T11:00:00Z",
    "clicked": false,
    "clicked_at": null,
    "bounced": false,
    "bounced_reason": null,
    "complaint": false,
    "complaint_reason": null,
    "unsubscribed": false
  }
}
```

**Transactional Email Send Delayed:**

```json
{
  "id": 458,
  "type": "email.send_delayed",
  "created_at": "2025-01-15T10:30:00Z",
  "delivery": { "attempt": 1, "delayed": false, "held_since": null, "reason": null },
  "data": {
    "transactional_id": 78,
    "recipient_email": "customer@example.com",
    "recipient_name": "John Doe",
    "subject": "Your login code",
    "broadcast_channel_id": 1,
    "subscriber_id": 123,
    "status": "queued",
    "email_server_id": 4,
    "email_server_label": "Transactional SMTP",
    "emails_per_hour": 200
  }
}
```

The email has not been sent yet, so the event has no `sent_at`. `email_server_id`, `email_server_label` and `emails_per_hour` name the server that is at its hourly limit.

### Context-Aware Handler Example

Here's how to handle different email contexts in a single webhook handler:

```javascript
function handleEmailOpened(event) {
  const data = event.data;

  // Determine email context from the receipt's source
  if (data.receipted_type === 'SequenceStep') {
    // Handle sequence email opened
    analytics.track('sequence_email_opened', {
      sequence_step_id: data.receipted_id,
      email: data.email
    });

    // Update sequence engagement metrics
    updateSequenceEngagement(data.receipted_id, data.email);

  } else if (data.receipted_type === 'Transactional') {
    // Handle transactional email opened
    analytics.track('transactional_email_opened', {
      transactional_id: data.receipted_id,
      email: data.email
    });

    // Update order/transaction status
    updateTransactionEngagement(data.receipted_id);

  } else if (data.receipted_type === 'Broadcast') {
    // Handle broadcast email opened
    analytics.track('broadcast_email_opened', {
      broadcast_id: data.receipted_id,
      receipt_id: data.receipt_id,
      email: data.email
    });

    // Update broadcast analytics
    updateBroadcastEngagement(data.receipted_id);

  } else {
    console.warn('Unknown email.opened context', data);
  }
}
```

## Webhook Security

Broadcast signs all webhook payloads using HMAC-SHA256 to ensure authenticity. You should verify the signature before processing webhook events.

### Signature Verification

Each webhook request includes these headers:

- `Content-Type: application/json` - Request content type
- `User-Agent: Broadcast-Webhooks/1.0` - User agent identifier
- `broadcast-webhook-id` - Unique identifier for the webhook event
- `broadcast-webhook-timestamp` - Unix timestamp when the webhook was sent
- `broadcast-webhook-signature` - HMAC signature for verification

The signature is calculated using:

```
HMAC-SHA256(timestamp + "." + payload, webhook_secret)
```

### Example Verification (Node.js)

```javascript
const crypto = require('crypto');

function verifyWebhook(payload, headers, secret) {
  const signature = headers['broadcast-webhook-signature'];
  const timestamp = headers['broadcast-webhook-timestamp'];

  // Check timestamp to prevent replay attacks (within 5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) {
    return false;
  }

  // Calculate expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('base64');

  // Extract signature from header (format: "v1,signature")
  const actualSignature = signature.split(',')[1];

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(actualSignature)
  );
}
```

## Delivery and Retries

Broadcast implements a robust retry mechanism for webhook deliveries:

- **Retry Schedule**: 6 attempts in total. After the first attempt fails, Broadcast tries again after 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours (each delay counts from the previous attempt)
- **Success Criteria**: HTTP status codes 200-299
- **Timeout**: 30 seconds per request
- **Retries per Endpoint**: The schedule is the same for every endpoint. The **Maximum retry attempts** value is stored, but it does not change the schedule
- **Exhausted Events**: After all attempts fail, a `message.attempt.exhausted` event is sent. Its `total_attempts` reads 7: the attempt counter is one higher than the 6 attempts made

Webhooks never hold up sending. If Broadcast can't queue a webhook, the email still goes out and the event is held and sent later. In the rare case it can't record the event at all, the email still goes out, the event is not delivered, and the failure is logged and shown on the Webhooks page. An event that was queued but never got sent, for example because the job was lost, is queued again automatically after about 30 minutes.

### When an Endpoint Stops Responding

After 5 failed deliveries in a row, across any events, Broadcast marks the endpoint **Delayed**. While an endpoint is delayed:

- Broadcast stops sending to it. New events and retries for that endpoint are held, and held events don't use up their retries.
- It sends one probe, using the held event that was tried least recently (events that were never tried go first, then the oldest), after 1 minute, then after 5 minutes, then 15 minutes, then every hour. So if your endpoint rejects one particular event, later probes try other events and the endpoint can still recover.
- Changing the endpoint's URL, in the dashboard or through the API, clears its Delayed state and failure count. Held events start going out within about a minute instead of waiting for the next probe.
- Any successful delivery marks the endpoint healthy again. So does a successful **Send Test** from the endpoint's page. A failed test doesn't count against it.
- Once the endpoint is healthy, held events are sent oldest first, at up to 10 per second (600 a minute), so an endpoint that just came back isn't flooded.
- Held events older than 3 days are given up on, and a `message.attempt.exhausted` event is sent for each one. The endpoint keeps being probed every hour, so new events flow again as soon as it responds.
- Inactive endpoints are never probed. Their held events are given up after 3 days.

### Monitoring Webhook Deliveries

You can monitor webhook delivery status in the dashboard:

1. Go to **Webhooks**
2. Click on your webhook endpoint
3. View delivery logs, success rates, and error messages
4. Check recent deliveries and their response codes

![Webhook endpoint configuration page showing details and recent deliveries](https://sendbroadcast.net/assets/docs/webhooks/webhooks-endpoint-detail-7bef970d.png)

### Endpoint Health and Queued Deliveries

The **Webhooks** page shows the health of every endpoint:

- A **Health** column shows a **Healthy** or **Delayed** badge for each endpoint. The same badge appears on the endpoint's own page.
- When an active endpoint is delayed, a banner reads "Webhooks are delayed: some endpoints are not responding". It lists each delayed endpoint with how long it has not responded and when the next probe runs, for example "Not responding since 2 hours ago. Next probe in 40 minutes." Their events are held, not lost, and are sent once the endpoint responds. Held events are given up after 3 days.
- When events could not be recorded in the last 24 hours, a red note says so, for example "2 webhook events could not be recorded in the last 24 hours. They will not be delivered." Users who can see error logs get a **View error logs** link that opens them filtered to webhook errors.
- A **Queued deliveries** panel shows how many deliveries are waiting and lists the next 20, with the event, endpoint, when it was created and its next attempt. The next attempt reads "Now", "in" a length of time, or "Held until the endpoint responds". When more are waiting it says "Showing the next 20 of" the total. When nothing is waiting it says "No deliveries waiting".

### Viewing Delivery Details

Click on any delivery in the **Recent Webhook Deliveries** section to see comprehensive details including the event payload, HTTP response, and retry information:

![Webhook delivery detail dialog showing status, response data, and event payload](https://sendbroadcast.net/assets/docs/webhooks/webhooks-delivery-detail-d380c25d.png)

## Testing Webhooks

Broadcast provides several ways to test your webhook endpoints:

### Send Test Button
Click the **Send Test** button on any webhook endpoint to send a test event immediately.

### Using webhook.site
For development, you can use [webhook.site](https://webhook.site) to inspect webhook payloads:

1. Go to webhook.site and copy the unique URL
2. Create a webhook endpoint with this URL
3. Trigger events in Broadcast to see the payloads

### Local Development with ngrok
For local testing, use [ngrok](https://ngrok.com) to create a secure tunnel:

```bash
# Start your local server
node server.js

# In another terminal, create a tunnel
ngrok http 3000

# Use the HTTPS URL from ngrok in your webhook endpoint
```

## Best Practices

### Handling Webhook Events

1. **Respond Quickly**: Return a 200 status code within 30 seconds
2. **Process Asynchronously**: Queue webhook events for background processing
3. **Handle Duplicates**: Events may be sent multiple times; implement idempotency
4. **Validate Signatures**: Always verify webhook signatures for security
5. **Log Events**: Keep logs of webhook events for troubleshooting

### Example Handler (Express.js)

```javascript
app.post('/webhooks/broadcast', express.raw({type: 'application/json'}), (req, res) => {
  const signature = req.headers['broadcast-webhook-signature'];
  const timestamp = req.headers['broadcast-webhook-timestamp'];
  const payload = req.body;

  // Verify signature first
  if (!verifyWebhook(payload, req.headers, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Parse the payload
  const event = JSON.parse(payload);

  // Handle different event types
  switch (event.type) {
    case 'email.opened':
      handleEmailOpened(event.data);
      break;
    case 'subscriber.created':
      handleNewSubscriber(event.data);
      break;
    case 'broadcast.sent':
      handleBroadcastSent(event.data);
      break;
    case 'broadcast.failed':
      handleBroadcastFailed(event.data);
      break;
    default:
      console.log('Unknown event type:', event.type);
  }

  // Queue for background processing
  queue.add('process-webhook', event);

  // Respond immediately
  res.status(200).send('OK');
});

// Example broadcast event handlers
function handleBroadcastSent(data) {
  console.log(`Broadcast "${data.name}" sent successfully to ${data.total_recipients} recipients`);

  // Update your analytics dashboard
  analytics.track('broadcast_completed', {
    broadcast_id: data.broadcast_id,
    subject: data.subject,
    recipient_count: data.total_recipients,
    completion_rate: data.percentage_complete
  });

  // Sync with your CRM
  crm.updateCampaignStatus(data.broadcast_id, 'completed');
}

function handleBroadcastFailed(data) {
  console.log(`Broadcast "${data.name}" failed`);

  // Alert your team
  slack.notify(`🚨 Broadcast failed: ${data.name} (ID: ${data.broadcast_id})`);

  // Log for investigation
  logger.error('Broadcast failed', { broadcast_id: data.broadcast_id, status: data.status });
}
```

### Error Handling

If your webhook endpoint returns an error status code (4xx or 5xx), Broadcast will retry the delivery according to the retry schedule. To prevent retries for permanent failures, return a 200 status code but handle the error in your application logic.

## Troubleshooting

### Common Issues

**Webhook not receiving events**
- Check that your endpoint URL is correct and accessible
- Verify the endpoint is configured for the correct event types
- Check firewall settings and SSL certificate validity

**Endpoint shows as Delayed**
- Your endpoint failed 5 deliveries in a row, so Broadcast is holding its events and probing it on a schedule (see [When an Endpoint Stops Responding](#when-an-endpoint-stops-responding))
- Fix the endpoint first: check that it's up, returns a 2xx status, and answers within 30 seconds
- Click **Send Test** on the endpoint's page to mark it healthy right away instead of waiting for the next probe. Held events are then sent, oldest first
- Held events older than 3 days are given up on, so fix the endpoint within that window to receive everything

**Signature verification failing**
- Ensure you're using the correct webhook secret
- Check that you're calculating the signature correctly
- Verify the timestamp is within the 5-minute window

**High failure rates**
- Check your endpoint's response time (must be under 30 seconds)
- Verify your server can handle the webhook volume
- Review error logs for specific failure reasons

**Payload structure issues**
- `email.sent`, `email.failed` and `email.send_delayed` have different payload structures for each source (broadcast, sequence, transactional)
- Delivery, open, click, bounce and complaint events always have the receipt structure: check `receipted_type` (`Broadcast`, `SequenceStep` or `Transactional`) and `receipted_id`
- Use the context-aware handler pattern to handle different payload structures
- Log unexpected payload structures for investigation

### Performance Considerations

**High-Volume Webhook Processing**
- Process webhooks asynchronously using background queues
- Implement connection pooling for database operations
- Use batch processing for multiple webhook events
- Cache frequently accessed data (subscriber info, configuration)
- Monitor endpoint response times and optimize accordingly

**Recommended Architecture**

```javascript
// Receive webhook quickly, queue for processing
app.post('/webhooks/broadcast', (req, res) => {
  // Verify signature
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  // Queue immediately, respond fast
  queue.add('webhook-processor', {
    payload: req.body,
    timestamp: Date.now()
  });

  res.status(200).send('OK');
});

// Process in background worker
queue.process('webhook-processor', async (job) => {
  const event = job.data.payload;
  await processWebhookEvent(event);
});
```

### Getting Help

If you're experiencing issues with webhooks:

1. Check the delivery logs in your webhook endpoint dashboard
2. Review the error messages and response codes
3. Test your endpoint with the built-in test webhook feature
4. Contact support at [simon@sendbroadcast.net](mailto:simon@sendbroadcast.net) with your webhook endpoint details
