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

# Webhooks

> Subscribe to real-time WhatsApp events on your own HTTPS endpoint.

Outbound webhooks push events to a URL you control, so you don't have to poll the
REST API for new activity. Register an endpoint once and iZap posts each
subscribed event to it as it happens — the direction of [Analytics MCP](/en/api-reference/mcp)
and the REST API is you calling iZap; this is iZap calling you.

<Note>
  Looking for the *inbound* side — Meta's Cloud API webhook that delivers
  WhatsApp messages to iZap? That's internal to iZap's own WhatsApp connection.
  What's on this page is iZap's own outbound delivery to **your** endpoint.
</Note>

## Register an endpoint

```bash theme={null}
curl -X POST "https://api.izap.ai/api/v1/webhook-endpoints/{business_id}" \
     -H "Authorization: Bearer $IZAP_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{
           "label": "ERP receiver",
           "url": "https://erp.example.com/hooks/izap",
           "subscribed_events": ["message.received"]
         }'
```

`url` must be `https://` on the standard port (443) — a custom port (e.g.
`https://example.com:8443/hook`) is rejected, as is a private or loopback
address, with `422`. `subscribed_events` can be left as `[]`
to receive every event in the catalog, including ones added later.

The response is the only place the signing secret is ever shown in full:

```json theme={null}
{
  "id": "8f0b9e2a-...",
  "label": "ERP receiver",
  "url": "https://erp.example.com/hooks/izap",
  "subscribed_events": ["message.received"],
  "enabled": true,
  "consecutive_failures": 0,
  "last_error": null,
  "last_delivery_at": null,
  "created": "2026-09-12T10:00:00+00:00",
  "signing_secret": "izap_whsec_..."
}
```

Store `signing_secret` right away — there is no endpoint to read it back later.
If you lose it, [rotate it](#manage-an-endpoint) for a new one.

<Note>
  Registering an endpoint requires a plan with event webhooks included. Without
  one, the call returns `403` with `code: "plan_limit_event_webhooks"`.
</Note>

## Event catalog

| Event              | Fires when                                               |
| ------------------ | -------------------------------------------------------- |
| `message.received` | A new inbound WhatsApp message arrives in a conversation |

The catalog is versioned by addition only — existing event payloads don't change
shape once shipped, so parsing today's `message.received` stays safe as new event
types are added.

## Payload

```json theme={null}
{
  "event": "message.received",
  "business_id": "8f0b9e2a-...",
  "conversation_id": "c3a1f6d0-...",
  "message": {
    "id": "8a2e1c40-...",
    "chat_id": "b91d4a5e-...",
    "text": "What are your hours?",
    "from": "+15551234567",
    "sender_type": "consumer",
    "created_at": "2026-09-12T10:00:00.123456+00:00"
  }
}
```

The body is serialized with sorted keys and no extra whitespace, and that exact
byte sequence is what gets signed — verify against the raw request body, not a
re-serialized parse of it.

## Headers

| Header                      | Contents                                                                                            |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| `izap-webhook-id`           | UUID identifying this delivery. Stable across retries and manual resends of the same event          |
| `izap-webhook-event`        | The event type, e.g. `message.received`                                                             |
| `izap-webhook-timestamp`    | Unix timestamp (seconds) of the attempt                                                             |
| `izap-webhook-signature-v2` | `v2=<hex-hmac>` — **recommended**, covers the id. See [Verify the signature](#verify-the-signature) |
| `izap-webhook-signature`    | `v1=<hex-hmac>` — legacy, does not cover the id. See [Legacy: v1 signature](#legacy-v1-signature)   |

A retried or manually resent delivery carries the same `izap-webhook-id` and
`payload` bytes, but a fresh `izap-webhook-timestamp` and both signatures
recomputed at send time. Once you've verified `izap-webhook-signature-v2`,
`izap-webhook-id` is safe to use as your idempotency key — v2 signs the id
itself, so a delivery replayed under a different id fails verification.
Dedupe on the id only after that check passes, never on the timestamp or the
signature alone: both change on every retry and resend of the same event.

## Verify the signature

`izap-webhook-signature-v2` is the recommended check — it's the only one of
the two that covers `izap-webhook-id`, which closes a real gap: an attacker
who captures one delivery can replay it inside your timestamp tolerance under
a *new* `izap-webhook-id`, and a v1-only check still verifies because v1 never
signed the id. There's also no other generic signed identifier on the body to
fall back on, and deduping on timestamp + signature doesn't help either — both
are freshly computed on every retry and resend of the same event.

```
signature = HMAC-SHA256(signing_secret, "{delivery_id}.{timestamp}.{raw_body}")
header     = "v2=" + hex(signature)
```

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify(
      signing_secret: str,
      delivery_id: str,
      timestamp: str,
      raw_body: bytes,
      header_signature_v2: str,
      tolerance_seconds: int = 300,
  ) -> bool:
      try:
          timestamp_seconds = int(timestamp)
      except (TypeError, ValueError):
          return False  # missing or malformed header — reject rather than crash
      if abs(time.time() - timestamp_seconds) > tolerance_seconds:
          return False  # stale — reject to block replay
      signed_material = f"{delivery_id}.{timestamp}.".encode() + raw_body
      expected = "v2=" + hmac.new(signing_secret.encode(), signed_material, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, header_signature_v2)

  # Only once verify() returns True is `izap-webhook-id` safe to use as your
  # idempotency key — dedupe here, not before.
  def handle(signing_secret: str, headers: dict, raw_body: bytes) -> None:
      delivery_id = headers["izap-webhook-id"]
      if not verify(
          signing_secret,
          delivery_id,
          headers["izap-webhook-timestamp"],
          raw_body,
          headers["izap-webhook-signature-v2"],
      ):
          raise ValueError("invalid signature")
      if already_processed(delivery_id):  # your own store
          return
      # ... handle the event, then mark delivery_id as processed
  ```

  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verify(
    signingSecret: string,
    deliveryId: string,
    timestamp: string,
    rawBody: Buffer,
    headerSignatureV2: string,
    toleranceSeconds = 300,
  ): boolean {
    const timestampSeconds = Number(timestamp);
    if (!Number.isFinite(timestampSeconds) || Math.abs(Date.now() / 1000 - timestampSeconds) > toleranceSeconds) {
      return false; // malformed or stale — reject to block replay
    }
    const signedMaterial = Buffer.concat([Buffer.from(`${deliveryId}.${timestamp}.`), rawBody]);
    const expected = "v2=" + createHmac("sha256", signingSecret).update(signedMaterial).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(headerSignatureV2);
    return a.length === b.length && timingSafeEqual(a, b);
  }

  // Only once verify() returns true is `izap-webhook-id` safe to use as your
  // idempotency key — dedupe here, not before.
  function handle(signingSecret: string, headers: Record<string, string>, rawBody: Buffer): void {
    const deliveryId = headers["izap-webhook-id"];
    if (
      !verify(
        signingSecret,
        deliveryId,
        headers["izap-webhook-timestamp"],
        rawBody,
        headers["izap-webhook-signature-v2"],
      )
    ) {
      throw new Error("invalid signature");
    }
    if (alreadyProcessed(deliveryId)) return; // your own store
    // ... handle the event, then mark deliveryId as processed
  }
  ```
</CodeGroup>

Read the body as raw bytes before any JSON parsing your framework does
automatically — most frameworks buffer and re-encode a parsed body, which no
longer matches what was signed.

## Legacy: v1 signature

`izap-webhook-signature` (`v1=<hex-hmac>`) is still sent on every delivery and
there's no plan to stop — existing integrations keep working unchanged. It
does **not** cover `izap-webhook-id`, so on its own it can't tell a replayed
delivery under a new id from a genuine new one; prefer
[`izap-webhook-signature-v2`](#verify-the-signature) for new integrations, and
migrate existing ones when convenient.

```
signature = HMAC-SHA256(signing_secret, "{timestamp}.{raw_body}")
header     = "v1=" + hex(signature)
```

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_v1(signing_secret: str, timestamp: str, raw_body: bytes, header_signature: str, tolerance_seconds: int = 300) -> bool:
      try:
          timestamp_seconds = int(timestamp)
      except (TypeError, ValueError):
          return False  # missing or malformed header — reject rather than crash
      if abs(time.time() - timestamp_seconds) > tolerance_seconds:
          return False  # stale — reject to block replay
      signed_material = f"{timestamp}.".encode() + raw_body
      expected = "v1=" + hmac.new(signing_secret.encode(), signed_material, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, header_signature)
  ```

  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verifyV1(
    signingSecret: string,
    timestamp: string,
    rawBody: Buffer,
    headerSignature: string,
    toleranceSeconds = 300,
  ): boolean {
    const timestampSeconds = Number(timestamp);
    if (!Number.isFinite(timestampSeconds) || Math.abs(Date.now() / 1000 - timestampSeconds) > toleranceSeconds) {
      return false; // malformed or stale — reject to block replay
    }
    const signedMaterial = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
    const expected = "v1=" + createHmac("sha256", signingSecret).update(signedMaterial).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(headerSignature);
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```
</CodeGroup>

## Delivery and retries

* Your endpoint gets **10 seconds** to respond. Anything in `2xx` counts as
  delivered.
* `5xx` and `429` responses are retried; any other `4xx` is treated as a
  permanent rejection and isn't retried — return `2xx` only once you've
  durably accepted the event.
* After **20 consecutive failures** an endpoint is automatically disabled
  (`enabled: false`, with `last_error` set). [Re-enable it](#manage-an-endpoint)
  once your receiver is healthy again — it doesn't recover on its own.

## Manage an endpoint

| Action                                             | Request                                                                        |
| -------------------------------------------------- | ------------------------------------------------------------------------------ |
| List endpoints                                     | `GET /api/v1/webhook-endpoints/{business_id}`                                  |
| Update label, URL, subscriptions, or enabled state | `PATCH /api/v1/webhook-endpoints/{business_id}/{endpoint_id}`                  |
| Delete an endpoint                                 | `DELETE /api/v1/webhook-endpoints/{business_id}/{endpoint_id}`                 |
| Rotate the signing secret                          | `POST /api/v1/webhook-endpoints/{business_id}/{endpoint_id}/rotate-secret`     |
| Resend one delivery                                | `POST /api/v1/webhook-endpoints/{business_id}/deliveries/{delivery_id}/resend` |

Rotating invalidates the previous secret immediately — there's no overlap
window, so update your verification code with the new secret before rotating
in production. A resend replays the original stored bytes, so it verifies
against the same payload the first attempt did.

<Card title="Scaffold a receiver" icon="bolt" href="/en/api-reference/wizard">
  `izap-wizard webhook receiver` writes a starting point that implements this
  contract for you.
</Card>
