Webhooks

MODO PRUEBA

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

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

esquema exacto
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#

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

JavaScript
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
<?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

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

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

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
}
⚠️ El error #1: parsear antes de verificar
Verifica la firma contra el cuerpo crudo (los bytes exactos que llegaron), antes de deserializar el JSON. Si tu framework parsea y luego vuelves a serializar el objeto para firmar, cualquier reordenamiento de llaves, espaciado o normativa de Unicode cambia los bytes y la firma nunca coincidirá. La mayoría de frameworks exigen un middleware de "raw body" para las rutas de webhooks (mira los comentarios en cada ejemplo).

Checklist de verificación

PuntoValor
AlgoritmoHMAC-SHA256, salida en hex minúsculas
Header de firmaGateway-Signature: t=<unix>,v1=<hex>
Payload firmadola cadena literal "{t}.{cuerpo_crudo}"
Secretowhsec_… de la respuesta de alta del endpoint (guárdalo; no se recupera)
Comparaciónen tiempo constante (nunca == de cadenas)
Tolerancia / anti-replayrechaza si |ahora − t| > 5 min
Rotación de secretodurante 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):

json
{
  "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
A diferencia de la respuesta autenticada de /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_typeSe dispara cuando…
payment_intent.succeededEl cobro se capturó con éxito.
payment_intent.requires_actionEl intent necesita acción del pagador (QR, CLABE, referencia, redirect 3DS).
payment_intent.failedEl cobro falló definitivamente (decline duro, o suave ya sin reintento).
payment_intent.expiredVenció la acción pendiente (QR/voucher/CLABE) sin que el pagador pagara.
payment_intent.canceledSe canceló explícitamente vía POST /v1/payment_intents/{id}/cancel.
refund.succeededLa devolución se completó.
refund.failedLa devolución falló (puede escalar a revisión manual).
Lo que NO llega por webhook
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):

IntentoEspera hasta el siguiente
1 → 21 minuto
2 → 35 minutos
3 → 430 minutos
4 → 52 horas
5 → 66 horas
6 → 712 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:

  1. Arregla la causa (tu servidor caído, un 5xx, un timeout, o una firma que rechazabas por parsear antes de verificar).
  2. Vuelve a registrar el endpoint con POST /v1/webhook_endpoints (o desde el portal → Webhooks). Recibirás un secret whsec_… nuevo — actualízalo en tu servidor.
  3. Rellena lo que te perdiste mientras estuvo caído con GET /v1/events (polling con cursor por id) — 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/events
El mismo mecanismo de auto-disable protege la cola interna que alimenta GET /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.