El webhook es la fuente de verdad de un cobro — nunca la respuesta HTTP de
confirm. Winal firma cada entrega con Gateway-Signature y
reintenta con backoff hasta ~72 horas si tu servidor no responde 2xx.
Registra tu endpoint
curl -s https://api.winal.com.mx/v1/webhook_endpoints \
-H "Authorization: Bearer $SK" \
-H "Content-Type: application/json" \
-d '{ "url": "https://tu-servidor.mx/webhooks/winal" }'
# → { "id": "...", "object": "webhook_endpoint", "url": "...", "secret": "whsec_..." }
El campo opcional events (lista de event_type) restringe la
suscripción; si lo omites o mandas una lista vacía, el endpoint recibe todo el catálogo.
El secret solo se muestra en esta respuesta — guárdalo, no se puede recuperar
después.
La firma Gateway-Signature
Cada entrega trae el header Gateway-Signature: t=<unix>,v1=<hex>,
donde:
v1 = hex( HMAC-SHA256( secret, "{t}.{cuerpo_crudo}" ) )
El "signed payload" es la concatenación literal "{timestamp}.{cuerpo}" — no
el JSON solo. Debes reconstruir esa cadena con el mismo timestamp del header y el
cuerpo crudo tal cual llegó (sin re-serializar), calcular tu propio HMAC y compararlo
en tiempo constante. Rechaza la entrega si la firma no coincide o si
|ahora − t| > 5 min (ventana anti-repetición). Durante una rotación de
secreto, Winal acepta como válida tanto la firma con el secreto nuevo como con el anterior.
Verificar en C#
static bool VerificarFirma(string header, string cuerpoCrudo, string secreto, TimeSpan tolerancia)
{
var partes = header.Split(',')
.Select(p => p.Split('=', 2))
.ToDictionary(p => p[0].Trim(), p => p[1]);
var t = long.Parse(partes["t"]);
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > tolerancia.TotalSeconds)
return false; // fuera de la ventana anti-repetición
var payloadFirmado = $"{t}.{cuerpoCrudo}";
var firmaEsperada = Convert.ToHexStringLower(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secreto), Encoding.UTF8.GetBytes(payloadFirmado)));
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(firmaEsperada), Encoding.UTF8.GetBytes(partes["v1"]));
}
Verificar en Node.js
const crypto = require("crypto");
function verificarFirma(header, cuerpoCrudo, secreto, toleranciaSegundos = 300) {
const partes = Object.fromEntries(
header.split(",").map((p) => { const [k, v] = p.split("="); return [k.trim(), v]; })
);
const t = Number(partes.t);
if (Math.abs(Date.now() / 1000 - t) > toleranciaSegundos) return false;
const payloadFirmado = `${t}.${cuerpoCrudo}`;
const firmaEsperada = crypto.createHmac("sha256", secreto).update(payloadFirmado).digest("hex");
const recibida = Buffer.from(partes.v1);
const esperada = Buffer.from(firmaEsperada);
return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
}
Verificar en PHP
<?php
function verificarFirma(string $header, string $cuerpoCrudo, string $secreto, int $tolerancia = 300): bool
{
$partes = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = array_pad(explode('=', $p, 2), 2, '');
$partes[trim($k)] = $v;
}
if (!isset($partes['t'], $partes['v1'])) {
return false;
}
$t = (int) $partes['t'];
if (abs(time() - $t) > $tolerancia) {
return false; // fuera de la ventana anti-repetición
}
$payloadFirmado = $t . '.' . $cuerpoCrudo;
$esperada = hash_hmac('sha256', $payloadFirmado, $secreto);
return hash_equals($esperada, $partes['v1']); // comparación en tiempo constante
}
// El cuerpo CRUDO, ANTES de cualquier json_decode():
$cuerpoCrudo = file_get_contents('php://input');
$ok = verificarFirma($_SERVER['HTTP_GATEWAY_SIGNATURE'] ?? '', $cuerpoCrudo, getenv('WINAL_WHSEC'));
Verificar en Python
import hashlib, hmac, time
def verificar_firma(header: str, cuerpo_crudo: bytes, secreto: str, tolerancia: int = 300) -> bool:
partes = {}
for p in header.split(","):
k, _, v = p.partition("=")
partes[k.strip()] = v
if "t" not in partes or "v1" not in partes:
return False
t = int(partes["t"])
if abs(int(time.time()) - t) > tolerancia:
return False # fuera de la ventana anti-repetición
payload_firmado = f"{t}.".encode() + cuerpo_crudo
esperada = hmac.new(secreto.encode(), payload_firmado, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, partes["v1"]) # comparación en tiempo constante
# En Flask: request.get_data() es el cuerpo CRUDO (antes de request.get_json()).
# En Django: request.body · En FastAPI: await request.body()
Verificar en Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func VerificarFirma(header string, cuerpoCrudo []byte, secreto string, toleranciaSeg float64) bool {
partes := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
partes[strings.TrimSpace(kv[0])] = kv[1]
}
}
t, err := strconv.ParseInt(partes["t"], 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > toleranciaSeg {
return false // fuera de la ventana anti-repetición
}
mac := hmac.New(sha256.New, []byte(secreto))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(cuerpoCrudo) // el cuerpo CRUDO, sin re-serializar
esperada := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(esperada), []byte(partes["v1"])) // tiempo constante
}
Verificar en Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
static boolean verificarFirma(String header, byte[] cuerpoCrudo, String secreto, long toleranciaSeg)
throws Exception {
Map<String, String> partes = new HashMap<>();
for (String p : header.split(",")) {
String[] kv = p.split("=", 2);
if (kv.length == 2) partes.put(kv[0].trim(), kv[1]);
}
if (!partes.containsKey("t") || !partes.containsKey("v1")) return false;
long t = Long.parseLong(partes.get("t"));
if (Math.abs(System.currentTimeMillis() / 1000 - t) > toleranciaSeg) {
return false; // fuera de la ventana anti-repetición
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
mac.update(cuerpoCrudo); // el cuerpo CRUDO, sin re-serializar
StringBuilder esperada = new StringBuilder();
for (byte b : mac.doFinal()) esperada.append(String.format("%02x", b));
return MessageDigest.isEqual(
esperada.toString().getBytes(StandardCharsets.UTF_8),
partes.get("v1").getBytes(StandardCharsets.UTF_8)); // tiempo constante
}
Checklist de verificación
| Punto | Valor |
|---|---|
| Algoritmo | HMAC-SHA256, salida en hex minúsculas |
| Header de firma | Gateway-Signature: t=<unix>,v1=<hex> |
| Payload firmado | la cadena literal "{t}.{cuerpo_crudo}" |
| Secreto | whsec_… de la respuesta de alta del endpoint (guárdalo; no se recupera) |
| Comparación | en tiempo constante (nunca == de cadenas) |
| Tolerancia / anti-replay | rechaza si |ahora − t| > 5 min |
| Rotación de secreto | durante la rotación, prueba contra el secreto nuevo y el anterior; acepta si cualquiera valida |
El envelope del evento
Cada entrega llega con este cuerpo exacto (verificado contra WebhookDispatchHandler):
{
"event_id": "8f2a1c3e-...",
"event_type": "payment_intent.succeeded",
"created_at": "2026-07-05T18:30:04Z",
"api_version": "2026-07-01",
"livemode": false,
"data": {
"id": "5b6b8b3e-1e4b-4c8a-9b2e-6f6a2c9d41a2",
"object": "payment_intent",
"status": "succeeded",
"amount_minor": 84900,
"tip_minor": 0,
"total_minor": 84900,
"currency": "MXN",
"metadata": { "payment_link_id": "plink_9fZ3..." }
}
}
data es una proyección reducida del recurso al momento del evento (no el
objeto completo con attempts); si necesitas más detalle, consulta
GET /v1/payment_intents/{id}?expand=attempts con tu clave de API.
data.metadata: correlación con tus propios IDs/v1 (que omite cualquier campo
null), esta proyección reducida incluye siempre las llaves
tip_minor, total_minor y metadata — esta última
explícitamente null cuando el intent no tiene metadata, en vez de faltar del
todo. Úsala para correlacionar el evento con tus propios identificadores (p. ej. un
integrador que crea el intent desde un Payment Link recibe automáticamente
metadata.payment_link_id — así lo usa el plugin
de WooCommerce para marcar la orden correcta sin depender solo del monto). Verificado
contra PaymentIntentService.BuildIntentWebhook.
Catálogo real de event_type
Este es el catálogo completo que Winal de verdad despacha hoy (grep de
OutboxTopics.WebhookDispatch en PaymentIntentService):
| event_type | Se dispara cuando… |
|---|---|
payment_intent.succeeded | El cobro se capturó con éxito. |
payment_intent.requires_action | El intent necesita acción del pagador (QR, CLABE, referencia, redirect 3DS). |
payment_intent.failed | El cobro falló definitivamente (decline duro, o suave ya sin reintento). |
payment_intent.expired | Venció la acción pendiente (QR/voucher/CLABE) sin que el pagador pagara. |
payment_intent.canceled | Se canceló explícitamente vía POST /v1/payment_intents/{id}/cancel. |
refund.succeeded | La devolución se completó. |
refund.failed | La devolución falló (puede escalar a revisión manual). |
payment_intent.created y una transición interna a processing se
registran en el historial del intent, pero no se despachan como webhook — si
necesitas enterarte de que un intent se creó, es tu propio servidor el que lo sabe (lo
acabas de crear). El primer webhook que puedes recibir de un intent nuevo es
requires_action, succeeded o failed.
Alternativa para desarrollo local: polling
Si no quieres exponer un endpoint público mientras desarrollas (sin túnel/ngrok a la mano),
GET /v1/events expone el mismo catálogo de eventos para que hagas polling desde
tu backend — ver Eventos y polling. El CLI
winal incluye winal listen --forward-to, que hace ese polling
por ti y reenvía cada evento a tu servidor local ya firmado con Gateway-Signature.
Dedupe por event_id
Winal entrega al menos una vez: un reintento tras un timeout de tu lado puede hacer
que proceses el mismo evento dos veces. Guarda los event_id ya procesados
(aunque sea en una tabla simple con índice único) y descarta silenciosamente los repetidos
antes de mutar tu propio estado.
Reintentos
Si tu endpoint no responde 2xx en la ventana de entrega (~10 s), Winal
reagenda con este escalonamiento real (OutboundWebhookService.RetryBackoffs):
| Intento | Espera hasta el siguiente |
|---|---|
| 1 → 2 | 1 minuto |
| 2 → 3 | 5 minutos |
| 3 → 4 | 30 minutos |
| 4 → 5 | 2 horas |
| 5 → 6 | 6 horas |
| 6 → 7 | 12 horas |
| 7 → 8+ | 24 horas (se repite) |
Si una entrega sigue fallando 72 horas después de haberse encolado, Winal
deshabilita el endpoint completo (deja de recibir entregas nuevas). Responde rápido
y con 2xx apenas verifiques la firma — procesa el evento de forma asíncrona si
tu lógica de negocio tarda.
Auto-disable de 72 h y reactivación
Cuando un endpoint se auto-deshabilita por 72 h de fallos, deja de recibir entregas nuevas. Para reactivarlo:
- Arregla la causa (tu servidor caído, un
5xx, un timeout, o una firma que rechazabas por parsear antes de verificar). - Vuelve a registrar el endpoint con
POST /v1/webhook_endpoints(o desde el portal → Webhooks). Recibirás unsecretwhsec_…nuevo — actualízalo en tu servidor. - Rellena lo que te perdiste mientras estuvo caído con
GET /v1/events(polling con cursor porid) — ver Eventos y polling. Ese es el catálogo íntegro; no hay reenvío automático de las entregas descartadas.
sink:events: mantener vivo /v1/eventsGET /v1/events. Para que el polling nunca muera por ese auto-disable, Winal
mantiene un endpoint sumidero convencional (sink:events) que siempre acepta la
entrega. No tienes que hacer nada: es interno; solo garantiza que /v1/events
siga disponible como red de seguridad aunque tu endpoint HTTP externo se caiga.