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.
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.
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.
| Query | Tipo | Default |
|---|---|---|
after_id | int64, opcional | 0 (desde el primer evento) |
limit | int, opcional | 50; 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.
GET /v1/events?after_id=0&limit=50
Authorization: Bearer sk_test_...
{
"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/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.
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
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.