> ## Documentation Index
> Fetch the complete documentation index at: https://docs.instantcampaign.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get an HTTP POST on your server when contacts, campaigns, emails, events, forms or templates change, and verify each delivery's signature.

A webhook sends a JSON `POST` to a URL you choose each time a selected event happens in your workspace, such as a contact unsubscribing, an email bouncing or a form being submitted. Use webhooks to keep a CRM or data warehouse in sync, or to trigger your own workflows.

## Add a webhook

<Note>
  Only workspace **Owners** and **Admins** can add, test, turn off or delete webhooks. Other members who can open the page see the list and each webhook's status.
</Note>

<Steps>
  <Step title="Open Webhooks">
    Go to **Settings → Developers → Webhooks** and click **Add Webhook**.
  </Step>

  <Step title="Enter the endpoint">
    Enter the **Endpoint URL**. It must start with `https://`, and it must be publicly reachable. Private and internal addresses are refused.
  </Step>

  <Step title="Set a signing secret">
    Enter a **Signing Secret**. It is optional, but without one deliveries are not signed and you cannot verify where they came from.
  </Step>

  <Step title="Pick the events">
    Tick at least one event under **Events**, then click **Add Webhook**.
  </Step>

  <Step title="Send a test">
    Click **Test** on the webhook's row. A test delivery with `"event": "test"` is sent, and the result appears in the delivery history below the row.
  </Step>
</Steps>

## Events

| Group            | Event                         | Sent when                                                                                                                     |
| ---------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Contacts         | `CONTACT_CREATED`             | A contact was created (dashboard, import, API, form, transactional send).                                                     |
|                  | `CONTACT_UPDATED`             | A contact's fields changed.                                                                                                   |
|                  | `CONTACT_DELETED`             | A contact was deleted.                                                                                                        |
|                  | `CONTACT_CONFIRMED`           | A pending contact clicked the double opt-in confirmation link. Sent only when the contact itself goes from pending to active. |
|                  | `CONTACT_UNSUBSCRIBED`        | A contact unsubscribed (link, one-click header, provider, API).                                                               |
| Lists            | `CONTACT_ADDED_TO_LIST`       | A contact was subscribed to a list.                                                                                           |
|                  | `CONTACT_REMOVED_FROM_LIST`   | A contact was removed from a list.                                                                                            |
|                  | `LIST_SUBSCRIPTION_CONFIRMED` | A contact clicked a double opt-in confirmation link and a pending list subscription became active. Sent once per list.        |
| Campaigns        | `CAMPAIGN_SENT`               | A campaign send finished dispatching.                                                                                         |
|                  | `CAMPAIGN_COMPLETED`          | A campaign send completed with final counts.                                                                                  |
|                  | `TRANSACTIONAL_SENT`          | A transactional API message was accepted by the provider.                                                                     |
| Email delivery   | `EMAIL_DELIVERED`             | The provider confirmed delivery to the recipient's mailbox.                                                                   |
|                  | `EMAIL_BOUNCED`               | A hard or soft bounce was reported.                                                                                           |
|                  | `EMAIL_COMPLAINED`            | The recipient marked the email as spam.                                                                                       |
| Email engagement | `EMAIL_OPENED`                | A tracked open (bots excluded).                                                                                               |
|                  | `EMAIL_CLICKED`               | A tracked link click.                                                                                                         |
| Events           | `EVENT_RECORDED`              | A custom event or goal was recorded for a contact.                                                                            |
|                  | `FORM_SUBMITTED`              | A subscription form was submitted.                                                                                            |
| Templates        | `TEMPLATE_CREATED`            | A template was created.                                                                                                       |
|                  | `TEMPLATE_EXPORTED`           | A template was exported.                                                                                                      |
| Other            | `TEST_EMAIL_SENT`             | A test send from the editor.                                                                                                  |

### Double opt-in confirmations

When someone clicks a double opt-in confirmation link, you can receive two kinds of event:

* `LIST_SUBSCRIPTION_CONFIRMED` is sent once for **each list** whose pending subscription the click activated. It is sent for a brand-new signup and for an existing, already-active contact who joined another double opt-in list. `data` holds `contactId`, `email`, `listId` and `listName`.
* `CONTACT_CONFIRMED` is sent only when the **contact** was pending and is now active, so an already-active contact confirming a new list does not send it. `data` holds `contactId` and `email`.

A new signup who confirms one list therefore sends one `CONTACT_CONFIRMED` and one `LIST_SUBSCRIPTION_CONFIRMED`. To track list subscriptions, subscribe to `LIST_SUBSCRIPTION_CONFIRMED`.

The same catalog is available from the API for integrations that build their own event picker. See the [API reference](/api-reference/introduction).

## What a delivery looks like

Every delivery has the same envelope. The event-specific fields are in `data`:

```json theme={null}
{
  "id": "msg_2mVb0x…",
  "event": "EMAIL_BOUNCED",
  "timestamp": "2026-09-05T10:00:00.000Z",
  "teamId": "…",
  "data": { "email": "jane@example.com", "messageId": "…", "type": "Permanent", "reason": "550" }
}
```

Each request also carries these headers:

| Header              | Value                                                                                                                       |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | The message id (same as `id` in the body). It stays the same on every retry, so use it to ignore duplicates.                |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds.                                                                                |
| `webhook-signature` | `v1,<signature>` ([Standard Webhooks](https://www.standardwebhooks.com/)). Only sent when the webhook has a signing secret. |
| `X-Signature-256`   | `sha256=<hex>`, a legacy signature of the body alone. Only sent when the webhook has a signing secret.                      |
| `X-Webhook-Event`   | The event type.                                                                                                             |
| `X-Webhook-Attempt` | The attempt number, starting at 1.                                                                                          |

## Verify the signature

Deliveries follow the Standard Webhooks spec, so any Standard Webhooks library can verify them with your signing secret. To verify by hand, compute an HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}` and compare it with the value after `v1,`:

```js theme={null}
import crypto from "node:crypto";

function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"]; // "v1,<base64>"

  // Reject old deliveries to prevent replays (5 minutes here).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  // A secret starting with "whsec_" is base64; any other secret is used as-is.
  const key = secret.startsWith("whsec_")
    ? Buffer.from(secret.slice(6), "base64")
    : Buffer.from(secret, "utf8");

  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  const received = signature.split(",")[1] ?? "";
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
  );
}
```

<Warning>
  Verify against the **raw** request body, exactly as received. If you parse the JSON and serialise it again, the signature will not match.
</Warning>

If your code already checks `X-Signature-256`, it keeps working: that header is `sha256=` followed by the hex HMAC-SHA256 of the raw body, keyed with the secret as-is.

## Retries and automatic disabling

Respond with any `2xx` status within 10 seconds to acknowledge a delivery. Anything else counts as a failure and is retried:

* Three quick attempts, 1 and 2 seconds apart.
* Then seven more after 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours, 8 hours and 10 hours. That makes 10 attempts over about 34 hours.
* `410 Gone` retires the webhook at once, and no more attempts are made.
* `429 Too Many Requests` backs off without counting as a failure.

If a webhook keeps failing for more than 12 hours, it is turned off and its pending retries are dropped. While a webhook is off, new events are not queued for it.

Each webhook row shows its status: **Active**, **Failing since** a date, **Disabled after 12h of failures**, **Retired (endpoint answered 410)** or **Inactive**. Owners and Admins also get an on/off switch; next to it they see the status only when something is wrong (failing, or turned off automatically), since the switch already shows on or off. Turning a webhook back on clears its failure history.

## Delivery history

Click the arrow on a webhook row to see its most recent deliveries. Each one shows whether it succeeded, the event, the HTTP status, the response time and when it was sent. **Delete** on a webhook also deletes its delivery history.

## REST hooks for Zapier and Make

Integration platforms can subscribe and unsubscribe themselves through the API with a key that has the **Webhooks — manage** scope. These subscriptions receive the same JSON body and the `webhook-id` and `webhook-timestamp` headers. They are not signed, but failed deliveries are retried on the same schedule as your endpoints. They do not appear on the **Webhooks** page. See the [API reference](/api-reference/introduction).

## Troubleshooting

| Symptom                           | Cause                                                                                                         |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Webhook URL must use HTTPS.**   | The URL starts with `http://`.                                                                                |
| "Invalid webhook URL" when saving | The URL points to a private or internal address.                                                              |
| No `webhook-signature` header     | The webhook has no signing secret. Delete it and add it again with one.                                       |
| Signature never matches           | You are hashing a re-serialised body, or your secret starts with `whsec_` and you are not base64-decoding it. |
| Same event received twice         | A retry after your endpoint timed out or returned a non-2xx status. De-duplicate on `webhook-id`.             |
