# Guide to Iterable Webhooks: Features and Best Practices

Iterable webhooks stream what happens to your messages and users. Every email sent, opened, clicked, bounced or marked as spam, every push, SMS, in-app or WhatsApp message delivered, and every subscribe or unsubscribe can be sent to your own endpoint as it happens. Teams use them to feed a data warehouse, trigger downstream workflows, keep suppression lists in sync and build their own engagement reporting.

This guide covers how system webhooks are set up and authenticated, the events they can carry and the shape of their payloads, the tight timeout and narrow retry rules that decide whether an event survives, and the practices that make an Iterable receiver dependable.

## What are Iterable webhooks?

Iterable has two kinds of webhook. A **journey webhook** is a tile in a journey that calls a URL with a body you compose. A **system webhook**, the subject of this guide, is project-wide: you pick a set of triggering events, and Iterable sends a `POST` every time one of them happens anywhere in the project, whichever campaign, template or journey caused it.

Each request is a JSON body with the user's `email` and/or `userId`, an `eventName`, and a `dataFields` object with the event's details.

Iterable does not sign system webhooks. Instead it offers an **Authentication** setting that puts a static value in the `Authorization` header: **Basic**, which sends exactly the token you enter, or **OAuth 2.0**, which sends `Bearer <token>`. Comparing that header with the value you configured is how you tell a real Iterable request from a forged one.

## Iterable webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Integrations** > **System Webhooks** (Manage Integrations permission or org admin) |
| Scope | One project. Not tied to a template, campaign or journey |
| Initial status | Disabled until you set **Status** to **Enabled** |
| Method and format | `POST`, `Content-Type: application/json` |
| Endpoint | HTTPS with a certificate signed by a CA Iterable trusts |
| Authentication | None, Basic (your token verbatim in `Authorization`) or OAuth 2.0 (`Authorization: Bearer <token>`) |
| Custom headers | **Add HTTP Header**, name and value. Visible to anyone with Manage Integrations |
| Signature and timestamp | None |
| Event field | `eventName` in the body |
| Timeout | 2 seconds |
| Retries | Up to 3, with increasing delays over a few minutes, only for 429, 502, 503, 504 and timeouts |
| Not retried | Other 4xx and 5xx, DNS errors, connection errors and TLS failures |
| Guarantees | Delivery and order are not guaranteed, and duplicates are possible |
| Circuit breaker | At least 500 errors and a 90% error rate in 10 seconds pauses delivery for 60 seconds |
| Merge tags | In the URL and custom headers, for example `{{campaignId}}` |
| Source IPs | Not published |

## Common events

| Channel | Events |
| --- | --- |
| Email | `emailSend` (blast and triggered), `emailClick`, `emailOpen`, `emailSubscribe`, `emailUnSubscribe`, `hostedUnsubscribeClick`, `emailBounce`, `emailComplaint`, `emailSendSkip` |
| Embedded | `embeddedClick`, `embeddedImpression`, `embeddedReceived`, `embeddedSession` |
| In-app | `inAppClick`, `inAppClose`, `inAppDelete`, `inAppDelivery`, `inAppOpen`, `inAppRecall`, `inAppSend`, `inAppSendSkip` |
| Push | `pushSend`, `pushOpen`, `pushBounce`, `pushUninstall`, `pushSendSkip` |
| RCS | `rcsSend`, `rcsSendSkip`, `rcsBounce`, `rcsSeen`, `rcsClick` |
| SMS | `smsSend`, `smsClick`, `smsReceived`, `smsBounce`, `smsSendSkip` |
| Web push | `webPushSend`, `webPushClick`, `webPushSendSkip` |
| WhatsApp | `whatsAppSend`, `whatsAppReceived`, `whatsAppBounce`, `whatsAppSeen`, `whatsAppClick`, `whatsAppSendSkip` |
| Other | `anonSession`, `unknownSession`, `journeyExit` |

`emailSubscribe` and `emailUnSubscribe` are universal: they cover subscription changes on every channel, not just email. Iterable spells the unsubscribe event `emailUnSubscribe` in its event list and `emailUnsubscribe` elsewhere in the same article, so match it case-insensitively until you have seen one on the wire. Blast and triggered email sends share `emailSend`; `dataFields.campaignId` and `dataFields.workflowId` tell them apart.

Here is Iterable's blast `emailSend` example, trimmed:

```json
{
  "email": "user@example.com",
  "eventName": "emailSend",
  "dataFields": {
    "contentId": 331201,
    "email": "user@example.com",
    "createdAt": "2016-12-02 20:21:04 +00:00",
    "campaignId": 59667,
    "templateId": 93849,
    "messageId": "9c438a20d26e4e9f8ad4edea23f811cd",
    "emailSubject": "My subject",
    "campaignName": "My campaign name",
    "workflowId": null,
    "channelId": 3420,
    "messageTypeId": 3866,
    "emailId": "c123456:t456789:user@example.com"
  }
}

```

Whether `email`, `userId` or both appear depends on your project type and the user's profile. Iterable is explicit that its examples are not schemas: it can add fields at any time, and the fields vary with the event, your webhook settings and your project, so test with real data.

> **Inspect Iterable webhooks as they arrive.** Point a system webhook at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the exact headers and `dataFields` your project sends, with no account or setup required.

## Setting up Iterable webhooks

Generate a long random token first, for example with `openssl rand -hex 32`.

1. Go to **Integrations** > **System Webhooks** and click **Create Webhook**.
2. Enter your `https://` URL and click **Create**. Iterable opens the **Edit System Webhook** screen.
3. Under **Authentication**, choose **OAuth 2.0** and enter only the token, without `Bearer `. Iterable sends `Authorization: Bearer <token>`. Or choose **Basic** and enter the full value you want sent, such as `Basic <token>`: Iterable adds no prefix and does no encoding.
4. Under triggering events, select only the events you need. Turn on **Exclude List Update Events** unless you want subscribe and unsubscribe events from list uploads and list API calls.
5. Set **Status** to **Enabled** and click **Save Webhook**.

The token is hidden once saved, so store it somewhere safe before you save. Changes to an existing webhook take a little while to propagate, and Iterable may keep sending to an old URL for a short time after you change it.

To route through Hookdeck, create a Hookdeck source with the **Iterable** type and use its URL in step 2. The source has one field, **Authorization Header**, and it needs the full value Iterable sends, prefix included: `Bearer <token>` for OAuth 2.0, or exactly what you typed for Basic. The source compares the whole value, so it checks the **Authentication** setting, not a custom header. It accepts `POST`.

## Securing Iterable webhooks

Compare the whole `Authorization` header with the value Iterable sends, in constant time. Do not strip the `Bearer ` or `Basic ` prefix and do not decode the value.

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

// "Bearer <token>" for OAuth 2.0, or exactly what you typed for Basic.
const EXPECTED = process.env.ITERABLE_AUTHORIZATION;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/iterable', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('authorization');
  if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Put the event on a queue and respond: Iterable times out after 2 seconds.
  // A worker then routes on event.eventName.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

EXPECTED = os.environ.get("ITERABLE_AUTHORIZATION", "")  # e.g. "Bearer 3f9c..."

def verify_iterable_request(headers) -> bool:
    if not EXPECTED:
        return False  # never accept when unconfigured
    received = headers.get("authorization")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), EXPECTED.encode("utf-8"))

```

A static `Authorization` value authenticates the sender, not the body. It cannot detect a modified payload, and with no timestamp a captured request can be replayed and still pass. Use HTTPS, which Iterable requires anyway, a long random token, and rotate it if it might have leaked. Prefer the **Authentication** setting over a custom header for the secret: Iterable hides the token after saving, while custom headers stay visible to anyone with the Manage Integrations permission.

> **Make Iterable webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `Authorization` value you configure, answers well inside Iterable's 2-second timeout, and queues every engagement event durably for your service.

## Iterable webhook limitations and pain points

### Two seconds to respond

**The Problem:** A handler that writes to a database or calls another API before responding times out, and Iterable retries an event you may already have processed.

**Why It Happens:** Iterable gives each request 2 seconds. A slower response counts as a failure even if your endpoint finishes the work afterwards, and the retry arrives as a duplicate.

**Workarounds:**

* Do nothing in the request path except check the header, enqueue the event and return `200`.
* Deduplicate, because a timeout followed by a retry is the normal way duplicates happen.

**How Hookdeck Can Help:** Hookdeck accepts the request and responds to Iterable straight away, then delivers to your service with a [delivery timeout](/docs/destinations#set-a-delivery-timeout) you choose, so slow processing no longer causes Iterable retries.

### Most failures are never retried

**The Problem:** A `500` from a bug, a `401` from a rotated token, or a DNS or TLS problem loses the event outright.

**Why It Happens:** Iterable retries only `429`, `502`, `503`, `504` and timeouts, at most three times over a few minutes. Every other error drops the event, and Iterable says plainly that system webhooks are not a definitive data export.

**Workarounds:**

* Return `503` rather than `500` for transient failures in your own code.
* Use Iterable's export APIs or Data Sync for anything that must be complete, and treat webhooks as the fast path.

**How Hookdeck Can Help:** Hookdeck stores every request it accepts and delivers it to your service with [retries](/docs/retries) on your schedule, whatever your service returns, and you can replay any event after a fix.

### The circuit breaker pauses everything

**The Problem:** A burst of errors from your endpoint pauses all delivery for a minute, including events unrelated to the failure.

**Why It Happens:** When an endpoint returns at least 500 errors with an error rate of 90% or more within 10 seconds, Iterable pauses delivery to it for 60 seconds.

**Workarounds:**

* Keep the webhook endpoint thin and separate from the code that does the work.
* Monitor your endpoint's error rate and alert on it, as Iterable recommends.

**How Hookdeck Can Help:** Hookdeck's ingestion stays up when your service does not, so Iterable never sees your error rate. [Issues](/docs/issues) alert you to failing deliveries, and a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) protects your service during bursts.

### Basic is not HTTP Basic

**The Problem:** Every request fails your check, or passes a check you thought was stricter, because the header is not what you expected.

**Why It Happens:** With **Basic**, Iterable sends exactly the token you type, with no `Basic ` prefix unless you type it and no base64 encoding unless you encode it yourself. With **OAuth 2.0**, it adds `Bearer `. The token is hidden after saving, so you cannot look it up later.

**Workarounds:**

* Decide on the full header value before you save, store it, and compare against it whole.
* If you lose the token, set a new one rather than guessing.

**How Hookdeck Can Help:** Hookdeck's Iterable source compares the full `Authorization` value you paste. Hookdeck answers with a `200` either way, but a request with a missing or wrong value is recorded as failed verification and never reaches your service, and you can see exactly what Iterable sent.

### List updates flood the endpoint

**The Problem:** A CSV upload or a bulk list API call produces a burst of `emailSubscribe` and `emailUnSubscribe` events that has nothing to do with user behaviour.

**Why It Happens:** Adding users to or removing them from lists records subscribe and unsubscribe events in their history, and those trigger system webhooks by default.

**Workarounds:**

* Turn on **Exclude List Update Events** so only user-initiated subscription changes are sent.
* Select only the triggering events you use.

**How Hookdeck Can Help:** Hookdeck absorbs the burst and delivers at a rate your service can handle, and [filters](/docs/filters) can drop event types you do not need before they reach you.

## Best practices

### Store the full Authorization value

Keep `Bearer <token>`, or your exact Basic value, as one string and compare it whole. Use the **Authentication** setting for the secret rather than a custom header.

### Deduplicate on the event, not the request

Iterable advises using the event timestamp to deduplicate. A key such as `eventName` plus `dataFields.messageId` (where present) plus `dataFields.createdAt` and the user identifier works across retries. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Do not depend on order

Events can arrive out of order, so an `emailOpen` may arrive before its `emailSend`. Store events and derive state from their timestamps rather than from arrival order.

### Treat payloads as open-ended

Iterable can add fields at any time and its examples are not schemas. Read the fields you need, ignore the rest, and do not reject unknown fields.

### Acknowledge fast and process asynchronously

With a 2-second timeout, asynchronous processing is not optional. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Iterable system webhooks send every selected engagement event in a project as an unsigned JSON `POST`, authenticated only by a static `Authorization` value from the Basic or OAuth 2.0 setting. Compare that full value in constant time, respond inside 2 seconds, deduplicate on the event, and remember that most failures are dropped after at most three retries.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, responds to Iterable immediately, and keeps every event until your service has processed it, so a slow handler or a short outage stops costing you engagement data.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Iterable webhooks reliably in minutes.