Detrás de todo el vocabulario — eventos, entregas, firmas —, un webhook es solo una solicitud HTTP. Si ya entiendes qué es un webhook y cómo funciona, el siguiente paso es saber leer una entrega de punta a punta: cada línea, del método al cuerpo, lleva información que resuelve depuración, deduplicación y seguridad. Vamos a diseccionar una solicitud real, parte por parte.
Una entrega típica, en crudo, se ve así:
POST /webhooks/github HTTP/1.1
Host: tu-app.com
User-Agent: GitHub-Hookshot/f05835d
Content-Type: application/json
Content-Length: 7291
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature-256: sha256=d57c68ca6f92289e6987922ff26938930f6e66a2d161ef06abdf1859230aa23c
{
"ref": "refs/heads/main",
"before": "9049f1265b7d61be4a8904a9a27120d2064dab3b",
"after": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"repository": { "full_name": "acme/api" },
"pusher": { "name": "octocat" },
"commits": [ "..." ]
}Tres bloques: la línea de solicitud (método y ruta), los headers y el cuerpo. Cada uno responde una pregunta distinta: ¿qué hago con esto?, ¿es confiable y es nuevo? y ¿qué pasó exactamente?
La línea de solicitud: por qué casi siempre es POST
Los webhooks transportan un hecho nuevo — un evento — y por eso usan POST: es el método HTTP para enviar datos que el servidor debe procesar. La ruta (/webhooks/github en el ejemplo) la eliges tú al registrar la URL; muchos equipos crean una ruta por proveedor, lo que simplifica el ruteo, los logs y los permisos.
La excepción clásica es la verificación del endpoint. Algunos proveedores, antes de enviar cualquier evento, confirman que la URL es tuya. Meta (WhatsApp Business, Instagram) hace un GET de handshake: GET /webhooks?hub.mode=subscribe&hub.verify_token=TU_TOKEN&hub.challenge=158201444. Tu servidor debe comprobar el hub.verify_token y responder 200 con el valor de hub.challenge en el cuerpo. Si tu endpoint solo acepta POST, esa verificación falla — y la integración ni siquiera comienza.
Los headers, grupo por grupo
Los headers son la parte más subestimada de una entrega. Se dividen en tres grupos con funciones distintas.
Identificación del evento y de la entrega
Antes de abrir el cuerpo, los headers ya dicen qué tipo de evento llegó y cuál entrega es esta:
X-GitHub-Event— el tipo de evento (push,pull_request…). Permite rutear el procesamiento sin parsear el JSON.X-GitHub-Delivery— un UUID único por entrega. Si GitHub reenvía el mismo evento, el UUID cambia; es el identificador que citas en un ticket de soporte.webhook-id— en el estándar Standard Webhooks (adoptado por un número creciente de proveedores), este header identifica el evento y se mantiene igual en los reintentos: es la clave ideal para deduplicación.
La distinción importa: un ID de entrega cambia en cada intento; un ID de evento no. Para implementar idempotencia quieres el segundo — si el proveedor solo ofrece el primero, usa el id de dentro del cuerpo.
Firma y seguridad
Tu endpoint es público, así que cualquiera puede enviarle un POST. El header de firma es lo que prueba que la solicitud vino realmente del proveedor: un HMAC calculado sobre el cuerpo con un secreto que solo ustedes dos conocen. Cada proveedor lo empaqueta a su manera:
| Proveedor | Header | Formato / algoritmo |
|---|---|---|
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 del cuerpo, en hex, con prefijo sha256= |
| Stripe | Stripe-Signature | t=timestamp,v1=hex — HMAC-SHA256 sobre {t}.{cuerpo}, el timestamp entra en el cálculo |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 del cuerpo, codificado en Base64 |
| Mercado Pago | x-signature | ts=…,v1=hex — HMAC-SHA256 sobre un manifest con data.id, x-request-id y el timestamp |
| Standard Webhooks | webhook-signature | v1,base64 — HMAC-SHA256 sobre {id}.{timestamp}.{cuerpo} |
Fíjate en el patrón: cuando el timestamp participa del cálculo (Stripe, Mercado Pago, Standard Webhooks), el proveedor está cerrando la puerta a los replay attacks — el reenvío de una entrega antigua capturada. El mecanismo completo de validación, incluida la comparación en tiempo constante y la tolerancia de reloj, está en Seguridad de webhooks: firma HMAC y buenas prácticas.
Contenido y metadatos
Content-Type— casi siempreapplication/json, pero no lo des por sentado. GitHub puede configurarse paraapplication/x-www-form-urlencoded, entregando el JSON dentro de un campopayload=. Sistemas legados (y algunas pasarelas de pago antiguas) todavía envían XML.Content-Length— el tamaño del cuerpo en bytes. Útil para detectar payloads truncados por proxies y para definir límites en tu servidor.User-Agent— identifica al remitente (GitHub-Hookshot/f05835d,Stripe/1.0 (+https://stripe.com/docs/webhooks)…). Sirve para filtros gruesos y estadística, pero es falsificable — nunca lo uses como autenticación.
El cuerpo: envelope, evento y datos
Aunque cada proveedor tiene su propio esquema, la mayoría converge en el mismo envelope: un identificador, un tipo, un timestamp y los datos del evento. Es el contrato mínimo para rutear, deduplicar y ordenar.
{
"id": "evt_1QxK2mLkdIwHu7ix", // deduplicación
"type": "payment.approved", // ruteo
"created_at": "2026-07-25T14:32:07Z", // orden / tolerancia
"data": { "...": "..." } // el evento en sí
}La gran división está en el contenido de data. Hay dos filosofías:
- Payload "gordo" (fat payload) — el evento lleva el objeto completo. GitHub y Stripe funcionan así: el push trae los commits, el evento de pago trae el objeto
payment_intententero. Procesas sin llamadas extra, pero el dato puede estar desactualizado si los eventos llegan fuera de orden. - Payload "delgado" (thin payload) — el evento trae solo referencias, y tú consultas la API para obtener el estado actual. Es el modelo de Mercado Pago:
{
"id": 117554765,
"type": "payment",
"action": "payment.updated",
"date_created": "2026-07-25T14:32:07Z",
"data": { "id": "1316643861" }
}El thin payload obliga a una llamada a la API (GET /v1/payments/1316643861) antes de cualquier decisión — más latencia y un punto de falla adicional, pero el estado que lees siempre es el actual, y un payload filtrado expone menos datos. Saber en cuál filosofía encaja tu proveedor define la arquitectura de tu consumidor.
Raw body: la trampa número uno
La firma HMAC se calcula sobre los bytes exactos del cuerpo. No sobre "el JSON equivalente" — sobre la secuencia de bytes. Si tu framework parsea el cuerpo antes de que valides (el clásico express.json() global), lo que queda es un objeto JavaScript; volver a serializarlo con JSON.stringify cambia espacios, orden de claves o escapes — y la firma no vuelve a coincidir. Es, por lejos, la causa más común de "la validación del webhook falla solo en producción".
// ❌ con el parser global, los bytes originales se pierden
app.use(express.json());
// ✅ opción 1 — raw body solo en la ruta del webhook
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
// req.body es un Buffer con los bytes exactos
const ok = verificarFirma(req.body, req.headers["stripe-signature"]);
if (!ok) return res.sendStatus(400);
res.sendStatus(200);
procesarDespues(JSON.parse(req.body));
}
);
// ✅ opción 2 — mantener express.json() y guardar el raw vía "verify"
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);El mismo principio vale para Next.js (desactiva el body parser de la ruta), Fastify (rawBody vía plugin) y cualquier otro framework: valida sobre el buffer crudo, parsea después.
La respuesta: qué espera el proveedor de ti
- Un status 2xx confirma la recepción.
200y204son los habituales; la mayoría de los proveedores ignora el cuerpo de la respuesta — no pierdas tiempo armando uno. - Rápido: el reloj corre. Los timeouts típicos van de 5 segundos (Shopify) a 10–30 segundos (GitHub, Stripe y otros). Si te pasas del tiempo, la entrega cuenta como fallida — aunque tu código haya terminado después. De ahí la regla: responde ya, procesa asíncrono.
- Los redirects cuentan como falla. La mayoría de los proveedores no sigue
3xx. Un redirect dehttpahttpso de dominio con/sinwwwbasta para "perder" webhooks — registra la URL final exacta. - 4xx y 5xx alimentan la política de reintentos. Ambos disparan nuevos intentos con backoff, pero las fallas persistentes tienen consecuencias: GitHub, Stripe y Shopify desactivan o eliminan endpoints que solo devuelven error durante días. Un detalle útil del estándar Standard Webhooks: responder
410 Goneseñala "deja de enviar aquí".
Resumen
- Un webhook es POST + headers + cuerpo; el GET de verificación (challenge de Meta) es la excepción que tu endpoint debe soportar.
- Los headers responden "qué es, es nuevo, es confiable": tipo de evento, ID de entrega/evento y firma HMAC — cada proveedor con su formato.
- El cuerpo sigue un envelope (id, type, timestamp, data) y viene "gordo" (GitHub, Stripe) o "delgado" (Mercado Pago) — la elección cambia tu arquitectura.
- Valida la firma sobre el raw body (bytes exactos), nunca sobre el JSON vuelto a serializar.
- Responde 2xx en milisegundos, sin redirects; los errores repetidos generan reintentos y pueden desactivar tu endpoint.