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…"
}
}
eventses 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ón | URLs aceptadas |
|---|---|
PRODUCTION | solo https, puerto 443, y nunca localhost ni direcciones privadas |
SANDBOX | http 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:
| Evento | Cuándo | Campos de data |
|---|---|---|
payment_intent.created | se creó el intent | merchant_account_id, external_reference, amount, currency, requested_provider |
payment.checkout.started | se pidió el checkout | provider |
payment.processing | el proveedor dejó el pago en proceso | provider |
payment.authorized | el proveedor autorizó | payment_id, provider, amount, currency |
payment.failed | el proveedor rechazó | provider, reason |
payment.cancelled | el comprador anuló | reason |
payment.expired | el checkout venció | — |
refund.requested | se pidió una devolución | payment_id, refund_id, amount, currency, external_reference |
refund.succeeded | la devolución se aplicó | payment_id, refund_id, amount, currency, payment_intent_status |
refund.failed | el proveedor rechazó la devolución | payment_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:
- Usa el body crudo, tal como llegó. No lo vuelvas a serializar: otro orden de claves o espacios cambian la firma.
- Rechaza el aviso si
X-Pay-Timestampestá a más de 5 minutos de tu reloj (evita que alguien lo reenvíe más tarde). - Compara en tiempo constante.
- Node.js
- Python
- Go
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);
});
import hashlib, hmac, re, time
def verificar_webhook(headers, raw_body: bytes, secret: str, tolerancia_seg=300) -> bool:
ts = headers.get("X-Pay-Timestamp")
m = re.fullmatch(r"v1=([0-9a-f]+)", headers.get("X-Pay-Signature", ""))
if not ts or not m:
return False
if abs(time.time() - float(ts)) > tolerancia_seg:
return False
esperada = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, m.group(1))
# Flask: request.get_data() entrega el body crudo
@app.post("/webhooks/pagos")
def webhook():
if not verificar_webhook(request.headers, request.get_data(), os.environ["PAGOS_WEBHOOK_SECRET"]):
return "", 400
evento = request.get_json()
# … aplicar el evento de forma idempotente
return "", 204
package pagos
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func VerificarWebhook(r *http.Request, secret string) ([]byte, bool) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, false
}
ts := r.Header.Get("X-Pay-Timestamp")
firma, ok := strings.CutPrefix(r.Header.Get("X-Pay-Signature"), "v1=")
sec, err := strconv.ParseInt(ts, 10, 64)
if !ok || err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return nil, false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
recibida, err := hex.DecodeString(firma)
return body, err == nil && hmac.Equal(mac.Sum(nil), recibida)
}
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:
| Intento | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Espera desde el anterior | inmediato | 1 min | 5 min | 15 min | 1 h | 6 h | 24 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 aliddel body) como clave única: si ya lo procesaste, responde2xxsin 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
2xxno es un "OK de negocio". Si tu sistema no pudo procesar un evento válido, igual responde2xxuna vez guardado y resuélvelo de tu lado; un 4xx/5xx solo provoca reintentos del mismo aviso.