# Webhook de WhatsApp (Meta Cloud API) — bandeja cruda de mensajes y eventos

**Fecha:** 2026-08-24 · **Proyecto:** CL001 Pádel (PR006BACKPADEL)
**Estado:** implementado y verificado contra MySQL local. La tabla ya está creada
en la base `padel`. Falta correr el SQL en el servidor y dar de alta la URL en el
panel de Meta — ver [`whatsapp-webhook-configuracion-meta.md`](whatsapp-webhook-configuracion-meta.md).

---

## El problema

Pádel **habla** por WhatsApp desde hace tiempo: `WhatsAppChannel` manda
confirmaciones de reserva, invitaciones privadas y de grupo, códigos de
verificación, avisos de partida abierta. Todas esas plantillas salen y ahí
termina la historia.

Lo que no existía era la vuelta. Meta empuja a un webhook **todo** lo que pasa en
la cuenta de WhatsApp Business:

- el socio que **contesta** el mensaje ("¿a qué hora?", "no puedo"),
- el **acuse** de cada plantilla que mandamos: `sent`, `delivered`, `read`,
  `failed` — y con él el motivo cuando falla (número inválido, usuario sin
  WhatsApp, plantilla pausada),
- las **plantillas** que Meta aprueba, rechaza o pausa por calidad,
- la **calidad del número** cuando baja y el límite de envío cuando cambia,
- las **alertas de la cuenta**.

Sin webhook, nada de eso llega. Se manda y no se sabe qué contestaron: ni si el
mensaje llegó, ni si el socio respondió, ni por qué una plantilla dejó de
enviarse.

Esta entrega **no** resuelve qué hacer con cada uno de esos eventos. Resuelve lo
que tiene que existir antes: la bandeja donde caen, completos y sin filtrar.

---

## Qué se entregó

Una sola URL, que es la que se registra en Meta:

```
https://{dominio}/api/v1/mensajeria/whatsappwebhook
```

| Método | Acción | Para qué |
|---|---|---|
| `GET`  | `verificar` | El saludo de verificación. Meta lo manda una vez, al guardar la URL en el panel. |
| `POST` | `recibir`   | Cada mensaje, acuse o alerta. Se guarda completo en `msj_whatsappwebhook` y se contesta 200. |

`recibir` no interpreta ni descarta nada. Guarda el sobre entero y contesta.
Quien quiera reaccionar a un mensaje o amarrar un acuse con lo que mandó
`WhatsAppChannel` lee la bandeja después.

Esa separación es a propósito: lo que Meta necesita es un **200 rápido**, y
cualquier lógica de negocio metida en la ruta es una razón más para que ese 200
no llegue a tiempo.

---

## Los archivos

Todos siguen la derivación de `ARQUITECTURA_GENERAL.md` §3, a partir de la tabla
`msj_whatsappwebhook` (módulo `msj_` → `Mensajeria`, entidad `WhatsappWebhook`):

| Pieza | Archivo |
|---|---|
| Tabla | `docs/migration_msj_whatsappwebhook.sql` |
| Modelo | `app/Models/Modulos/Mensajeria/WhatsappWebhook.php` |
| Form Request (GET) | `app/Http/Requests/Mensajeria/WhatsappWebhook/VerificarFormRequest.php` |
| Form Request (POST) | `app/Http/Requests/Mensajeria/WhatsappWebhook/RecibirFormRequest.php` |
| Controlador | `app/Http/Controllers/Mensajeria/WhatsappWebhookController.php` |
| Rutas | `routes/modulos/mensajeria/whatsappwebhook.php` |
| Middleware | `app/Http/Middleware/WhatsappMetaAutorizado.php` (alias `whatsappMeta`) |
| Configuración | `config/mensajeria.php` → `whatsapp.webhook` |
| Limitador | `app/Providers/AppServiceProvider.php` → `whatsapp-webhook` |
| Pruebas | `tests/Feature/Mensajeria/WhatsappWebhookTest.php` |

**No hay Resource.** La respuesta a Meta es un 200 y el challenge en texto plano;
no hay una forma de salida que modelar, y la arquitectura pide no crear lo que no
se usa (§5.2 #8).

**No hay pantalla ni store de Pinia.** No se pidió una vista de la bandeja. Si
más adelante hace falta, el camino ya está: `listar` en el controlador,
`ListaResource`, `mensajeria/whatsappwebhookStore.js` y una `Index.vue`.

---

## La tabla

`msj_whatsappwebhook` — **una fila por notificación HTTP recibida**.

| Campo | Tipo | De dónde sale |
|---|---|---|
| `idWhatsappWebhook` | PK | |
| `whatsappWebhookObjeto` | varchar(60) | `object` — hoy siempre `whatsapp_business_account` |
| `whatsappWebhookCuentaId` | varchar(64) | `entry[].id` — el WABA ID |
| `whatsappWebhookCampo` | varchar(64) | `entry[].changes[].field` — `messages`, `message_template_status_update`, … |
| `whatsappWebhookTipo` | enum | Clasificación propia: `Mensaje` / `Estado` / `Error` / `Otro` |
| `whatsappWebhookNumeroId` | varchar(64) | `value.metadata.phone_number_id` |
| `whatsappWebhookTelefono` | varchar(32) | `messages[].from` o `statuses[].recipient_id` |
| `whatsappWebhookEventoId` | varchar(191) | El **wamid** del primer mensaje o acuse |
| `whatsappWebhookEventos` | smallint | Cuántos eventos traía el sobre |
| `whatsappWebhookPayload` | **json** | **El cuerpo completo, sin filtrar ni recortar** |
| `whatsappWebhookHuella` | char(64) | SHA-256 del cuerpo crudo |
| `whatsappWebhookFirma` | varchar(191) | El header `X-Hub-Signature-256` recibido |
| `whatsappWebhookFirmaValida` | enum | `Si` / `No` / `NoVerificada` |
| `whatsappWebhookIP` | varchar(45) | IP de origen |
| `whatsappWebhookFecha` | datetime | Momento de recepción |
| `whatsappWebhookEstado` | enum | `Activo` (sin procesar) / `Procesado` / `Eliminado` |

Las columnas sueltas son **un resumen para poder buscar**. La verdad completa
siempre vive en `whatsappWebhookPayload`; si algo del sobre no se reconoce, la
columna se queda en `NULL` y la fila se guarda igual.

### Por qué una fila por petición y no por mensaje

Porque el requisito es no perder nada. Partir el sobre en eventos obliga a
decidir qué es un evento, y cualquier `field` nuevo que Meta invente —la lista
crece en cada versión de la API— se quedaría fuera. Guardando el sobre entero, un
`field` desconocido igual queda registrado y se puede procesar después.
`whatsappWebhookEventos` dice cuántos traía el lote.

### Por qué no hay UNIQUE

Meta reintenta durante 36 horas lo que no reciba un 200, así que un mismo evento
puede llegar varias veces. Un UNIQUE convertiría el reintento en un error 1062 y
perderíamos la evidencia de que Meta reintentó.

En su lugar se guarda `whatsappWebhookHuella` (SHA-256 del cuerpo crudo)
indexada: quien procese la bandeja deduplica por huella o por
`whatsappWebhookEventoId`, pero **la recepción nunca rechaza**.

---

## Las decisiones que no son obvias

### Por qué el módulo es `Mensajeria` (`msj_`) y no `Notificacion` (`not_`)

`msj_` es un **prefijo nuevo en el catálogo del proyecto**, acordado para esta
entrega. La primera versión colgaba de `not_`, y se cambió antes de registrar la
URL en Meta.

El motivo: `not_` nombra lo que el sistema **decide emitir** —una notificación,
su canal, su bitácora de envío—. Esta bandeja trae también lo que el socio
contesta y lo que Meta avisa por su cuenta (plantillas rechazadas, calidad del
número, alertas de la cuenta), que no son notificaciones de nadie. Meterlo en
`not_` obligaba a estirar el significado del módulo hasta que dejara de decir
algo.

**La línea que separa los dos módulos:**

| Módulo | Qué contiene |
|---|---|
| `not_` → `Notificacion` | Lo que el sistema **emite**: la notificación, su canal, sus plantillas, su bitácora de envío. |
| `msj_` → `Mensajeria` | El **tráfico crudo del canal**, en cualquier dirección: lo que entra y lo que el proveedor avisa. |

Con eso, lo que siga —`msj_whatsappconversacion`, `msj_whatsappplantilla`— cae
en su sitio sin volver a renombrar.

Se descartaron: `int_` (Integraciones) —válido, pero apuesta a que lo siguiente
sean webhooks de otros proveedores y no más mensajería—; `wsp_` (WhatsApp)
—convierte un canal en módulo, cuando los módulos de este proyecto son dominios,
y obligaría a renombrar todas las columnas de `whatsappWebhook*` a `webhook*`—;
y `com_` (Comunicación) —se lee igual de fácil como *comercial* o *comentario*—.

> Como el prefijo es nuevo, conviene que quede validado con el equipo
> (`ARQUITECTURA_GENERAL.md` §2.1: no inventar prefijos sin acordarlo).

### Por qué la configuración vive en `config/mensajeria.php`

Al principio estaba en `config/notificaciones.php`, junto al `phone_number_id` y
el `access_token` de la misma aplicación de Meta. Se movió con el cambio de
módulo: dejarla allá habría hecho que `Mensajeria` leyera
`config('notificaciones.*')` — reintroduciendo por la puerta de atrás justo el
acoplamiento que el cambio de prefijo quitó.

Las variables de entorno **siguen llamándose `META_WHATSAPP_*`** a propósito: se
nombran por proveedor, no por módulo, y renombrarlas sólo rompería los `.env` ya
desplegados sin ganar nada.

`config/notificaciones.php` conserva un comentario que apunta hacia acá, para que
quien busque el webhook donde estaba lo encuentre.

### El `RecibirFormRequest` no valida nada, y es a propósito

El resto de los Form Requests del sistema existen para rechazar. Éste existe para
lo contrario.

No es sólo el requisito de "guardar todo sin exclusión". Es cómo funciona Meta:
el sobre (`object` + `entry[].changes[]`) es estable, pero el `value` de adentro
cambia por completo según el `field` suscrito, y la lista de campos crece en cada
versión. Validar el `value` sería congelar hoy una forma que mañana no es la
misma, y perder exactamente los eventos nuevos que interesa ver.

Y rechazar sale caro: un 422 no es un 200, así que Meta lo reintenta 36 horas y
termina desactivando la suscripción. **Un campo que no supimos leer tumbaría el
webhook completo.**

Los largos de columna los resuelve el controlador recortando el resumen
(`texto()`), no rechazando: con MySQL en modo estricto, un valor más largo que su
columna aborta el INSERT, y perderíamos la notificación entera por un campo de
resumen que de todos modos queda completo dentro del payload.

### El 500 del `catch` es deliberado

Es la única respuesta distinta de 200 que `recibir` puede dar, y existe para no
perder nada: si falla el guardado, el 500 hace que Meta reintente en lugar de dar
el evento por entregado.

### La ruta va sin `seguridadFSG`

Ese middleware es un WAF: heurísticas de inyección SQL, XSS y recorrido de rutas
sobre cada campo del cuerpo. Aquí el cuerpo es **texto que escribió un socio en su
WhatsApp** —comillas, apóstrofos, guiones, enlaces, lo que sea— y esas
heurísticas se disparan con facilidad.

Un bloqueo por puntuación no sólo perdería el mensaje: Meta lo reintentaría 36
horas y terminaría desactivando la suscripción, dejando el webhook mudo sin que
nadie lo relacione. Lo que protege esta ruta es más simple y más duro: **sin la
firma correcta del App Secret no pasa nada.** La alternativa —meterle excepciones
al WAF— debilita el WAF para todos los demás.

Es el mismo criterio que ya se aplicó en `routes/modulos/transferencia/membresia.php`.

### La firma se calcula sobre el cuerpo CRUDO

`json_encode(json_decode($cuerpo))` casi nunca devuelve los mismos bytes que mandó
Meta: cambia el escapado de acentos y emojis —y los mensajes de WhatsApp están
llenos de ambos—, el orden de las llaves y los espacios. Un solo byte distinto y
el HMAC no cuadra.

Por eso el middleware firma sobre `$request->getContent()`, la cadena tal cual
llegó. Hay una prueba dedicada a esto
(`test_recibir_valida_la_firma_sobre_el_cuerpo_crudo_con_acentos_y_emoji`).

### Las dos acciones cuelgan de la raíz del submódulo

La arquitectura pide `/{modulo}/{submodulo}/{accion}`, pero la dirección la fija
Meta: **una sola Callback URL** que atiende el GET de verificación y el POST de
los eventos. No se puede partir en `/verificar` y `/recibir` sin dejar de ser lo
que Meta espera.

El patrón se conserva donde sí depende de nosotros: `->name('verificar')` y
`->name('recibir')` coinciden exactamente con los métodos del controlador (§4.4 #4).

### La ruta no se da de alta en `seg_ruta`

Porque no pasa por `permiso:`. Detrás no hay una persona con perfil: es Meta. Es
el mismo caso que la transferencia de membresías.

---

## Cómo se protege la ruta

| Capa | Qué hace |
|---|---|
| `whatsappMeta` (middleware) | Tope de tamaño del cuerpo (413) y verificación del HMAC-SHA256 contra el App Secret (403). |
| `throttle:whatsapp-webhook` | 1000 por minuto por IP. Holgado: Meta manda en ráfaga cuando se acumulan acuses, y apretar el límite se paga con reintentos. |
| `verify_token` | En el `GET`: sin el token configurado, la verificación rechaza todo — nadie puede registrar el webhook a su nombre. |

### El modo `NoVerificada`

Mientras `META_WHATSAPP_APP_SECRET` esté vacío no hay contra qué comparar, así
que la notificación **se guarda marcada como `NoVerificada`** en vez de
rechazarse. Es lo que permite dar de alta el webhook y ver payloads reales antes
de tener el App Secret a la mano.

**Mientras eso pase, la ruta acepta lo que le manden.** En cuanto se configure el
secreto, `META_WHATSAPP_WEBHOOK_FIRMA_ESTRICTA=true` empieza a cerrar la puerta.
No dejar ese hueco abierto más de lo necesario.

---

## Cómo consumir la bandeja

Lo pendiente, lo más reciente primero — usa el índice
`ix_whatsappwebhook_estado_fecha`:

```php
use App\Models\Modulos\Mensajeria\WhatsappWebhook;

$pendientes = WhatsappWebhook::query()
    ->where('whatsappWebhookEstado', WhatsappWebhook::ESTADO_ACTIVO)
    ->orderBy('whatsappWebhookFecha')
    ->limit(100)
    ->get();
```

Sólo los mensajes entrantes de un socio:

```php
WhatsappWebhook::query()
    ->where('whatsappWebhookTipo', WhatsappWebhook::TIPO_MENSAJE)
    ->where('whatsappWebhookTelefono', '5215512345678')
    ->orderByDesc('whatsappWebhookFecha')
    ->get();
```

El acuse de una plantilla que mandamos, por su wamid:

```php
WhatsappWebhook::query()
    ->where('whatsappWebhookTipo', WhatsappWebhook::TIPO_ESTADO)
    ->where('whatsappWebhookEventoId', $wamidQueDevolvioMeta)
    ->get();
```

Y el detalle, siempre desde el payload (viene ya casteado a arreglo):

```php
$texto = $fila->whatsappWebhookPayload['entry'][0]['changes'][0]['value']['messages'][0]['text']['body'] ?? null;
```

> **Deduplica al procesar, no al recibir.** Antes de actuar sobre una fila,
> descarta las que repitan `whatsappWebhookHuella` o `whatsappWebhookEventoId`:
> los reintentos de Meta son normales y la bandeja los conserva a propósito.

---

## Pruebas

`tests/Feature/Mensajeria/WhatsappWebhookTest.php` — **16 pruebas, todas en
verde**:

- El saludo de verificación devuelve el challenge en **texto plano** (no JSON: si
  llega envuelto, Meta no registra la URL).
- Rechaza un `verify_token` que no cuadra, y rechaza también cuando no hay token
  configurado.
- Guarda un mensaje entrante con su resumen completo y el payload íntegro.
- Valida la firma sobre el cuerpo crudo, con acentos y emoji.
- Clasifica un acuse de entrega como `Estado` y saca el teléfono de
  `recipient_id`.
- Guarda una plantilla aprobada (`message_template_status_update`), que no trae
  ni `messages` ni `statuses`.
- **Guarda un `field` que Meta no ha inventado todavía y un sobre deformado** —
  la prueba de que no hay exclusión.
- Cuenta todos los eventos de un lote.
- Rechaza firma inválida y petición sin firma en modo estricto, sin guardar nada.
- Guarda como `NoVerificada` cuando no hay App Secret.
- Rechaza un cuerpo más grande que el tope (413).
- El reintento de Meta no se rechaza y deja la misma huella.

La suite corre sobre SQLite en memoria y el proyecto no lleva las tablas de
negocio en migraciones, así que la prueba levanta la tabla con el mismo perfil de
columnas del SQL. **Si `docs/migration_msj_whatsappwebhook.sql` cambia, el método
`crearTabla()` de la prueba cambia con él.**

> El `ExampleTest` de fábrica de Laravel falla desde antes de este trabajo (pide
> `/`, que en esta aplicación redirige al login). No tiene relación con el
> webhook.

### Prueba de humo ejecutada

Contra `php artisan serve` y la base `padel` real se comprobó, además de la
suite: verificación con token correcto (200 + challenge) e incorrecto (403); POST
sin App Secret (guardado como `NoVerificada`); POST con firma válida, inválida y
ausente (200 / 403 / 403); acuse de estado, plantilla aprobada y un `field`
inventado — los tres guardados. El texto con acentos y emoji volvió íntegro desde
la columna JSON de MySQL. Las filas de prueba se borraron y el `.env` quedó como
estaba.

---

## Lo que falta para que empiece a recibir

1. Correr `docs/migration_msj_whatsappwebhook.sql` en el servidor.
2. Publicar el código.
3. Dar de alta la URL en Meta y suscribir los campos —
   [`whatsapp-webhook-configuracion-meta.md`](whatsapp-webhook-configuracion-meta.md).
4. Copiar el **App Secret** a `META_WHATSAPP_APP_SECRET` en cuanto se tenga, para
   cerrar el modo `NoVerificada`.

---

## Registro de cambios

**2026-08-24 — entrega inicial.** Tabla, modelo, form requests, controlador,
rutas, middleware de firma, configuración y 16 pruebas. Verificado contra MySQL
local.

**2026-08-24 — cambio de módulo: `Notificacion` → `Mensajeria`.** Hecho **antes**
de registrar la URL en Meta, así que no hubo que reverificar nada. Lo que se
movió:

| | Antes | Ahora |
|---|---|---|
| Tabla | `not_whatsappwebhook` | `msj_whatsappwebhook` |
| **URL** | `/api/v1/notificacion/whatsappwebhook` | **`/api/v1/mensajeria/whatsappwebhook`** |
| Modelo | `Modulos\Notificacion\` | `Modulos\Mensajeria\` |
| Controlador / Requests | `…\Notificacion\` | `…\Mensajeria\` |
| Rutas | `routes/modulos/notificacion/` | `routes/modulos/mensajeria/` |
| Pruebas | `tests/Feature/Notificacion/` | `tests/Feature/Mensajeria/` |
| Configuración | `config/notificaciones.php` → `whatsapp.webhook` | `config/mensajeria.php` → `whatsapp.webhook` |
| Script SQL | `migration_not_whatsappwebhook.sql` | `migration_msj_whatsappwebhook.sql` |

**Las columnas no cambiaron**: se derivan de la entidad (`WhatsappWebhook`), no
del prefijo del módulo. Tampoco cambiaron los nombres de las variables de
entorno. En local la tabla vieja estaba vacía y se rehízo desde el script nuevo.

El razonamiento del cambio está arriba, en *Por qué el módulo es `Mensajeria`*.
