Eventos y polling

MODO PRUEBA

GET /v1/events es la alternativa de polling a los webhooks: el mismo catálogo de eventos, pero leído por tu backend en vez de recibido por HTTP. Pensado sobre todo para desarrollo local — sin túnel, sin exponer un puerto público.

Requisito: necesitas al menos un webhook endpoint ACTIVO — usa sink:events
/v1/events lee la tabla interna de entregas de webhook (webhook_deliveries) — es la misma cola que alimenta las entregas HTTP reales. Si tu tenant no tiene ningún webhook_endpoint registrado, esa tabla nunca se llena y /v1/events siempre devuelve una lista vacía. Registra uno con POST /v1/webhook_endpoints (ver Webhooks) usando "url": "sink:events" — una convención de primera clase que la plataforma reconoce: el Worker nunca le hace una llamada HTTP real a esta URL, así que la entrega jamás falla. Antes de esta convención recomendábamos aquí una URL dummy (p. ej. https://example.invalid/…) solo para "activar" el llenado de la tabla — pero esa URL SIEMPRE falla, y a las 72 h de fallar sin un 2xx la plataforma deshabilita el endpoint completo (ver Webhooks): a partir de ahí EnqueueAsync deja de encolar y /v1/events vuelve a devolver data: [] para siempre, sin ninguna señal de qué pasó. Con sink:events eso no puede ocurrir: nunca falla, nunca se deshabilita.
bash · registra un endpoint sink (una vez)
curl -s https://api.winal.com.mx/v1/webhook_endpoints \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "url": "sink:events" }'

Leer eventos

Cursor exclusivo por id: after_id devuelve solo eventos con id > after_id. El primer id visible es 1, así que after_id=0 (o simplemente omitirlo) trae desde el principio.

QueryTipoDefault
after_idint64, opcional0 (desde el primer evento)
limitint, opcional50; tope 200 (valores mayores se recortan, no fallan)

Un evento recién ocurrido puede tardar ~2 s en aparecer aquí. El id de cada fila se asigna al insertarla, no al confirmar la transacción que la escribió; sin un margen, dos escrituras concurrentes que confirman fuera de orden podrían hacer que un cliente de polling avance su cursor sobre la fila de id más alto y nunca vea la de id más bajo que confirma un instante después. Por eso /v1/events solo sirve filas con al menos ~2 s de antigüedad — un pequeño retraso a cambio de que after_id cumpla su promesa de no repetir ni saltar eventos.

request
GET /v1/events?after_id=0&limit=50
Authorization: Bearer sk_test_...
200 · respuesta real
{
  "object": "list",
  "data": [
    {
      "id": 15,
      "object": "event",
      "event_id": "5e332767-ae86-42e3-b395-cc325de90f2b",
      "event_type": "payment_intent.succeeded",
      "delivered": false,
      "created_at": "2026-07-07T22:55:49.195959Z",
      "data": {
        "event_id": "5e332767-ae86-42e3-b395-cc325de90f2b",
        "event_type": "payment_intent.succeeded",
        "created_at": "2026-07-07T22:55:48.665058Z",
        "api_version": "2026-07-01",
        "livemode": false,
        "data": {
          "id": "f5f3fc01-8d3b-4abd-881e-52664a1d9c44",
          "object": "payment_intent",
          "status": "succeeded",
          "amount_minor": 10000,
          "tip_minor": 1500,
          "total_minor": 11500,
          "currency": "MXN",
          "metadata": { "payment_link_id": "pl_smoke_fosos" }
        }
      }
    }
  ]
}

Cada fila tiene dos niveles: el id de la fila (tu cursor — pásalo como after_id en la siguiente llamada) y, dentro de data, el envelope completo del webhook tal como se firma y entrega a los endpoints suscritos (mismo event_id/event_type/created_at/ api_version/livemode/data de Webhooks). delivered indica si esa fila ya se entregó con éxito (2xx) a su endpoint suscrito — es información de la entrega HTTP, no del estado del recurso.

delivered es una foto, no un valor en vivo
Cada respuesta de /v1/events refleja el estado de delivered en el momento EXACTO de esa lectura, sobre una fila que sigue siendo mutable: puede pasar de false a true (o agotar su escalonamiento de reintentos y quedarse en false) entre dos llamadas. No vuelvas a leerla como si fuera el resultado final después de avanzar after_id — para el estado actualizado de una entrega puntual usa el visor de webhooks del portal admin.
Un evento puede repetirse: una fila por endpoint suscrito
Si el tenant tiene más de un webhook_endpoint activo, el mismo evento de dominio genera una fila por endpoint (mismo event_id, distinto id de fila). Deduplica por event_id igual que harías con webhooks reales — ver Webhooks → Dedupe.

data.metadata: correlación con tus propios IDs

Los eventos payment_intent.* incluyen ahora metadata dentro del objeto anidado — la misma metadata libre que mandaste al crear el intent (o que Winal copió automáticamente si el intent nació de un Payment Link: payment_link_id). A diferencia de la respuesta autenticada de /v1/payment_intents/{id} (que omite los campos null), aquí metadata siempre está presente como llave — null explícito si no mandaste ninguna. Igual, tip_minor y total_minor siempre aparecen (ver Reportes → Propinas).

Patrón de polling

bash · loop simple
after_id=0
while true; do
  resp=$(curl -s "https://api.winal.com.mx/v1/events?after_id=$after_id&limit=50" \
    -H "Authorization: Bearer $SK")

  # procesa cada evento de $resp.data (dedupe por event_id, aplica tu lógica) …

  last_id=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin)['data']; print(d[-1]['id'] if d else 0)")
  if [ "$last_id" -gt "$after_id" ]; then after_id=$last_id; fi
  sleep 2
done

No necesitas escribir este loop tú mismo: el CLI oficial winal ya lo implementa con winal listen --forward-to <url> — hace exactamente este polling cada 2 s y además reenvía cada evento a tu servidor local con la firma Gateway-Signature recalculada, para que pruebes tu verificación de firma sin necesitar un endpoint público de verdad.