Bolsas corporativas de entradas: delegación, nominación y entrega en wallet.
Una orden (bolsa de entradas de un evento) se crea por API desde ticketing. El responsable la distribuye desde el portal: delega sub-bolsas o nomina entradas a titulares finales. La entrega es wallet pass (Apple/Google con QR rotativo) o Equity (la app de entradaUno). El portal te avisa todo: quién tiene cada entrada, cuándo se emitió, cuándo entró y cuándo volvió.
https://passes.pagouno.io
· Portal del gestor: https://passes.equity.ar
· Colección Postman: docs/pagouno.passes.postman_collection.json (en el repo del servicio).Conceptos clave
revision; la vieja queda SUPERSEDED (tombstone) al instante.WALLET: pass Apple/Google con QR rotativo (TOTP), landing por mail. EQUITY: la entrada se emite en tu app vía contrato (b) y el portal manda el mail.Autenticación
X-Api-Key — audiences por capacidad
| Audience | Quién la usa | Permite |
|---|---|---|
| passes.ingest | cada marketplace (una key POR ORIGEN) | crear, ampliar y anular órdenes — contrato (a) |
| passes.access | control de acceso | webhook usada (d) + feed de credenciales (e) |
webhookUrl que mandás EN CADA ORDEN (https + host público; sin allowlist de dominios).
Las keys se dan de alta y se revocan desde el panel de PagoUno (kill-switch inmediato, sin coordinar redeploy).Firma HMAC-SHA256
Los contratos de mayor impacto ((d), (e) y todos los salientes) van firmados con el secreto compartido de tu integración. Canonical string única:
firma = HMAC-SHA256(secreto, "<unixSeconds>." + <body crudo UTF-8>) Header: X-Passes-Signature: t=<unixSeconds>,v1=<hex minúscula> · tolerancia ±300 s # En GET (feed) el body es vacío → canonical "<t>."
Tu secreto de callbacks — autoservicio
El secreto con el que firmamos TUS callbacks (c) se autogenera en tu primer uso y lo consultás cuando quieras con tu misma key de ingesta (guardalo del lado tuyo; no viaja nunca en las órdenes):
GET /api/v1/webhook-secret · X-Api-Key: <tu key de ingesta> → 200 { "integration":"MKT_TUORIGEN", "hmacSecret":"…", "algorithm":"HMAC-SHA256", "header":"X-Passes-Signature", "canonical":"<unixSeconds>.<body crudo>", "toleranceSeconds":300 }
La regla de revision
revision. Aplicá el mensaje solo si
revision ≥ la última vista para ese passRef (last-write-wins); a igual revision desempatá por
occurredAtUtc. Los reintentos pueden llegar fuera de orden.(a) Orden — entrante vos → portal
| Método | Endpoint | Auth | Idempotencia |
|---|---|---|---|
| POST | /api/v1/orders | passes.ingest | hash byte-exacto del body por externalRef |
| PATCH | /api/v1/orders/{externalRef} | passes.ingest | amendmentRef |
| POST | /api/v1/orders/{externalRef}/cancel | passes.ingest | natural (anular dos veces = anulada) |
{
"externalRef": "E1-ORD-100001", "tenantId": "cust-3",
"webhookUrl": "https://api.tumarketplace.com/gestor-pases/webhook", ← opcional (v2)
"event": { "externalRef":"EVT-555", "name":"…", "venueName":"…", "venueAddress":"…", "startsAt":"2026-08-15T20:30:00-03:00" },
"branding": { "name":"…", "logoUrl":"…", "primaryColor":"#2563eb", … },
"config": { "allowRedelegation":true, "maxDepth":3, "deliveryMethods":["WALLET","EQUITY"],
"defaultLocale":"es", "reminders":{ "balanceDaysBeforeEvent":[7,2], "pendingBagDays":[3,7] } },
"sectors": [ { "externalRef":"VIP", "kind":"TICKET", "numbered":false, "qty":100, "ticketIds":["11","22","…"] },
{ "externalRef":"PLATEA", "kind":"TICKET", "numbered":true,
"seats":[{ "externalRef":"PL-01-001", "row":"1", "seat":"1", "ticketId":"901" }] } ],
"manager": { "email":"agencia@x.com", "name":"Agencia X", "locale":"es" }
}
→ 201 { "orderId":"…", "portalUrl":"…" } · 200 replay idéntico · 409 mismo ref, body divergenteticketIds/ticketId(v2, opcional): el id de cada unidad en TU sistema — vuelve en todos los callbacks (c) y en (b). Todo-o-nada por sector: si el sector nace con ids, todo PATCH que agregue cupo debe traerlos (400 ticket_ids_required); si nace sin ids, no puede introducirlos (400 ticket_ids_not_expected). En no numeradascount(ticketIds) == qty; únicos por orden. El portal asigna el id concreto a cada persona al nominar (FIFO): no asumas quién tiene qué id hasta recibir el callback.webhookUrl(v2, opcional): destino de los callbacks (c) para ESTA orden, usado VERBATIM (no se concatena path). Validación solo técnica: https + host público (nada de IPs privadas/loopback) — sin allowlist de dominios: el destino lo decidís vos, orden por orden. La firma HMAC es siempre la de tu integración (tu secreto NUNCA viaja en la orden). Sin webhookUrl → tu BaseUrl registrada (si la acordaste) +/passes/beneficiary; sin ninguna de las dos, esa orden no recibe callbacks.- El replay del POST es por hash byte-exacto del body (el orden del array
ticketIdsimporta). No hay backfill de ids para cupo ya creado.
PATCH — ampliación aditiva (y el "deshacer" de una devolución)
- El cupo/butacas nuevas caen en la bolsa raíz. Acepta
webhookUrl(rotación del endpoint). - Re-enviar un ticketId/butaca DEVUELTA la reingresa a la orden — es el camino de deshacer una devolución.
- Un ticketId ya vigente →
409 ticket_id_exists(tratalo como "ya aplicado"). Sector desconocido o repetido en el payload →400 invalid_items.
Cancel — anulación total
202 { passesToRevoke, equityToCancel }. Revoca los wallet emitidos, anula los Equity, cierra todas las bolsas y avisa por mail a cada owner. Irreversible.
(b) Emisión Equity — saliente portal → ticketing
La implementa ticketing. Firmado HMAC, idempotente por passRef+revision.
POST {ticketing}/passes/equity/issue { passRef, revision, orderExternalRef, sectorExternalRef, seatExternalRef, ticketId?, holder:{email,name,locale} } → 200 { equityRef, deepLink? } POST {ticketing}/passes/equity/update (mismo body + equityRef) → 200 { equityRef, deepLink? } ← devuelve SIEMPRE el ref vigente POST {ticketing}/passes/equity/cancel { equityRef, passRef, reason:"ORDER_CANCELLED" }
Toda llamada a update (incluida una corrección de nombre) incrementa revision. Tras el 200 de issue, el portal manda el mail "tu pase está en la app" al titular con tu deepLink.
(c) Callback de beneficiario — saliente portal → marketplace
La implementa cada marketplace (destino: webhookUrl de la orden o tu BaseUrl registrada;
firmado con el secreto HMAC de TU integración — cada origen valida solo lo suyo). Reintentos por outbox. Así se ve una invocación completa:
POST {webhookUrl de la orden} ← o {tu BaseUrl}/passes/beneficiary si la orden no trajo webhookUrl Content-Type: application/json X-Passes-Signature: t=1783971950,v1=9f3ab6… ← HMAC-SHA256(tu secreto, "<t>." + body crudo UTF-8); secreto: GET /api/v1/webhook-secret { "eventId":"…", "type":"ASSIGNED", … } ← body = UNO de los cinco eventos de abajo → tu 2xx = ack · cualquier otra cosa/timeout ⇒ reintentos con backoff (1, 5, 15, 60, 240 min) hasta morir a las 24 h
Cinco tipos de evento:
ASSIGNED | UPDATED (al nominar / re-nominar / corregir) { "eventId":"…", "type":"ASSIGNED", "orderExternalRef":"…", "passRef":"…", "revision":2, "sectorExternalRef":"VIP", "seatExternalRef":null, "holder":{"email":"…","name":"…"}, "deliveryMethod":"WALLET", "ticketId":"11", "barcode":"…", "chain":["agencia@x.com","sub@y.com"], "occurredAtUtc":"…" } ISSUED (v2 — credencial emitida; también en cada re-emisión) { …, "type":"ISSUED", "revision":2, "ticketId":"11", "barcode":"…", "walletUrl":"https://…" /* WALLET: landing para "ver mi entrada" */, "equityRef":"…", "deepLink":"…" /* EQUITY: walletUrl null */ } CANCELLED (v2 — pase fallido descartado por el gestor; su ticketId vuelve al pool y PUEDE reaparecer en otro passRef) { …, "type":"CANCELLED", "revision":1, "ticketId":"11" } RETURNED (v2 — el manager devolvió saldo LIBRE a tu sistema; delta acumulativo, sin passRef) { "eventId":"<returnRef>", "type":"RETURNED", "orderExternalRef":"…", "returnedBy":"agencia@x.com", "items":[ { "sectorExternalRef":"VIP", "qty":4, "ticketIds":["44","55",…], "seatExternalRefs":[], "remainingQty":6 } ], "occurredAtUtc":"…" } → 2xx = ack
- Dedup: eventos por-pase →
eventIdY(passRef, revision, type)(ISSUED es at-least-once). RETURNED →eventId(= returnRef del portal). - Orden: garantizado solo DENTRO del stream de un pase (ASSIGNED→ISSUED→UPDATED→ISSUED…). RETURNED es conmutativo: sumá qty/ids por sector sin asumir orden. Para el mapa ticketId→pase gana el evento con
occurredAtUtcmayor; RETURNED libera incondicionalmente. - Máquina por método: WALLET
ASSIGNED→ISSUED(→UPDATED→ISSUED)*(→CANCELLED)· EQUITYASSIGNED→ISSUED(→UPDATED)*(→CANCELLED). ticketIdes null si la orden no trajo ids (o el pool se agotó):qtyes lo autoritativo; RETURNED lleva "los ids que haya".barcodees el QR estático de respaldo del pase: tratálo como credencial (no lo loguees ni lo muestres).- La devolución total NO cierra la orden: el cierre sigue siendo tuyo vía cancel (a). Los ticketIds devueltos quedan fuera de la orden hasta que los reingreses por PATCH.
(d) Webhook "usada" — entrante puerta → portal
| Método | Endpoint | Auth | Idempotencia |
|---|---|---|---|
| POST | /api/v1/webhooks/access-used | passes.access + HMAC | eventId |
{ "eventId":"…", "passRef":"…", "revision":2, /* o walletSourceRef | equityRef | barcodeValue */
"usedAtUtc":"…", "gate":"P2", "device":"ZEBRA-07" }
→ 200 { "result":"OK" } revision vigente → USED (bloquea la edición del titular)
→ 200 { "result":"STALE_REVISION" } revisión supersedida presentada → NO marca, se audita
→ 200 { "result":"UNKNOWN_REF" } ref desconocido (ack sin retry) · 5xx → reservado a fallas reales(e) Feed de credenciales — entrante puerta → portal
| Método | Endpoint | Auth |
|---|---|---|
| GET | /api/v1/access/events/{eventExternalRef}/credentials?updatedSince=&limit=500 | passes.access + HMAC (body vacío) |
→ { "items":[ { "passRef":"…", "revision":2, "status":"ISSUED", "sector":"VIP", "seat":null,
"holder":{"email":"…","name":"…"}, "barcode":"token-por-revision",
"totp":{"secretHex":"…","periodSeconds":60,"digits":8}, "updatedAtUtc":"…" },
{ "passRef":"…", "revision":1, "status":"SUPERSEDED", "updatedAtUtc":"…" } /* tombstone */
], "nextSince":"…" }- El delta incluye todos los estados:
ISSUED | SUPERSEDED | USED | CANCELLED. El validador deniega ante cualquier status ≠ ISSUED. - Tratá
revisioncomo monotónica (fail-closed): nunca aceptes una revision menor a la última conocida del pase. - Solo pases
WALLET(los Equity los validás contra tu base). El código TOTP se calcula consecretHex(RFC 6238, SHA1,periodSeconds/digits).
Ciclo de vida — el viaje completo de una orden y sus pases
Desde el POST /api/v1/orders hasta la puerta, cada paso con quién lo dispara y
qué interacción genera hacia tu sistema (callbacks/llamadas) o hacia las personas (mails).
POST /orders (a).
El portal crea la orden, la bolsa raíz y el pool de ticketIds.
passes.bag.invitation al manager con el link al portal. Todavía no existe ningún pase.passes.bag.invitation al delegado, en su idioma) y puede reclamar el saldo libre
de una sub-bolsa (✉️ passes.bag.reclaimed). Es pura contabilidad de saldos: no te llega ningún callback
— los ticketIds siguen libres en el pool.QUEUED con revision 1, un barcode propio y — si hay pool — su ticketId (FIFO;
en numeradas, el de la butaca).
📡 Callback (c) ASSIGNED por cada pase, con titular, cadena de delegación, ticketId y barcode.
QUEUED
(lease multi-réplica) y llama a PagoUno Wallet (POST /passes, QR rotativo TOTP — el secreto queda
guardado para la puerta). El pase pasa a ISSUED.
📡 Callback (c) ISSUED con
walletUrl (la landing "ver mi entrada").
passes.pass.wallet al titular con esa landing (Apple/Google según el teléfono).PENDING_EXTERNAL y el
portal llama a tu ticketing: (b) equity/issue. Con tu 200 {equityRef, deepLink} pasa a
ISSUED.
📡 Callback (c) ISSUED con
equityRef + deepLink (walletUrl null).
passes.pass.equity al titular con tu deepLink.FAILED: visible para el gestor, que puede reintentar (vuelve a QUEUED,
repite el paso 4/5) o descartar. Al completarse el lote (con o sin errores):
passes.batch.completed al gestor con el conteo (total / con error)./passes/sync).
En EQUITY sí incrementa revision y va (b) equity/update.
📡 Callback (c) UPDATED.
revision + 1. La revisión
vieja pasa a SUPERSEDED al instante (tombstone en el feed (e): la puerta la rechaza aunque el pass viejo
siga en un teléfono). El portal revoca la credencial vieja (wallet /passes/revoke) y re-emite para el
titular nuevo (repite el paso 4/5).
📡 Callbacks (c) UPDATED (rev N) y luego ISSUED (rev N, credencial nueva).
✉️ Mail al titular NUEVO con su credencial. Editable hasta que el pase se usa en puerta.
USED, se bloquea la edición del titular y el uso aparece en los reportes del portal.
Una revisión supersedida en puerta → STALE_REVISION (no entra, queda auditado).CANCELLED, el cupo se libera y su ticketId vuelve al pool (puede reaparecer en un pase futuro).
📡 Callback (c) CANCELLED con el ticketId liberado.
returnRef.
📡 Callback (c) RETURNED con los ticketIds devueltos (FIFO) y
remainingQty por sector.
✉️ Mail-recibo
passes.bag.returned al manager. El "deshacer" es tuyo: PATCH (a) re-enviando esos ids.POST …/cancel (a):
todos los pases pasan a CANCELLED, el portal revoca cada wallet emitido (wallet /passes/revoke)
y anula cada Equity ((b) equity/cancel), y cierra todas las bolsas.
passes.order.cancelled a cada owner de bolsa.passes.reminder.pending-bag (bolsa nunca abierta a los N días) y
✉️ passes.reminder.balance (saldo sin nominar a N días del evento). No generan callbacks.Estados del pase
reserva de cupo emisión (worker) nominación ────▶ QUEUED ────▶ ISSUING ──┬──▶ ISSUED ─────────────▶ USED (webhook (d); bloquea edición) │ │ │ │ re-nominación: la rev vigente pasa a SUPERSEDED │ ▼ (tombstone en (e)) + revoke wallet + re-emisión rev N+1 → QUEUED │ ├──▶ FAILED ──▶ retry → QUEUED │ └─────▶ discard → CANCELLED (+ (c) CANCELLED, ticket → pool) │ EQUITY: └──▶ PENDING_EXTERNAL ──▶ ISSUED ((b) issue → tu 200) anulación de orden: cualquier estado ──▶ CANCELLED (revoke wallet / (b) equity-cancel masivos)
Matriz de interacciones — quién llama a quién en cada momento
| Momento | Lo dispara | Estado del pase | Llamadas del portal | Mails (motor de notificaciones) |
|---|---|---|---|---|
| Creación de orden | ticketing (a) | — (no hay pases) | — | bag.invitation → manager |
| Delegación | gestor (portal) | — | — | bag.invitation → delegado |
| Reclaim de sub-bolsa | gestor (portal) | — | — | bag.reclaimed → delegado |
| Nominación (materialización) | gestor (portal) | nace QUEUED + ticketId FIFO | (c) ASSIGNED → marketplace | — |
| Emisión WALLET | worker | ISSUING → ISSUED | wallet POST /passes · (c) ISSUED | pass.wallet → titular |
| Emisión EQUITY | worker | PENDING_EXTERNAL → ISSUED | (b) equity/issue → ticketing · (c) ISSUED | pass.equity → titular |
| Falla de emisión | worker | FAILED | — | — |
| Lote completo | worker | — | — | batch.completed → gestor |
| Corrección (mismo email) | gestor (portal) | ISSUED (WALLET: misma rev · EQUITY: rev+1) | wallet /passes/sync o (b) equity/update · (c) UPDATED | — |
| Re-nominación (otro email) | gestor (portal) | rev N → SUPERSEDED · nueva rev QUEUED → ISSUED | wallet /passes/revoke + re-emisión · (c) UPDATED + ISSUED | pass.wallet/equity → titular nuevo |
| Reenvío del mail | gestor o titular (portal) | — | — | pass.wallet/equity → titular |
| Validación en puerta | control de acceso (d) | ISSUED → USED | — | — |
| Sincronización de puerta | control de acceso (e) | lee ISSUED + tombstones | — | — |
| Descarte de FAILED | gestor (portal) | FAILED → CANCELLED (ticket → pool) | (c) CANCELLED | — |
| Devolución al origen | manager raíz (portal) | — (solo saldo libre) | (c) RETURNED | bag.returned → manager |
| Reingreso de devueltos | ticketing (a) PATCH | — | — | — |
| Anulación de orden | ticketing (a) cancel | todos → CANCELLED | wallet revoke × N · (b) equity/cancel × N | order.cancelled → todos los owners |
| Recordatorios | worker diario | — | — | reminder.pending-bag / reminder.balance |
passes.*, es/en,
con el branding del evento). Todas las llamadas salientes van por outbox: si tu endpoint está caído, se
reintenta con backoff (1 m → 4 h) hasta 24 h.Errores y reintentos
| Código | Cuándo | Qué hacer |
|---|---|---|
400 invalid_ticket_ids | count≠qty, duplicados, mezcla con/sin ids en el payload | La respuesta trae detail[] — corregí y reintentá |
400 ticket_ids_required / ticket_ids_not_expected | PATCH que rompe el todo-o-nada del sector | Respetá cómo nació el sector |
400 invalid_webhook_url | webhookUrl no-https o host privado/loopback | Usá una URL https pública |
409 order_conflict | Mismo externalRef con body distinto | El replay debe ser byte-exacto |
409 ticket_id_exists | PATCH con un ticketId ya vigente | Tratalo como "ya aplicado" |
401 | API key inválida o HMAC que no verifica | Revisá key/secreto/reloj (tolerancia ±300 s) |
Salientes con backoff exponencial (1 m → 4 h), DEAD a las 24 h con alerta. Todos los contratos son
idempotentes: reintentá con seguridad usando la misma clave de negocio (externalRef,
amendmentRef, eventId, passRef+revision, returnRef).
Checklist de go-live
Changelog
- v3 (2026-07-13) — multi-marketplace ABIERTO: opera cualquier marketplace con su key de ingesta, sin alta de dominios; los callbacks (c) van a la webhookUrl de cada orden (validación solo técnica: https + host público); secreto HMAC por origen autogenerado + autoservicio
GET /api/v1/webhook-secret. - v2 (2026-07-12) — ticketIds del sistema origen (pool por sector, todo-o-nada), webhookUrl por orden, devoluciones al origen (evento RETURNED con eventId=returnRef), eventos ISSUED y CANCELLED en (c), ticketId en (b), PATCH que reingresa tickets devueltos.
- v1 — contratos (a)–(e), wallet + Equity, QR rotativo con tombstones por revisión.