# Configuración del webhook en el panel de Meta

**Fecha:** 2026-08-24 · **Proyecto:** CL001 Pádel (PR006BACKPADEL)
**Para:** quien da de alta la Callback URL en `developers.facebook.com`.

Este documento es el procedimiento de alta. La explicación de qué hace el
webhook y por qué está hecho así vive en
[`whatsapp-webhook.md`](whatsapp-webhook.md).

---

## Lo que hay que tener a la mano

| Dato | De dónde sale |
|---|---|
| **Callback URL** | `https://{dominio}/api/v1/mensajeria/whatsappwebhook` |
| **Verify token** | `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN` del `.env` del servidor |
| **App Secret** | Panel de Meta → **Configuración de la app › Básica** → *Clave secreta de la app* |

El **Verify token** ya viene generado en el `.env` de este proyecto. No hay que
inventar uno nuevo: se copia el que está y se pega en Meta. Son la misma cadena
en los dos lados; si no coinciden, Meta no registra la URL.

---

## Antes de tocar el panel

**1. La tabla tiene que existir en el servidor.**

```bash
# Correr por pasos, siguiendo los comentarios del archivo.
docs/migration_msj_whatsappwebhook.sql
```

**2. El código tiene que estar publicado**, y la ruta responder:

```bash
php artisan route:list --path=whatsappwebhook
```

Debe listar las dos:

```
GET|HEAD  api/v1/mensajeria/whatsappwebhook   api.mensajeria.whatsappwebhook.verificar
POST      api/v1/mensajeria/whatsappwebhook   api.mensajeria.whatsappwebhook.recibir
```

**3. Si la configuración está cacheada, refrescarla.** El `verify_token` se lee
de `config()`, y con el caché viejo la verificación falla aunque el `.env` esté
bien:

```bash
php artisan config:clear    # o config:cache, según cómo esté el servidor
```

**4. HTTPS con certificado válido.** Meta lo exige y es la causa más común de que
la verificación falle sin explicación:

- El certificado debe estar emitido por una **CA reconocida**.
  **Los autofirmados no sirven** — Meta los rechaza.
- La URL tiene que ser **pública**. `localhost`, una IP privada o un servidor
  detrás de VPN no funcionan. Para probar en local hace falta un túnel público
  (ngrok o equivalente) y registrar temporalmente esa URL.

**5. La app y el número, los correctos.** En el encabezado del panel aparece
**Modo de la app**: para operar con el número real del negocio tiene que estar en
*Activo*, no en *Desarrollo*. Y el webhook se registra en **la misma app** de la
que se toma el App Secret — si la cuenta tiene varias, revisar el selector de app
de arriba a la izquierda antes de empezar.

---

## Comprobar el saludo ANTES de ir al panel

Esto es lo que Meta va a hacer. Si funciona aquí, funciona allá — y si falla, se
falla en una terminal y no en un formulario que no dice por qué.

```bash
curl -i -G "https://{dominio}/api/v1/mensajeria/whatsappwebhook" \
  --data-urlencode "hub.mode=subscribe" \
  --data-urlencode "hub.verify_token={EL_VERIFY_TOKEN_DEL_ENV}" \
  --data-urlencode "hub.challenge=1158201444"
```

Tiene que responder **exactamente** esto:

```
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

1158201444
```

El cuerpo es el challenge **pelado**, sin JSON alrededor. Si sale envuelto en un
objeto, o el código no es 200, Meta no registra la URL.

Y con un token equivocado tiene que dar **403**:

```bash
curl -i -G "https://{dominio}/api/v1/mensajeria/whatsappwebhook" \
  --data-urlencode "hub.mode=subscribe" \
  --data-urlencode "hub.verify_token=intruso" \
  --data-urlencode "hub.challenge=1158201444"
```

---

## El alta en el panel

1. Entrar a **developers.facebook.com** con la cuenta que administra la app, y
   abrir la aplicación de WhatsApp del cliente.

2. Ir a **WhatsApp › Configuración** (`App Dashboard > WhatsApp > Configuration`).

   > Si la app se creó con el caso de uso *"Connect with customers through
   > WhatsApp"*, el camino es **Casos de uso › Personalizar › Configuración**.

3. En la sección **Webhook**, pulsar **Editar**. Se abre un formulario con dos
   campos:

   | Campo | Qué se pega |
   |---|---|
   | **URL de devolución de llamada** (*Callback URL*) | `https://{dominio}/api/v1/mensajeria/whatsappwebhook` |
   | **Token de verificación** (*Verify token*) | el valor de `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN` |

4. Pulsar **Verificar y guardar**.

   Meta manda en ese momento el `GET` de verificación. Si la ruta contesta el
   challenge, el panel guarda solo y el formulario se cierra. Si no, Meta muestra
   un error genérico — ver *Cuando falla* abajo.

5. Ya guardada la URL, aparece la lista de **campos del webhook**
   (*Webhook fields*). Pulsar **Administrar** y suscribir:

   | Campo | Qué trae | ¿Suscribir? |
   |---|---|---|
   | `messages` | Los mensajes que manda el socio **y** los acuses (`sent`, `delivered`, `read`, `failed`) de las plantillas que enviamos. | **Sí — es el importante** |
   | `message_template_status_update` | Plantillas aprobadas, rechazadas o pausadas. | Recomendado |
   | `message_template_quality_update` | Baja de calidad de una plantilla. | Recomendado |
   | `phone_number_quality_update` | Baja de calidad del número y cambios de límite de envío. | Recomendado |
   | `account_update` / `account_alerts` | Alertas y cambios de la cuenta. | Recomendado |

   La bandeja guarda **cualquier** campo que llegue, incluidos los que no están
   en esta tabla: suscribir de más no rompe nada. Lo que no se suscribe
   simplemente no lo manda Meta.

6. **Comprobar que ya está entrando.** Mandar un WhatsApp al número del negocio
   desde un celular y consultar:

   ```sql
   SELECT idWhatsappWebhook, whatsappWebhookCampo, whatsappWebhookTipo,
          whatsappWebhookTelefono, whatsappWebhookFirmaValida, whatsappWebhookFecha
     FROM msj_whatsappwebhook
    ORDER BY idWhatsappWebhook DESC
    LIMIT 10;
   ```

   Tiene que aparecer una fila con `whatsappWebhookTipo = 'Mensaje'` y el
   teléfono desde el que se escribió.

---

## Cerrar el modo `NoVerificada` — no dejarlo pendiente

Mientras `META_WHATSAPP_APP_SECRET` esté vacío, la ruta **no puede comprobar que
quien escribe es Meta**: guarda todo marcado como `NoVerificada` y acepta lo que
le manden. Eso es a propósito, para poder registrar el webhook y ver payloads
reales antes de tener el secreto — pero es un hueco abierto.

### Cuál es exactamente el App Secret

Panel de Meta → **Configuración de la app › Básica**. En la columna derecha, el
campo **Clave secreta de la app**, con los puntos y el botón **Mostrar**. Pide de
nuevo la contraseña de Facebook, y hace falta rol de *Administrador* o
*Desarrollador* en la app. Son **32 caracteres hexadecimales**.

Con qué no confundirlo:

| Dato | Aspecto | ¿Es éste? |
|---|---|---|
| **Clave secreta de la app** | 32 hex | **Sí** |
| `META_WHATSAPP_ACCESS_TOKEN` | empieza con `EAA…`, muy largo | No — ése autoriza a pádel a hablarle a Meta; el App Secret prueba que quien habla **es** Meta |
| `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN` | lo inventamos nosotros | No — sólo sirve para el saludo inicial |
| *Identificador de la app* | numérico, público | No |
| `META_WHATSAPP_PHONE_ID` | numérico | No |

> Tiene que ser el App Secret de **la misma app donde está registrado el
> webhook**. Si la cuenta tiene varias apps y se toma el de otra, la firma no
> cuadrará nunca.

### Activarlo en dos tiempos

No poner el secreto y el modo estricto a la vez. Si el secreto fuera el
equivocado, con estricto encendido **todo llegaría como 403** y el webhook se
quedaría mudo sin dejar una sola fila que lo explique.

1. Pegar el secreto con el modo estricto **apagado**:

   ```dotenv
   META_WHATSAPP_APP_SECRET=el-app-secret-copiado
   META_WHATSAPP_WEBHOOK_FIRMA_ESTRICTA=false
   ```

2. `php artisan config:clear` (o `config:cache`) y mandar un WhatsApp de prueba.

3. Leer el veredicto. Con estricto apagado la fila se guarda igual, sólo queda
   marcada — que es justo lo que permite comprobar el secreto sin perder nada:

   ```sql
   SELECT idWhatsappWebhook, whatsappWebhookFirmaValida, whatsappWebhookFecha
     FROM msj_whatsappwebhook
    ORDER BY idWhatsappWebhook DESC
    LIMIT 5;
   ```

   `Si` → el secreto es el correcto. `No` → es de otra app; corregirlo antes de
   seguir.

4. Sólo cuando salga `Si`, cerrar la puerta:

   ```dotenv
   META_WHATSAPP_WEBHOOK_FIRMA_ESTRICTA=true
   ```

   y otro `php artisan config:clear`.

> **El secreto no se puede comprobar contra las filas ya guardadas.** La tabla
> conserva el header de la firma y el payload decodificado, pero **no los bytes
> crudos** — y el HMAC sólo cuadra sobre esos bytes exactos. La comprobación
> tiene que ser siempre con un mensaje nuevo.

Si con `FIRMA_ESTRICTA=true` dejan de llegar filas y aparecen 403, el App Secret
está mal copiado. **No apagar el modo estricto para salir del paso**: eso vuelve
a abrir la puerta. Volver al punto 1 y corregir el secreto.

---

## Cuando falla

| Síntoma | Causa casi siempre |
|---|---|
| Meta dice que no pudo validar la URL | El `verify_token` del panel no es el del `.env`, o la configuración quedó cacheada (`config:clear`). |
| Meta no llega a tocar el servidor | Certificado autofirmado o vencido, o la URL no es pública. |
| El `curl` de arriba responde JSON en vez del challenge pelado | Hay un proxy o un middleware global envolviendo la respuesta. La ruta devuelve `text/plain` a propósito. |
| Verifica bien pero no llega ningún mensaje | Faltó el paso 5: la URL está guardada pero sin campos suscritos. |
| Llegan 403 en el log del webhook | El App Secret no corresponde a la app, o hay algo que reescribe el cuerpo antes de Laravel (la firma se calcula sobre los bytes exactos). |
| Llegan 429 | Se topó `throttle:whatsapp-webhook` (1000/min por IP). Subirlo en `AppServiceProvider::LIMITES_POR_MINUTO`. |
| Meta desactivó la suscripción sola | Estuvo mucho tiempo sin recibir 200. Revisar por qué fallaba, arreglarlo y volver a suscribir los campos. |

Para ver qué contestó la ruta:

```bash
tail -n 100 storage/logs/laravel.log
```

Y la tabla `base_error` guarda las excepciones de `recibir` (se registran con el
alias `WhatsappWebhookController@recibir`, junto con el payload que las causó).

---

## Resumen de variables de entorno

Las lee `config/mensajeria.php` (bloque `whatsapp.webhook`). Siguen llamándose
`META_WHATSAPP_*` porque se nombran por proveedor, no por módulo: son las
credenciales de la misma aplicación de Meta que usa el canal de salida.

```dotenv
# Se pega igual en el campo "Token de verificación" del panel de Meta.
META_WHATSAPP_WEBHOOK_VERIFY_TOKEN=af918bb6...        # ya generado en el .env

# Configuración de la app › Básica → Clave secreta de la app.
# Mientras esté vacío, todo se guarda como 'NoVerificada'.
META_WHATSAPP_APP_SECRET=

# Con true, una firma que no cuadra se rechaza con 403 y no se guarda.
META_WHATSAPP_WEBHOOK_FIRMA_ESTRICTA=true

# Tope del cuerpo en bytes. Un webhook de WhatsApp ronda 1 KB.
META_WHATSAPP_WEBHOOK_MAXIMO_BYTES=524288
```
