Saltar al contenido
ApiPay Hub · Docs

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:

  1. Lee el cuerpo crudo, antes de que ningún middleware lo parsee.
  2. Verifica la firma con el helper del SDK.
  3. Deduplica por event.id antes de tocar tu base de datos.
  4. Responde 2xx rápido y manda el trabajo pesado a una cola.
  5. 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"
    }
  }
}
MiembroQué es
idIdentificador del evento, prefijo evt_ y 24 base62. Estable entre reintentos: es tu clave de deduplicación
typeTipo del evento, del catálogo de abajo
createdInstante en que se generó el evento, ISO 8601 UTC
livemodefalse cuando el evento pertenece a datos de prueba
data.objectEl recurso afectado: un payment intent o un refund, según el type

Catálogo de eventos#

Tipodata.objectCuándo
payment_intent.requires_actionpayment intentEl intent pasó a REQUIRES_ACTION y trae redirect_url
payment_intent.succeededpayment intentCobro aprobado
payment_intent.failedpayment intentRechazado por el emisor o la pasarela
payment_intent.canceledpayment intentCancelado por el comercio
payment_intent.expiredpayment intentCaducó sin completarse
refund.succeededrefundReembolso liquidado por la pasarela
refund.failedrefundLa 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.

FrameworkCómo obtener los bytes originales
Expressexpress.raw({ type: 'application/json' }) solo en la ruta del webhook; req.body es un Buffer
FastifyaddContentTypeParser con { parseAs: 'buffer' } en un plugin encapsulado para esa ruta
Flaskrequest.get_data(), que devuelve bytes. Nunca request.json antes de verificar
FastAPIawait request.body() con el parámetro request: Request, sin modelo Pydantic
Laravel$request->getContent(). Nunca $request->all() ni $request->json()
PHP sin frameworkfile_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
  • t son los segundos Unix en que se firmó el evento.
  • v1 es 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.object

La 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:

  1. Generas el secreto nuevo en el backoffice; a partir de ese momento llegan dos v1.
  2. Despliegas tu servicio con el secreto nuevo en configuración.
  3. 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 respuestaInterpretación de ApiPay
2xxEntregado. No hay más reintentos de este evento
4xxEntrega fallida. Se reintenta según el calendario, incluido un 400 por firma inválida
5xxEntrega fallida. Se reintenta
timeoutEntrega 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:

IntentoCuá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} y GET /v1/transactions.
  • El orden no está garantizado. Con reintentos en juego, un payment_intent.succeeded puede llegar después de un refund.succeeded del mismo intent. No dependas del orden de llegada: depende del status que trae el data.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.id de forma atómica y antes de procesar.
  • Responde 2xx en cientos de milisegundos; lo pesado va a una cola.
  • Los type desconocidos se ignoran sin fallar.
  • Comprueba livemode si 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#