Webhooks paso a paso#
Los webhooks son la única fuente de verdad del resultado de un pago. El navegador se cierra, la red se cae, el pagador cambia de pestaña: nada de eso detiene el cobro en la pasarela. Lo que llega a tu servidor firmado por ApiPay es lo que pasó.
Esta es la guía más importante del portal. Los cinco pasos son obligatorios y el orden importa:
- Lee el cuerpo crudo, antes de que ningún middleware lo parsee.
- Verifica la firma con el helper del SDK.
- Deduplica por
event.idantes de tocar tu base de datos. - Responde
2xxrápido y manda el trabajo pesado a una cola. - Trata los tipos desconocidos como ignorables, no como error.
El envelope#
{
"id": "evt_3f8a1c9d2e4b6a07c5d1e9f2",
"type": "payment_intent.succeeded",
"created": "2026-08-07T14:30:12.418Z",
"livemode": false,
"data": {
"object": {
"id": "pi_9f2c4a7d1b3e4f60a8c5d2e7",
"object": "payment_intent",
"amount_minor": 1499000,
"currency": "CLP",
"status": "SUCCEEDED",
"gateway_id": "webpay",
"redirect_url": null,
"metadata": { "order_id": "4831" },
"livemode": false,
"created_at": "2026-08-07T14:30:00Z"
}
}
}
| Miembro | Qué es |
|---|---|
id | Identificador del evento, prefijo evt_ y 24 base62. Estable entre reintentos: es tu clave de deduplicación |
type | Tipo del evento, del catálogo de abajo |
created | Instante en que se generó el evento, ISO 8601 UTC |
livemode | false cuando el evento pertenece a datos de prueba |
data.object | El recurso afectado: un payment intent o un refund, según el type |
Catálogo de eventos#
| Tipo | data.object | Cuándo |
|---|---|---|
payment_intent.requires_action | payment intent | El intent pasó a REQUIRES_ACTION y trae redirect_url |
payment_intent.succeeded | payment intent | Cobro aprobado |
payment_intent.failed | payment intent | Rechazado por el emisor o la pasarela |
payment_intent.canceled | payment intent | Cancelado por el comercio |
payment_intent.expired | payment intent | Caducó sin completarse |
refund.succeeded | refund | Reembolso liquidado por la pasarela |
refund.failed | refund | La pasarela rechazó el reembolso |
Son siete hoy y va a haber más: los tipos nuevos son un cambio aditivo del contrato y no cambian la
versión mayor. Un switch que falle ante un type que no conoce es un bug esperando a que
publiquemos un evento.
Paso 1 · Obtén el cuerpo CRUDO#
La firma se calcula sobre "{t}." + los bytes exactos del cuerpo. Cualquier reserialización —un
JSON.stringify sobre el objeto ya parseado, un middleware que reordena claves, un framework que
normaliza espacios en blanco— produce bytes distintos y la firma deja de coincidir.
| Framework | Cómo obtener los bytes originales |
|---|---|
| Express | express.raw({ type: 'application/json' }) solo en la ruta del webhook; req.body es un Buffer |
| Fastify | addContentTypeParser con { parseAs: 'buffer' } en un plugin encapsulado para esa ruta |
| Flask | request.get_data(), que devuelve bytes. Nunca request.json antes de verificar |
| FastAPI | await request.body() con el parámetro request: Request, sin modelo Pydantic |
| Laravel | $request->getContent(). Nunca $request->all() ni $request->json() |
| PHP sin framework | file_get_contents('php://input') |
| Spring Boot | @RequestBody byte[] rawBody, o HttpServletRequest.getInputStream(). Nunca un DTO |
import express from 'express';
const app = express();
// El parser JSON global va montado en el resto de la app, NO en /webhooks.
app.use('/api', express.json());
// express.raw solo aqui: req.body queda como Buffer con los bytes tal cual llegaron.
app.post('/webhooks/apipay', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body; // Buffer con los bytes originales
const signature = req.header('ApiPay-Signature') ?? '';
// verificarYProcesar es la funcion del paso 2: verifica, deduplica y encola.
verificarYProcesar(rawBody, signature, res);
});Paso 2 · Verifica la firma#
Cada entrega lleva la cabecera:
ApiPay-Signature: t=1786898412,v1=6f2c1a9e4b7d0c38a51e2f9b8c4d6a70e3f15b29c8d4a6e0b7f2c1938a5e4d0c
tson los segundos Unix en que se firmó el evento.v1es el HMAC-SHA256 en hexadecimal de"{t}." + cuerpo_crudo, con el secreto de firma del endpoint (whsec_…) como clave.
Los cuatro SDKs traen el helper y hacen las cuatro cosas que hay que hacer: comparación en tiempo
constante, tolerancia de 300 s en ambos sentidos, aceptación de múltiples v1 y
excepción —nunca un booleano silencioso— si algo no cuadra.
import { constructEvent, SignatureVerificationError } from '@apipay/node';
let event;
try {
event = constructEvent(
rawBody, // Buffer o string, sin parsear
signature, // valor completo de la cabecera ApiPay-Signature
process.env.APIPAY_WEBHOOK_SECRET ?? '',
);
} catch (error) {
if (error instanceof SignatureVerificationError) {
// No verifica: 400 y no se procesa nada. No loguear el cuerpo.
res.status(400).send();
return;
}
throw error;
}
// A partir de aqui el evento es autentico y esta tipado:
// event.id, event.type, event.livemode, event.data.objectLa tolerancia de 300 segundos#
Una entrega cuyo t esté a más de 300 s del reloj de tu servidor —en cualquiera de los dos
sentidos— se rechaza. Es la protección anti-replay: sin ella, quien capture una entrega válida puede
reenviarla mañana y tu sistema la aceptaría como nueva.
Rotación de secretos: dos v1 en la misma cabecera#
Durante una rotación, ApiPay firma cada entrega dos veces, una con el secreto anterior y otra con
el nuevo, y la cabecera lleva dos elementos v1:
ApiPay-Signature: t=1786898412,v1=6f2c1a9e…4d0c,v1=b7e3d1a0…92f4
Basta con que una coincida. No existe kid ni ningún selector: los helpers de los cuatro SDKs
prueban todos los candidatos —sin cortocircuito, para no filtrar por tiempo cuál coincidió— y aceptan
si alguno cuadra. Eso te deja rotar en tres pasos sin ventana de caída:
- Generas el secreto nuevo en el backoffice; a partir de ese momento llegan dos
v1. - Despliegas tu servicio con el secreto nuevo en configuración.
- Cierras la rotación en el backoffice; vuelve a llegar un solo
v1.
Paso 3 · Deduplica por event.id ANTES de procesar#
La entrega es at-least-once. Un mismo evento puede llegar dos veces: se cayó la conexión después de
procesarlo pero antes de responder, tu balanceador reintentó, hubo un despliegue en medio. El id
(evt_…) es el mismo en todos los reintentos de un evento, así que es la clave natural para no
cobrar, despachar o abonar dos veces.
La forma más simple y robusta es una tabla con el evt_ como clave primaria, dejando que la base de
datos resuelva la carrera:
CREATE TABLE apipay_processed_events (
event_id text PRIMARY KEY,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
-- Devuelve 1 fila la primera vez y 0 filas en cualquier reintento.
INSERT INTO apipay_processed_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
// 0 filas => ya lo procesamos: responder 200 y no volver a hacer nada.
const { rowCount } = await db.query(
'INSERT INTO apipay_processed_events (event_id, event_type) VALUES ($1, $2) ' +
'ON CONFLICT (event_id) DO NOTHING RETURNING event_id',
[event.id, event.type],
);
if (rowCount === 0) {
res.status(200).send();
return;
}
Paso 4 · Responde 2xx rápido; el trabajo pesado, a una cola#
Tu endpoint tiene que responder en el orden de cientos de milisegundos. Emitir la factura, avisar a la bodega, mandar el correo y llamar a tu ERP no van dentro del handler: van a una cola.
// Dentro del handler: verificar, deduplicar, encolar, responder.
switch (event.type) {
case 'payment_intent.succeeded':
await cola.encolar('marcar-orden-pagada', {
eventId: event.id,
intentId: event.data.object.id,
orderId: event.data.object.metadata?.order_id,
});
break;
case 'payment_intent.failed':
case 'payment_intent.expired':
case 'payment_intent.canceled':
await cola.encolar('liberar-stock', { eventId: event.id });
break;
case 'refund.succeeded':
await cola.encolar('registrar-reembolso', { eventId: event.id });
break;
default:
// Tipo nuevo del catalogo: ignorar sin fallar. Es un cambio aditivo.
break;
}
res.status(200).send();
Qué respuesta significa qué#
| Tu respuesta | Interpretación de ApiPay |
|---|---|
2xx | Entregado. No hay más reintentos de este evento |
4xx | Entrega fallida. Se reintenta según el calendario, incluido un 400 por firma inválida |
5xx | Entrega fallida. Se reintenta |
| timeout | Entrega fallida. Se reintenta |
Reintentos: 1m, 5m, 30m, 2h, 12h#
Si una entrega no recibe 2xx, ApiPay la reintenta con este calendario, contado desde el intento
inicial:
| Intento | Cuándo |
|---|---|
| 1 (inicial) | inmediato |
| 2 | +1 minuto |
| 3 | +5 minutos |
| 4 | +30 minutos |
| 5 | +2 horas |
| 6 | +12 horas |
Son cinco reintentos tras el intento inicial. Agotados los seis intentos, la entrega queda en
FAILED y no se vuelve a intentar automáticamente; desde el backoffice puedes reintentarla a mano.
Consecuencias que conviene tener presentes:
- La ventana total es de unas 15 horas. Una caída de tu endpoint más larga que eso pierde eventos, y
la recuperación es manual: reintento desde el backoffice, o conciliación con
GET /v1/payment-intents/{id}yGET /v1/transactions. - El orden no está garantizado. Con reintentos en juego, un
payment_intent.succeededpuede llegar después de unrefund.succeededdel mismo intent. No dependas del orden de llegada: depende delstatusque trae eldata.object. - Un endpoint que falla de forma sostenida se deshabilita. El backoffice te avisa antes; revisa ahí el estado de tus endpoints y el detalle de las entregas.
Paso 5 · Lista de verificación#
Antes de dar por terminada tu integración de webhooks:
- El endpoint es HTTPS y está accesible desde internet.
- Lee el cuerpo crudo; el parser JSON del framework no lo toca antes.
- Verifica la firma con el helper del SDK, no a mano.
- Está excluido del CSRF y de cualquier autenticación de sesión propia.
- Deduplica por
event.idde forma atómica y antes de procesar. - Responde
2xxen cientos de milisegundos; lo pesado va a una cola. - Los
typedesconocidos se ignoran sin fallar. - Comprueba
livemodesi el mismo endpoint atiende test y live. Mejor aún: dos endpoints. - El secreto
whsec_…viene de configuración, jamás del código. - NTP activo en los nodos que reciben webhooks.
- No se registra el cuerpo del webhook en logs de aplicación.
Siguientes pasos#
- Modo test y sandbox: monto acabado en
13para forzar un resultado que llega solo por webhook, a los 60 s. - Payment intents: qué evento emite cada transición.
- Reembolsos:
refund.succeededyrefund.failed. - Recetas completas con el handler ya montado: Express, Laravel, FastAPI, Spring Boot.