Un webhook es una llamada API que tú no inicias. Esa inversión crea tres trabajos: demostrar quién lo envió, hacer que la entrega repetida sea inofensiva y confirmar recepción antes de que tu trabajo útil comience. Hazlos bien y los webhooks se vuelven infraestructura aburrida, que es exactamente lo que quieres.
🎙️ Publicado y grabado: ·
Con una API normal, tu código hace una pregunta cuando quiere una respuesta. Con un webhook, publicas una dirección y el sistema de otra persona la llama cuando ocurre un evento. Polling pregunta "¿cambió el pago?" cada minuto. Un webhook dice "avísame cuando cambie". Usa webhooks para notificación oportuna, luego usa la API del proveedor cuando necesites el registro autoritativo actual.
# API: your app initiates
GET https://api.example.com/payments/pay_42
# webhook: provider initiates
POST https://your-app.com/webhooks/payments
Content-Type: application/json
{"id":"evt_91","type":"payment.succeeded","data":{"payment_id":"pay_42"}}Trata el webhook como una notificación, no como un comando mágico. Si la consecuencia es costosa o sensible, obtén el objeto desde la API del proveedor después de verificar el evento. Eso te protege de campos obsoletos y mantiene el registro del proveedor como autoritativo.
Dale a cada proveedor su propia ruta, acepta solo POST, requiere HTTPS en producción y mantén el handler deliberadamente pequeño. Lee los headers, preserva los bytes originales, autentica el mensaje, registra el evento, encola trabajo y retorna. No canalices cinco proveedores por una ruta genérica ingeniosa. Sus firmas, reglas de reintento y versiones de payload divergirán.
POST /webhooks/acme
# Keep the envelope. It is your audit trail and deduplication key.
{
"id": "evt_91",
"type": "invoice.paid",
"created_at": "2026-07-24T12:30:00Z",
"data": { "invoice_id": "inv_7" }
}Fija la versión de la API de webhook del proveedor cuando esa opción exista. Almacena el tipo de evento, ID de evento del proveedor, hora de recepción, resultado de verificación de firma y estado de procesamiento. Los payloads que solo viven en logs de aplicación se vuelven imposibles de reproducir limpiamente.
Un endpoint público puede ser llamado por cualquiera. Una URL secreta no es autenticación; las URLs se filtran en logs y capturas de pantalla. Los buenos proveedores firman los bytes exactos de la petición con un secreto compartido. Recalcula esa firma, compárala en tiempo constante y rechaza timestamps antiguos para que una petición capturada no pueda ser reenviada indefinidamente.
import { createHmac, timingSafeEqual } from "node:crypto";
function validSignature(rawBody, timestamp, suppliedHex, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(String(timestamp)).update(".").update(rawBody).digest();
const supplied = Buffer.from(suppliedHex, "hex");
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}Usa el signing string documentado del proveedor y su librería oficial cuando exista. Algunos firman solo el body; otros firman un timestamp, un punto y el body. "HMAC SHA-256" no te dice el formato de bytes. Rota secretos con un breve solapamiento donde tanto la clave antigua como la nueva verifican.
La verificación de firma opera sobre bytes, no sobre el objeto JavaScript que tu parser JSON crea. Parsear y serializar puede cambiar espacios, escapes o formato de claves. Los datos siguen viéndose idénticos para una persona, pero la firma ya no coincide. Captura el cuerpo crudo de la petición primero, verifícalo, y parsea JSON solo después de que la verificación tenga éxito.
Webhook signature verification failed.
Error: No signatures found matching the expected signature for payload.
# Express: raw middleware must run before express.json()
app.post("/webhooks/acme", express.raw({ type: "application/json" }), handler);
# Put app.use(express.json()) after the webhook route.Uno: registra el tipo del body y confirma que es un Buffer o byte array, no un objeto. Dos: mueve el middleware de raw-body antes de todo JSON parser que pueda tocar esta ruta. Tres: computa la firma sobre esos bytes intactos con el secreto actual del endpoint. Cuatro: confirma que los nombres de header de timestamp y firma coinciden con los docs del proveedor. Nunca "arregles" esto saltando la verificación.
La entrega de webhooks es típicamente al menos una vez. Si el proveedor no recibe tu respuesta, no puede saber si procesaste el evento, así que envía el mismo evento de nuevo. Los duplicados son normales, no un caso extremo. Pon un constraint unique en el ID de evento del proveedor y haz la operación de negocio resultante también idempotente. Además asume que los eventos pueden llegar fuera de orden.
CREATE TABLE webhook_events (
provider text NOT NULL, event_id text NOT NULL,
event_type text NOT NULL, payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);
INSERT INTO webhook_events(provider,event_id,event_type,payload)
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING;Si PostgreSQL dice duplicate key value violates unique constraint "webhook_events_pkey", el constraint hizo su trabajo pero tu ruta de insert no. Uno: cambia el insert a ON CONFLICT DO NOTHING. Dos: verifica si se insertó una fila. Tres: encola solo cuando fue nueva. Para el ordenamiento, compara versiones de objeto del proveedor o timestamps de eventos e ignora transiciones obsoletas; nunca deduzcas secuencia del tiempo de llegada.
Tu camino síncrono debe verificar, registrar atómicamente el evento más el trabajo pendiente, y responder con un 2xx. Después un worker envía emails, actualiza búsqueda, llama APIs lentas y ejecuta lógica de negocio. "Terminar el procesamiento de negocio y luego devolver 200" es un mal default. Acopla la entrega del proveedor a cada dependencia que posees y fabrica reintentos durante lentitud ordinaria.
async function webhook(req, res) {
const event = verifyAndParse(req.rawBody, req.headers);
// One database transaction writes both the inbox event and
// an outbox job. A dispatcher publishes unsent outbox rows.
await db.transaction(tx => tx.storeEventAndOutboxIfNew(event));
res.status(204).end();
}Un dashboard de proveedor puede reportar 504 Gateway Timeout mientras tus logs muestran que el trabajo terminó segundos después. Uno: mide el tiempo del handler e identifica llamadas después de la verificación. Dos: mueve cada llamada lenta a un worker. Tres: haz commit de la fila inbox y job outbox en una transacción, o usa una cola con un handoff atómico equivalente. Cuatro: devuelve 204 inmediatamente después. Un dispatcher puede reintentar filas outbox no enviadas tras un crash, así que un evento confirmado no puede quedarse varado entre base de datos y cola.
Un proveedor en internet no puede llamar a localhost en tu portátil. Usa el CLI oficial de webhook forwarding del proveedor cuando esté disponible; si no, usa un túnel HTTPS de buena reputación. Apunta el proveedor a la dirección HTTPS temporal, pero mantén la verificación de firma habilitada y usa un secreto de test. Un túnel expone tu máquina a tráfico real.
# First prove the local route itself works
curl -i -X POST http://127.0.0.1:3000/webhooks/acme \
-H "Content-Type: application/json" \
--data-binary @fixture.json
# Then start the provider CLI/tunnel and register its HTTPS URL:
https://temporary-host.example/webhooks/acmeSi curl imprime curl: (7) Failed to connect to localhost port 3000: Connection refused, ningún proceso está escuchando ahí. Uno: inicia la app. Dos: confirma su puerto real en el log de inicio. Tres: haz bind a la interfaz requerida por tu túnel o contenedor. Cuatro: vuelve a ejecutar curl local antes de depurar el túnel. Copia una entrega de test real en un fixture para poder reproducir los mismos bytes repetidamente.
Un sistema de webhooks útil tiene una tabla inbox, intentos de cola y un camino de dead-letter. Registra el ID de evento, tipo, latencia de recepción, resultado de verificación, respuesta HTTP, intento de procesamiento y estado final. Nunca registres secretos de firma o headers de autorización. Tu dashboard debe responder tres preguntas rápido: ¿lo recibimos, lo aceptamos y qué pasó después?
# One searchable structured record
{
"provider":"acme", "event_id":"evt_91", "type":"invoice.paid",
"verified":true, "response_status":204, "queue_id":"job_6",
"processing_state":"succeeded", "attempt":1
}Mantén una herramienta de replay controlada que re-encole un evento almacenado y ya verificado por ID. No repliques forjando una nueva petición HTTP entrante a menos que intencionalmente quieras probar la verificación también. Alerta sobre edad creciente de la cola y fallos repetidos, no sobre cada reintento individual.
Empieza en el límite y muévete hacia dentro. Log de entrega del proveedor, proxy de borde, recepción de aplicación, verificación de firma, insert en inbox, publicación en cola, resultado del worker. Adivinar desde el síntoma final de negocio pierde tiempo porque un webhook puede fallar en seis capas distintas.
404 Not Found # wrong public path or deployment
405 Method Not Allowed # route does not accept POST
415 Unsupported Media Type # body parser rejects Content-Type
Webhook signature verification failed. # bytes/secret/header mismatch
504 Gateway Timeout # handler answered too slowlyPara 404, copia la URL exacta de entrega, compara su path con la ruta desplegada y haz curl a esa URL pública. Para 405, registra POST en esa ruta y comprueba si un proxy reescribe el método. Para 415, inspecciona el Content-Type recibido, configura bytes crudos para ese media type y no parsees primero. Para un fallo de firma, verifica bytes crudos, secreto específico del endpoint, formato de firma y reloj. Para 504, mueve el trabajo de negocio detrás de una cola durable y confirma antes.
El checklist de producción es corto porque el diseño debe ser corto.
# inbound path
POST only → capture raw bytes → verify signature + timestamp
→ parse JSON → insert unique event ID → durable queue → 204
# worker path
load stored event → idempotent business action → mark succeeded
retry transient failures with backoff → dead-letter permanent failures
# assumptions
duplicates: yes · delayed delivery: yes · out of order: yes
one delivery only: no · secret URL is auth: no · arrival order is truth: no
# debug order
provider log → public URL/status → app receipt → signature
→ inbox row → queue job → worker result
# localhost
local curl first → official forwarder or HTTPS tunnel → test secretMi versión con opinión: mantén el receptor tonto y haz al worker capaz. Si el receptor sabe verificar, persistir, encolar y responder, sobrevivirá picos de tráfico y dependencias caídas. Si además envía recibos, actualiza cinco tablas y llama tres APIs, eventualmente convertirá una caída de cinco segundos en un incidente de entrega duplicada.