# 05 · Recibir eventos

En vez de preguntar, **os llamamos nosotros**: cada cambio relevante (un cliente escribe, un mensaje se entrega, Meta aprueba una plantilla…) se envía por `POST` a una URL vuestra, **firmado**.

Garantías que tenéis que asumir al programar el receptor:

| Garantía | Qué implica |
| --- | --- |
| **Al menos una vez** | Un evento puede llegar **repetido**. Deduplicad por `id` |
| **Sin orden garantizado** | Puede llegar `read` antes que `delivered`. Comparad `occurred_at` y no retrocedáis estados |
| **Reintentos largos** | Si no respondéis 2xx, se reintenta ~31 h |
| **Pueden llegar en ráfaga** | Responded rápido y trabajad después |

## 1. Registrar el destino

```bash
curl -X POST $BASE_URL/v1/webhook-subscriptions -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://erp.cliente.com/hooks/whatsapp", "events": ["whatsapp.message.*", "whatsapp.template.status_changed"]}'
```

La respuesta incluye `secret` (`whs_…`) **una sola vez**: guardadlo, lo necesitáis para verificar la firma.

- **`https` obligatorio**, sin credenciales en la URL y sin direcciones privadas o internas (loopback, 10.x, 192.168.x, 169.254.x…). Se comprueba al crearla **y en cada entrega**. No se siguen redirecciones (un `3xx` cuenta como fallo).
- Una suscripción es de **un tenant y una app**. Para recibir los eventos de todos los tenants de una app, el equipo de administración la crea con `POST /admin/v1/apps/{id}/webhook-subscriptions`; en `tenant_ref` de cada evento sabréis a qué cliente pertenece.
- `events`: tipos exactos o comodines (`*`, `whatsapp.*`, `whatsapp.message.*`). Catálogo: [referencia/eventos.md](referencia/eventos.md).
- **Desarrollo local:** vuestro `localhost` no es accesible ni https; usad un túnel (cloudflared, ngrok…) o probad el receptor con el [vector de firma](ejemplos/vectores/firma-evento.json).

Gestión: `GET/PATCH/DELETE /v1/webhook-subscriptions`, `POST …/{id}/test` (envía un evento `whatsapp.test` y devuelve el resultado), `GET /v1/webhook-deliveries` (historial) y `POST /v1/webhook-deliveries/{id}/redeliver`.

## 2. Qué recibís

Petición `POST` con `Content-Type: application/json` y estas cabeceras:

| Cabecera | Contenido |
| --- | --- |
| `X-Wa-Event-Id` | El `id` del evento (igual en cada reintento) |
| `X-Wa-Signature` | `t=<unix>,v1=<hex>` — ver abajo |
| `User-Agent` | `wa-gateway/1.0` |

Cuerpo (sobre común):

```json
{ "id": "evt_…", "type": "whatsapp.message.received", "tenant_id": "ten_…", "tenant_ref": "CLI-0042", "occurred_at": "2026-10-12T09:41:07Z", "data": { … } }
```

`tenant_ref` es **vuestro** identificador del cliente: localizáis al cliente sin consultar nada.

### Una persona escribe desde la app del móvil (`whatsapp.message.sent_from_app`)

Solo en números con coexistencia ([14](14-cuenta-de-meta-y-coexistencia.md)). Mismo cuerpo que `whatsapp.message.received` (`conversation_id`, `contact`, `message`), con `message.direction = "outbound"` y `message.via = "business_app"`. Es la señal de que **alguien ha tomado la conversación**: un bot debe dejar de responder. No abre la ventana de 24 h ni cuenta como mensaje enviado de la licencia. El historial que se importa al conectar **no genera ningún evento**.

### Un cliente escribe (`whatsapp.message.received`)

Si responde a un botón, a una fila de lista, a un botón de plantilla o a un formulario Flow, `data.message.reply` trae su elección (`{ id, title }`); en el resto es `null` ([03](03-enviar-mensajes.md#botones-y-listas)).

Evento real:

```json
{
  "id": "evt_01a1311dd3a67812937eb247eb1e9d9a",
  "data": {
    "contact": {
      "id": "ctc_01a1311dd2b77dcb9e6f7cc95eb039d7",
      "wa_id": "34600888001",
      "display_name": null,
      "external_ref": null,
      "profile_name": "Eva Martín",
      "opt_in_status": "unknown"
    },
    "message": {
      "id": "msg_01a1311dd3a37503beafc0aa2ffccae1",
      "to": null,
      "body": "Hola, ¿me confirmáis la hora de mañana?",
      "from": "34600888001",
      "type": "text",
      "error": null,
      "status": "received",
      "payload": {
        "id": "wamid.CAP1791825400474",
        "from": "34600888001",
        "text": {
          "body": "Hola, ¿me confirmáis la hora de mañana?"
        },
        "type": "text",
        "timestamp": "1791825400"
      },
      "read_at": null,
      "sent_at": null,
      "billable": null,
      "media_id": null,
      "direction": "inbound",
      "failed_at": null,
      "number_id": "num_01a131198cb47a84a44f24d7d370daae",
      "queued_at": null,
      "contact_id": "ctc_01a1311dd2b77dcb9e6f7cc95eb039d7",
      "created_at": "2026-10-12T17:16:40.000Z",
      "template_id": null,
      "delivered_at": null,
      "external_ref": null,
      "pricing_type": null,
      "origin_app_id": null,
      "wa_message_id": "wamid.CAP1791825400474",
      "conversation_id": "cnv_01a1311dd2b87388a8d019184ea19c0b",
      "pricing_category": null,
      "sent_by_agent_id": null,
      "reply_to_message_id": null
    },
    "conversation_id": "cnv_01a1311dd2b87388a8d019184ea19c0b"
  },
  "type": "whatsapp.message.received",
  "tenant_id": "ten_01a131198c7c7185b88aad2746e7e573",
  "tenant_ref": "DEMO-202610121712-hpc",
  "occurred_at": "2026-10-12T17:16:40.000Z"
}
```

Lo habitual: buscad al cliente por `data.contact.external_ref` (si lo habéis enlazado al enviar con `contact.external_ref`) o por `data.contact.wa_id` (su teléfono).

### Un envío cambia de estado (`whatsapp.message.status`)

`data.status` ∈ `sent`, `delivered`, `read`, `failed`. Localizad **vuestro** registro por `data.message.external_ref` (el que enviasteis).

Entregado, con el precio que informa Meta:

```json
{
  "id": "evt_01a1311ddd7e7961be00c5b551f26253",
  "data": {
    "error": null,
    "status": "delivered",
    "message": {
      "id": "msg_01a1311dd2bc72cba98e732400044f39",
      "to": "34600888001",
      "body": "Hola Eva, te recordamos tu cita del 16 de octubre.",
      "from": null,
      "type": "template",
      "error": null,
      "status": "delivered",
      "payload": {
        "to": "34600888001",
        "type": "template",
        "template": {
          "name": "recordatorio_cita",
          "language": {
            "code": "es"
          },
          "components": [
            {
              "type": "body",
              "parameters": [
                {
                  "text": "Eva",
                  "type": "text"
                },
                {
                  "text": "16 de octubre",
                  "type": "text"
                }
              ]
            }
          ]
        }
      },
      "read_at": null,
      "sent_at": "2026-10-12T17:16:40.759Z",
      "billable": true,
      "media_id": null,
      "direction": "outbound",
      "failed_at": null,
      "number_id": "num_01a131198cb47a84a44f24d7d370daae",
      "queued_at": "2026-10-12T17:16:40.500Z",
      "contact_id": "ctc_01a1311dd2b77dcb9e6f7cc95eb039d7",
      "created_at": "2026-10-12T17:16:40.500Z",
      "template_id": "tpl_01a131198cdb72fbb5bb7c28cbdd35e7",
      "delivered_at": "2026-10-12T17:16:43.000Z",
      "external_ref": "cita-48213",
      "pricing_type": "regular",
      "origin_app_id": "app_01a131198c747270ac323426043d53b8",
      "wa_message_id": "wamid.FAKE2e0ec3b598cc14",
      "conversation_id": "cnv_01a1311dd2b87388a8d019184ea19c0b",
      "pricing_category": "utility",
      "sent_by_agent_id": null,
      "reply_to_message_id": null
    }
  },
  "type": "whatsapp.message.status",
  "tenant_id": "ten_01a131198c7c7185b88aad2746e7e573",
  "tenant_ref": "DEMO-202610121712-hpc",
  "occurred_at": "2026-10-12T17:16:43.000Z"
}
```

## 3. Verificar la firma (obligatorio)

Sin verificar, cualquiera que conozca vuestra URL podría fingir eventos.

**Algoritmo** (cualquier lenguaje):

1. Tomad el **cuerpo exacto recibido, sin parsear ni reformatear**, como bytes/cadena (`raw`).
2. De `X-Wa-Signature` sacad `t` (segundos Unix) y `v1` (hexadecimal).
3. Rechazad si `|ahora − t| > 300` segundos (evita que alguien reenvíe un evento viejo).
4. Calculad `HMAC-SHA256(clave = secret, mensaje = t + "." + raw)` en hexadecimal.
5. Comparad con `v1` **en tiempo constante**. Si no coincide: `401`.

> El fallo más común: leer el JSON con vuestro framework y volver a serializarlo. El orden de claves o los espacios cambian y la firma deja de coincidir. Capturad el cuerpo **crudo** (`express.raw`, `request.get_data()`, `file_get_contents('php://input')`, `Request.Body`…).

Ejemplos ejecutados contra el sistema real:

- Node: [`ejemplos/node/receptor-eventos.mjs`](ejemplos/node/receptor-eventos.mjs) (servidor completo + función `verificar`)
- Python: [`ejemplos/python/receptor_eventos.py`](ejemplos/python/receptor_eventos.py)
- Ruby: [`ejemplos/ruby/verificar_firma.rb`](ejemplos/ruby/verificar_firma.rb)
- PHP, C#, Java: [13 · Otros lenguajes](13-otros-lenguajes.md)
- Con el SDK de TypeScript: `WaGateway.verifyWebhook(raw, header, secret)` ([12](12-sdk.md))

**Validad vuestra implementación** con el [vector de prueba](ejemplos/vectores/firma-evento.json): contiene un evento, un secreto, su firma y cinco casos (válido, cuerpo modificado, secreto equivocado, repetición tardía y cabecera mal formada) con el resultado esperado de cada uno.

```
secret  = whs_vector_secret_0001
t       = 1760262067
v1      = f8d434cdcdd7e091907823aa365aca20d7f1f5da594e4731cda0fbc2b3c8c972   (para el cuerpo del vector)
```

## 4. Responder

- **2xx en menos de 10 segundos**, cuanto antes mejor. Cualquier otra cosa (o un tiempo agotado) cuenta como fallo.
- Guardad el evento (o encoladlo) y responded; procesad después.
- Para 4xx permanentes (p. ej. firma inválida) responded `401`/`400`: se reintentará igualmente; un destino que falla de forma continua se desactiva (abajo).

## 5. Reintentos y desactivación

| Intento | Espera tras el fallo anterior |
| --- | --- |
| 2.º | 30 segundos |
| 3.º | 2 minutos |
| 4.º | 10 minutos |
| 5.º | 1 hora |
| 6.º | 6 horas |
| 7.º | 24 horas |

Tras el 7.º intento fallido la entrega pasa a `dead` (visible en `GET /v1/webhook-deliveries?status=dead`; se puede reenviar a mano con `redeliver`).

Tras **50 fallos seguidos** la suscripción se **desactiva** (`active: false`). Cuando arreglen el destino: `PATCH /v1/webhook-subscriptions/{id}` con `{ "active": true }`.

## 6. Plan de recuperación si estuvisteis caídos

1. Mirad `GET /v1/webhook-deliveries?status=dead` y `…?status=failed`.
2. Reenviad con `POST /v1/webhook-deliveries/{id}/redeliver` (mismo `id` de evento: vuestra deduplicación los absorbe).
3. Para reconciliar mensajes: `GET /v1/messages?external_ref=…&from=…&to=…` y `GET /v1/conversations?updated_since=…` os dan el estado actual.

## Receptor correcto, en pocas líneas

```js
app.post('/hooks/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
  let evento;
  try { evento = verificar(req.body.toString('utf8'), req.header('X-Wa-Signature'), process.env.WA_WEBHOOK_SECRET); }
  catch { return res.sendStatus(401); }
  res.sendStatus(200);                              // responder ya
  if (yaProcesado(evento.id)) return;               // deduplicar (tabla con el id)
  cola.push(evento);                                // trabajar después
});
```
