> ## 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

> Assine eventos do WhatsApp em tempo real no seu próprio endpoint HTTPS.

Webhooks de saída enviam eventos para uma URL que você controla, para que não
seja preciso fazer polling na API REST em busca de novidades. Registre um
endpoint uma vez e a iZap envia (via POST) cada evento assinado para ele assim
que acontece — o [Analytics MCP](/pt/api-reference/mcp) e a API REST são você
chamando a iZap; aqui é a iZap chamando você.

<Note>
  Procurando o lado *de entrada* — o webhook da Cloud API da Meta que entrega
  mensagens do WhatsApp para a iZap? Esse é interno à conexão de WhatsApp da
  própria iZap. O que está nesta página é a entrega de saída da iZap para o
  **seu** endpoint.
</Note>

## Registrando um 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": "Receptor do ERP",
           "url": "https://erp.exemplo.com/hooks/izap",
           "subscribed_events": ["message.received"]
         }'
```

`url` precisa ser `https://` na porta padrão (443) — uma porta customizada
(ex.: `https://exemplo.com:8443/hook`) é rejeitada, assim como um endereço
privado ou de loopback, com `422`. `subscribed_events` pode ficar
`[]` para receber todos os eventos do catálogo, inclusive os que forem
adicionados depois.

A resposta é o único lugar em que o segredo de assinatura aparece por
completo:

```json theme={null}
{
  "id": "8f0b9e2a-...",
  "label": "Receptor do ERP",
  "url": "https://erp.exemplo.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_..."
}
```

Guarde `signing_secret` imediatamente — não existe endpoint para lê-lo de
volta depois. Se você o perder, [gire-o](#gerenciando-um-endpoint) para obter
um novo.

<Note>
  Registrar um endpoint exige um plano que inclua webhooks de eventos. Sem
  isso, a chamada retorna `403` com `code: "plan_limit_event_webhooks"`.
</Note>

## Catálogo de eventos

| Evento             | Dispara quando                                      |
| ------------------ | --------------------------------------------------- |
| `message.received` | Uma nova mensagem de WhatsApp chega em uma conversa |

O catálogo é versionado apenas por adição — o formato dos eventos já
publicados não muda, então analisar o `message.received` de hoje continua
seguro conforme novos tipos de evento forem adicionados.

## Payload

```json theme={null}
{
  "event": "message.received",
  "business_id": "8f0b9e2a-...",
  "conversation_id": "c3a1f6d0-...",
  "message": {
    "id": "8a2e1c40-...",
    "chat_id": "b91d4a5e-...",
    "text": "Qual o horário de vocês?",
    "from": "+15551234567",
    "sender_type": "consumer",
    "created_at": "2026-09-12T10:00:00.123456+00:00"
  }
}
```

O corpo é serializado com chaves ordenadas e sem espaços extras, e é
exatamente essa sequência de bytes que é assinada — verifique contra o corpo
bruto da requisição, não contra um novo parse/serialização dele.

## Cabeçalhos

| Cabeçalho                   | Conteúdo                                                                                                  |
| --------------------------- | --------------------------------------------------------------------------------------------------------- |
| `izap-webhook-id`           | UUID que identifica esta entrega. Estável entre novas tentativas e reenvios manuais do mesmo evento       |
| `izap-webhook-event`        | O tipo do evento, ex. `message.received`                                                                  |
| `izap-webhook-timestamp`    | Timestamp Unix (segundos) da tentativa                                                                    |
| `izap-webhook-signature-v2` | `v2=<hex-hmac>` — **recomendada**, cobre o id. Veja [Verificando a assinatura](#verificando-a-assinatura) |
| `izap-webhook-signature`    | `v1=<hex-hmac>` — legada, não cobre o id. Veja [Legado: assinatura v1](#legado-assinatura-v1)             |

Uma entrega reenviada (automática ou manualmente) carrega o mesmo
`izap-webhook-id` e os mesmos bytes de `payload`, mas um novo
`izap-webhook-timestamp` e ambas as assinaturas recalculadas no momento do
envio. Só depois de verificar `izap-webhook-signature-v2` é que
`izap-webhook-id` é seguro como chave de idempotência — a v2 assina o próprio
id, então uma entrega reenviada sob um id diferente falha na verificação.
Deduplique pelo id somente depois dessa verificação, nunca pelo timestamp ou
pela assinatura isoladamente: ambos mudam a cada nova tentativa e reenvio do
mesmo evento.

## Verificando a assinatura

`izap-webhook-signature-v2` é a verificação recomendada — é a única das duas
que cobre `izap-webhook-id`, o que fecha uma lacuna real: quem captura uma
entrega pode reenviá-la dentro da sua tolerância de timestamp sob um
`izap-webhook-id` *novo*, e uma verificação só com v1 continua válida, porque a
v1 nunca assinou o id. Também não existe outro identificador assinado
genérico no corpo para recorrer, e deduplicar por timestamp + assinatura não
ajuda — ambos são recalculados a cada nova tentativa e reenvio do mesmo
evento.

```
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  # cabeçalho ausente ou malformado — rejeite em vez de travar
      if abs(time.time() - timestamp_seconds) > tolerance_seconds:
          return False  # desatualizado — rejeite para bloquear 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)

  # Só depois que verify() retornar True é que `izap-webhook-id` é seguro como
  # chave de idempotência — deduplique aqui, nunca antes.
  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("assinatura inválida")
      if already_processed(delivery_id):  # seu próprio armazenamento
          return
      # ... processe o evento e então marque delivery_id como processado
  ```

  ```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; // malformado ou desatualizado — rejeite para bloquear 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);
  }

  // Só depois que verify() retornar true é que `izap-webhook-id` é seguro como
  // chave de idempotência — deduplique aqui, nunca antes.
  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("assinatura inválida");
    }
    if (alreadyProcessed(deliveryId)) return; // seu próprio armazenamento
    // ... processe o evento e então marque deliveryId como processado
  }
  ```
</CodeGroup>

Leia o corpo como bytes brutos antes de qualquer parse de JSON automático do
seu framework — a maioria dos frameworks armazena em buffer e re-codifica um
corpo já processado, que deixa de bater com o que foi assinado.

## Legado: assinatura v1

`izap-webhook-signature` (`v1=<hex-hmac>`) continua sendo enviada em toda
entrega e não há plano de parar — integrações existentes continuam
funcionando sem mudanças. Ela **não** cobre `izap-webhook-id`, então sozinha
não distingue uma entrega reenviada sob um id novo de uma entrega
genuinamente nova; prefira
[`izap-webhook-signature-v2`](#verificando-a-assinatura) em integrações
novas, e migre as existentes quando for conveniente.

```
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  # cabeçalho ausente ou malformado — rejeite em vez de travar
      if abs(time.time() - timestamp_seconds) > tolerance_seconds:
          return False  # desatualizado — rejeite para bloquear 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; // malformado ou desatualizado — rejeite para bloquear 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>

## Entrega e novas tentativas

* Seu endpoint tem **10 segundos** para responder. Qualquer `2xx` conta como
  entregue.
* Respostas `5xx` e `429` são reenviadas; qualquer outro `4xx` é tratado como
  rejeição definitiva e não é reenviado — retorne `2xx` somente depois de
  aceitar o evento de forma durável.
* Depois de **20 falhas consecutivas** um endpoint é desabilitado
  automaticamente (`enabled: false`, com `last_error` preenchido).
  [Reative-o](#gerenciando-um-endpoint) quando seu receptor estiver saudável
  novamente — ele não se recupera sozinho.

## Gerenciando um endpoint

| Ação                                               | Requisição                                                                     |
| -------------------------------------------------- | ------------------------------------------------------------------------------ |
| Listar endpoints                                   | `GET /api/v1/webhook-endpoints/{business_id}`                                  |
| Atualizar rótulo, URL, assinaturas ou estado ativo | `PATCH /api/v1/webhook-endpoints/{business_id}/{endpoint_id}`                  |
| Excluir um endpoint                                | `DELETE /api/v1/webhook-endpoints/{business_id}/{endpoint_id}`                 |
| Girar o segredo de assinatura                      | `POST /api/v1/webhook-endpoints/{business_id}/{endpoint_id}/rotate-secret`     |
| Reenviar uma entrega                               | `POST /api/v1/webhook-endpoints/{business_id}/deliveries/{delivery_id}/resend` |

Girar o segredo invalida o anterior imediatamente — não há janela de
sobreposição, então atualize seu código de verificação com o novo segredo
antes de girar em produção. Um reenvio reproduz os bytes originais
armazenados, então ele verifica contra o mesmo payload da primeira tentativa.

<Card title="Monte um receptor" icon="bolt" href="/pt/api-reference/wizard">
  `izap-wizard webhook receiver` grava um ponto de partida que já implementa
  esse contrato para você.
</Card>
