Saltar al contenido principal

Webhooks

Cuando un pago cambia de estado, la API te avisa con un POST firmado a la URL que registres. No necesitas consultar en bucle: verifica la firma, responde 2xx y aplica el evento una sola vez.

Registrar un endpoint​

curl -s -X POST "$BASE/v1/admin/webhook-endpoints" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"url": "https://consumer.example.com/webhooks/pagos",
"events": ["payment.authorized", "payment.failed", "refund.succeeded"]
}'
{
"success": true,
"data": {
"id": "whe_01…",
"application_id": "app_…",
"url": "https://consumer.example.com/webhooks/pagos",
"enabled": true,
"events": ["payment.authorized", "payment.failed", "refund.succeeded"],
"created_at": "…",
"secret": "a3f1c9…"
}
}
  • events es opcional: vacío o ausente significa todos los eventos.
  • El secret (64 caracteres hex) se muestra una sola vez. Guárdalo como un secreto de tu sistema; si lo pierdes, borra el endpoint y crea otro.
  • Una aplicación puede tener varios endpoints. Cada uno recibe su propia copia de cada evento.
AplicaciónURLs aceptadas
PRODUCTIONsolo https, puerto 443, y nunca localhost ni direcciones privadas
SANDBOXhttp o https, puertos 80, 443, 3000, 4300, 4400 u 8080 (sirve para probar en tu máquina)

Una URL fuera de estas reglas responde 400 WEBHOOK_ENDPOINT_URL_NOT_ALLOWED.

Qué recibe tu endpoint​

POST /webhooks/pagos HTTP/1.1
Host: consumer.example.com
Content-Type: application/json
X-Pay-Event-Id: evt_01…
X-Pay-Timestamp: 1790445600
X-Pay-Signature: v1=5c4e…

{
"id": "evt_01…",
"type": "payment.authorized",
"created_at": "2026-09-26T18:05:00.000Z",
"data": {
"payment_intent_id": "payi_01…",
"payment_id": "pay_01…",
"provider": "transbank_webpay_plus",
"amount": 25990,
"currency": "CLP"
}
}

data.payment_intent_id viene siempre. El resto de data depende del tipo:

EventoCuándoCampos de data
payment_intent.createdse creó el intentmerchant_account_id, external_reference, amount, currency, requested_provider
payment.checkout.startedse pidió el checkoutprovider
payment.processingel proveedor dejó el pago en procesoprovider
payment.authorizedel proveedor autorizópayment_id, provider, amount, currency
payment.failedel proveedor rechazóprovider, reason
payment.cancelledel comprador anulóreason
payment.expiredel checkout venció—
refund.requestedse pidió una devoluciónpayment_id, refund_id, amount, currency, external_reference
refund.succeededla devolución se aplicópayment_id, refund_id, amount, currency, payment_intent_status
refund.failedel proveedor rechazó la devoluciónpayment_id, refund_id, reason

Verificar la firma​

La firma es HMAC-SHA256(secret, X-Pay-Timestamp + "." + body_crudo) en hexadecimal, enviada como X-Pay-Signature: v1=<hex>. Para verificarla:

  1. Usa el body crudo, tal como llegó. No lo vuelvas a serializar: otro orden de claves o espacios cambian la firma.
  2. Rechaza el aviso si X-Pay-Timestamp está a más de 5 minutos de tu reloj (evita que alguien lo reenvíe más tarde).
  3. Compara en tiempo constante.
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verificarWebhook(headers, rawBody, secret, toleranciaSeg = 300) {
const ts = headers['x-pay-timestamp'];
const firma = /^v1=([0-9a-f]+)$/.exec(headers['x-pay-signature'] ?? '')?.[1];
if (!ts || !firma) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranciaSeg) return false;
const esperada = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(esperada, 'hex');
const b = Buffer.from(firma, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}

// Express: express.raw({ type: 'application/json' }) para tener el body crudo
app.post('/webhooks/pagos', express.raw({ type: 'application/json' }), (req, res) => {
if (!verificarWebhook(req.headers, req.body.toString('utf8'), process.env.PAGOS_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const evento = JSON.parse(req.body);
// … aplicar el evento de forma idempotente (ver abajo)
res.sendStatus(204);
});

Reintentos​

Un aviso cuenta como entregado solo si tu endpoint responde 2xx dentro de 5 segundos. Cualquier otro resultado (3xx, 4xx, 5xx, timeout o error de red) programa un nuevo intento:

Reintentos: 0, 1 min, 5 min, 15 min, 1 h, 6 h, 24 h y luego se marca agotada10POST21 minsin 2xx ↻35 minsin 2xx ↻415 minsin 2xx ↻51 hsin 2xx ↻66 hsin 2xx ↻724 hsin 2xx ↻entrega agotadareintento manual por APIFirma v1 = HMAC-SHA256(secret, timestamp + "." + raw_body) · un 2xx en cualquier intento corta la cadena
Intento1234567
Espera desde el anteriorinmediato1 min5 min15 min1 h6 h24 h

Si el séptimo intento también falla, la entrega queda agotada. Puedes revisarla y volver a programarla:

# entregas de un endpoint, con estado, intento, código HTTP y extracto de la respuesta
curl -s "$BASE/v1/admin/webhook-endpoints/whe_01…/deliveries" -H "Authorization: Bearer $KEY"

# reprogramar una entrega ahora (con un calendario de reintentos nuevo)
curl -s -X POST "$BASE/v1/admin/webhook-endpoints/whe_01…/deliveries/whd_01…/retry" \
-H "Authorization: Bearer $KEY"

Reintentar una entrega que ya fue exitosa responde 409 WEBHOOK_DELIVERY_ALREADY_SUCCEEDED.

Buenas prácticas en tu endpoint​

  • Responde rápido. Verifica la firma, guarda el evento y responde 2xx; procesa después (por ejemplo, en una cola propia). Si tardas más de 5 segundos, se reintenta.
  • Aplica cada evento una sola vez. El mismo evento puede llegar más de una vez (reintentos, reprogramaciones). Usa X-Pay-Event-Id (igual al id del body) como clave única: si ya lo procesaste, responde 2xx sin repetir efectos.
  • No dependas del orden de llegada. Un reintento puede llegar después de un evento posterior. Ante la duda, consulta GET /v1/payment-intents/{payment_intent_id} y usa ese estado.
  • Un 2xx no es un "OK de negocio". Si tu sistema no pudo procesar un evento válido, igual responde 2xx una vez guardado y resuélvelo de tu lado; un 4xx/5xx solo provoca reintentos del mismo aviso.