Webhooks de eventos (POST)
Crowder te notifica las transiciones del lifecycle vía webhooks. Cada evento dispara una transición concreta; no hay eventos puramente informativos.
Request
Sección titulada «Request»POST {endpoint}/{interaction}/eventsHeaders:
Authorization: Bearer <api_key>Content-Type: application/jsonEventos y transiciones
Sección titulada «Eventos y transiciones»status | Transición | Trigger | Campos relevantes |
|---|---|---|---|
purchaseReserved | valid → reserved | Crowder te pide reservar stock real. Vos devolvés expiresAt en el ack — el TTL arranca acá. | currency, partnerItems |
purchasePaid | reserved → confirmed | Pago capturado en Crowder. | currency, purchase, partnerItems |
purchaseExpired | reserved → expired | El TTL de la reserva venció sin pago. Crowder notifica para que el partner libere el hold de forma sincronizada (no solo por su cuenta). | status |
purchaseRefunded | confirmed → confirmed (parcial) o confirmed → refunded (total) | Devolución de uno o más items. Crowder envía un array items con qué devolver. Si abarca todos los items vigentes → terminal refunded. Si es subset → la interacción queda en confirmed con partnerItems reducido. | reason, items |
Secuencia happy path: purchaseReserved → purchasePaid.
Bodies de ejemplo
Sección titulada «Bodies de ejemplo»{ "status": "purchaseReserved", "currency": "ARS", "partnerItems": [ { "uuid": "HOTEL-3N-PREMIUM", "type": "HOTEL", "description": "Hotel 3 noches · Hilton Buenos Aires", "price": 120000.00, "quantity": 1 } ]}{ "status": "purchasePaid", "currency": "ARS", "purchase": { "id": 987654, "amount": 120000.00 }, "partnerItems": [/* ... */]}{ "status": "purchaseExpired"}El body no lleva más campos: la interaction viaja en la URL y el evento es puramente notificacional. El partner libera el hold y responde 200 con { "status": "expired" }.
{ "status": "purchaseRefunded", "reason": "user_request", "items": [ { "uuid": "TICKET-VIP" } ]}reason: user_request | cancelled_by_partner | chargeback | fraud | other.
Ack al webhook
Sección titulada «Ack al webhook»No hay envelope acknowledged. El código HTTP carga el significado y el body lleva el status resultante del lifecycle como discriminador, más los campos que el partner aporta como output.
| HTTP | Body | Significado |
|---|---|---|
200 | { "status": "<lifecycle>", ... } con el lifecycle alcanzado: reserved (lleva expiresAt), confirmed, expired, refunded | Procesado correctamente. |
202 | { "status": "deferred", "notes"?: "..." } | Recibido, procesamiento async. |
409 | { "status": "error", "error": { "code": "invalid_transition", "message": "..." } } | El estado actual no admite el evento. No se mutó nada. |
400 | { "status": "error", "error": { "code": "invalid_payload" | "unsupported_event", ... } } | Payload mal formado o evento desconocido. |
401 / 403 | { "status": "error", "error": { "code": "auth_invalid", ... } } | Bearer ausente o inválido. |
404 | { "status": "error", "error": { "code": "not_found", ... } } | interaction desconocida. |
5xx | (libre) | Error interno. Entra a reintentos. |
{ "status": "reserved", "expiresAt": "2026-04-28T15:48:00Z" }{ "status": "confirmed" }{ "status": "expired" }{ "status": "confirmed" }{ "status": "refunded" }{ "status": "deferred", "notes": "Encolado en queue de fulfillment. Ref: voucher-q-4528."}{ "status": "error", "error": { "code": "invalid_transition", "message": "Evento purchasePaid requiere lifecycle=\"reserved\", actual=\"valid\"." }}Reintentos e idempotencia
Sección titulada «Reintentos e idempotencia»Backoff exponencial: 1m → 5m → 30m → 2h → 12h → 24h. Después: failed en backoffice.
4xxsalen del ciclo de reintentos (no se vuelve a intentar).5xxentra al backoff exponencial.
Validación de transición
Sección titulada «Validación de transición»El partner valida que su lifecycle actual coincida con el “from” de la tabla. Si no encaja (ej: purchasePaid sobre valid), respondé 409 invalid_transition sin mutar nada. Crowder consulta el GET de estado antes de disparar, así que un 409 indica una divergencia que requiere resolución bilateral.