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

> Suscríbete a eventos de WhatsApp en tiempo real en tu propio endpoint HTTPS.

Los webhooks salientes envían eventos a una URL que tú controlas, así no tienes
que consultar la API REST en busca de actividad nueva. Registra un endpoint una
vez e iZap le envía cada evento suscrito en el momento en que ocurre. Con el
[Analytics MCP](/es/api-reference/mcp) y la API REST eres tú quien llama a iZap;
aquí es iZap quien te llama a ti.

<Note>
  ¿Buscas el lado *entrante*, el webhook de la Cloud API de Meta que entrega los
  mensajes de WhatsApp a iZap? Eso es interno de la propia conexión de WhatsApp
  de iZap. Lo que describe esta página es la entrega saliente de iZap a **tu**
  endpoint.
</Note>

## Registra un 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` debe ser `https://` en el puerto estándar (443): un puerto personalizado
(p. ej., `https://example.com:8443/hook`) se rechaza con `422`, igual que una
dirección privada o de loopback. Puedes dejar `subscribed_events` como `[]`
para recibir todos los eventos del catálogo, incluidos los que se agreguen más
adelante.

La respuesta es el único lugar donde se muestra completo el secreto de firma:

```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_..."
}
```

Guarda `signing_secret` de inmediato: no hay ningún endpoint para volver a
leerlo. Si lo pierdes, [rótalo](#gestiona-un-endpoint) para obtener uno nuevo.

<Note>
  Registrar un endpoint requiere un plan que incluya webhooks de eventos. Sin
  él, la llamada devuelve `403` con `code: "plan_limit_event_webhooks"`.
</Note>

## Catálogo de eventos

| Evento | Se dispara cuando |
| - | - |
| `message.received` | Llega un nuevo mensaje de WhatsApp entrante a una conversación |

El catálogo se versiona solo por adición: una vez publicados, los payloads de
los eventos existentes no cambian de forma, así que interpretar el
`message.received` de hoy sigue siendo seguro a medida que se agregan nuevos
tipos de eventos.

## 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"
  }
}
```

El cuerpo se serializa con las claves ordenadas y sin espacios en blanco
adicionales, y esa secuencia exacta de bytes es la que se firma: verifica contra
el cuerpo bruto de la solicitud, no contra una re-serialización del JSON
interpretado.

## Encabezados

| Encabezado | Contenido |
| - | - |
| `izap-webhook-id` | UUID que identifica esta entrega. Es estable entre reintentos y reenvíos manuales del mismo evento |
| `izap-webhook-event` | El tipo de evento, p. ej., `message.received` |
| `izap-webhook-timestamp` | Marca de tiempo Unix (segundos) del intento |
| `izap-webhook-signature-v2` | `v2=<hex-hmac>`: **recomendado**, cubre el id. Consulta [Verifica la firma](#verifica-la-firma) |
| `izap-webhook-signature` | `v1=<hex-hmac>`: heredado, no cubre el id. Consulta [Heredado: firma v1](#heredado-firma-v1) |

Una entrega reintentada o reenviada manualmente lleva el mismo
`izap-webhook-id` y los mismos bytes de `payload`, pero un
`izap-webhook-timestamp` nuevo y las dos firmas recalculadas en el momento del
envío. Después de verificar `izap-webhook-signature-v2`, `izap-webhook-id` es
seguro como clave de idempotencia: v2 firma el propio id, así que una entrega
reproducida con otro id no pasa la verificación. Elimina duplicados por el id
solo después de que esa verificación sea correcta, nunca solo por la marca de
tiempo o la firma: ambas cambian en cada reintento y reenvío del mismo evento.

## Verifica la firma

`izap-webhook-signature-v2` es la verificación recomendada: es la única de las
dos que cubre `izap-webhook-id`, y eso cierra una brecha real. Un atacante que
captura una entrega puede reproducirla dentro de tu margen de tiempo con un
`izap-webhook-id` *nuevo*, y una verificación solo con v1 la sigue aceptando
porque v1 nunca firmó el id. Tampoco hay otro identificador firmado genérico en
el cuerpo al que recurrir, y eliminar duplicados por marca de tiempo + firma
tampoco ayuda: ambas se calculan de nuevo en cada reintento y reenvío del mismo
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  # encabezado ausente o mal formado: rechaza en lugar de fallar
      if abs(time.time() - timestamp_seconds) > tolerance_seconds:
          return False  # desactualizado: rechaza para bloquear la reproducción
      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)

  # Solo cuando verify() devuelve True, `izap-webhook-id` es seguro como clave
  # de idempotencia: elimina duplicados aquí, no 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("invalid signature")
      if already_processed(delivery_id):  # tu propio almacenamiento
          return
      # ... procesa el evento y luego marca delivery_id como procesado
  ```

  ```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; // mal formado o desactualizado: rechaza para bloquear la reproducción
    }
    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);
  }

  // Solo cuando verify() devuelve true, `izap-webhook-id` es seguro como clave
  // de idempotencia: elimina duplicados aquí, no 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("invalid signature");
    }
    if (alreadyProcessed(deliveryId)) return; // tu propio almacenamiento
    // ... procesa el evento y luego marca deliveryId como procesado
  }
  ```
</CodeGroup>

Lee el cuerpo como bytes brutos antes de cualquier interpretación de JSON que tu
framework haga automáticamente: la mayoría de los frameworks almacenan y vuelven
a codificar el cuerpo interpretado, y eso ya no coincide con lo que se firmó.

## Heredado: firma v1

`izap-webhook-signature` (`v1=<hex-hmac>`) se sigue enviando en cada entrega y
no hay planes de dejar de hacerlo: las integraciones existentes siguen
funcionando sin cambios. **No** cubre `izap-webhook-id`, así que por sí sola no
puede distinguir una entrega reproducida con un id nuevo de una entrega nueva
genuina. Prefiere
[`izap-webhook-signature-v2`](#verifica-la-firma) para las integraciones nuevas
y migra las existentes cuando te convenga.

```
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  # encabezado ausente o mal formado: rechaza en lugar de fallar
      if abs(time.time() - timestamp_seconds) > tolerance_seconds:
          return False  # desactualizado: rechaza para bloquear la reproducción
      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; // mal formado o desactualizado: rechaza para bloquear la reproducción
    }
    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 y reintentos

* Tu endpoint tiene **10 segundos** para responder. Cualquier respuesta `2xx`
  cuenta como entregada.
* Las respuestas `5xx` y `429` se reintentan; cualquier otro `4xx` se trata como
  un rechazo permanente y no se reintenta. Devuelve `2xx` solo cuando hayas
  aceptado el evento de forma duradera.
* Después de **20 fallos consecutivos**, el endpoint se desactiva
  automáticamente (`enabled: false`, con `last_error` definido).
  [Vuelve a activarlo](#gestiona-un-endpoint) cuando tu receptor esté bien de
  nuevo: no se recupera solo.

## Gestiona un endpoint

| Acción | Solicitud |
| - | - |
| Listar endpoints | `GET /api/v1/webhook-endpoints/{business_id}` |
| Actualizar etiqueta, URL, suscripciones o estado de activación | `PATCH /api/v1/webhook-endpoints/{business_id}/{endpoint_id}` |
| Eliminar un endpoint | `DELETE /api/v1/webhook-endpoints/{business_id}/{endpoint_id}` |
| Rotar el secreto de firma | `POST /api/v1/webhook-endpoints/{business_id}/{endpoint_id}/rotate-secret` |
| Reenviar una entrega | `POST /api/v1/webhook-endpoints/{business_id}/deliveries/{delivery_id}/resend` |

Rotar invalida el secreto anterior de inmediato: no hay periodo de
superposición, así que actualiza tu código de verificación con el nuevo secreto
antes de rotarlo en producción. Un reenvío reproduce los bytes originales
almacenados, así que se verifica contra el mismo payload que el primer intento.

<Card title="Genera un receptor" icon="bolt" href="/es/api-reference/wizard">
  `izap-wizard webhook receiver` escribe un punto de partida que implementa este
  contrato por ti.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.