Gestor de PasesDISTRIBUCIÓN DE ENTRADAS · BOLSAS · WALLET & EQUITY

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ó.

REST + JSON X-Api-Key + HMAC Apple / Google Wallet Equity Callbacks con reintentos ticketIds del origen Devoluciones
Base URL server-to-server: 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

📦
OrdenLa bolsa madre de un evento: sectores, cupos/butacas, branding y el email del primer responsable. La creás vos con el contrato (a).
👜
BolsaSaldo distribuible. La raíz es del manager; cada delegación crea una sub-bolsa. El saldo libre se puede reclamar hacia arriba o devolver al origen.
🎫
Pase + revisionUna entrada nominada a un email. Cada cambio de titular incrementa revision; la vieja queda SUPERSEDED (tombstone) al instante.
🔖
ticketIdEl id de la unidad en TU sistema (opcional). Viaja en la creación y vuelve en cada callback: es tu clave de reconciliación.
✉️
EntregaWALLET: 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.
🔁
OutboxTodo lo saliente ((b) y (c)) sale de un outbox con reintentos y backoff: podés estar caído y los mensajes llegan igual, en orden por pase.

Autenticación

X-Api-Key — audiences por capacidad

AudienceQuién la usaPermite
passes.ingestcada marketplace (una key POR ORIGEN)crear, ampliar y anular órdenes — contrato (a)
passes.accesscontrol de accesowebhook usada (d) + feed de credenciales (e)
Multi-marketplace ABIERTO: tu API key define tu ORIGEN — y es TODO lo que necesitás para operar. Toda orden queda atada a la key que la creó: solo VOS podés ampliarla o anularla (otro origen recibe 404) y tus callbacks (c) se firman con TU secreto HMAC (autogenerado en el primer uso; lo consultás con tu misma key, ver abajo). El destino de los callbacks es la 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

Todo mensaje por-pase ((b) update, (c), (d), (e)) lleva 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étodoEndpointAuthIdempotencia
POST/api/v1/orderspasses.ingesthash byte-exacto del body por externalRef
PATCH/api/v1/orders/{externalRef}passes.ingestamendmentRef
POST/api/v1/orders/{externalRef}/cancelpasses.ingestnatural (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 divergente
  • ticketIds / 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 numeradas count(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 ticketIds importa). 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 → eventId Y (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 occurredAtUtc mayor; RETURNED libera incondicionalmente.
  • Máquina por método: WALLET ASSIGNED→ISSUED(→UPDATED→ISSUED)*(→CANCELLED) · EQUITY ASSIGNED→ISSUED(→UPDATED)*(→CANCELLED).
  • ticketId es null si la orden no trajo ids (o el pool se agotó): qty es lo autoritativo; RETURNED lleva "los ids que haya". barcode es 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étodoEndpointAuthIdempotencia
POST/api/v1/webhooks/access-usedpasses.access + HMACeventId
{ "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étodoEndpointAuth
GET/api/v1/access/events/{eventExternalRef}/credentials?updatedSince=&limit=500passes.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á revision como 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 con secretHex (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).

1
Creación de la orden — ticketing hace POST /orders (a). El portal crea la orden, la bolsa raíz y el pool de ticketIds.
✉️ Mail passes.bag.invitation al manager con el link al portal. Todavía no existe ningún pase.
2
Distribución interna (delegación / reclaim) — el manager delega sub-bolsas por email (✉️ 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.
3
Nominación — un gestor asigna N entradas a un email. El portal reserva el cupo en el momento (sincrónico) y materializa los pases en lotes (asíncrono): cada pase nace 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.
4
Emisión WALLET — un worker toma los pases 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").
✉️ Mail passes.pass.wallet al titular con esa landing (Apple/Google según el teléfono).
5
Emisión EQUITY — el pase pasa a 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).
✉️ Mail passes.pass.equity al titular con tu deepLink.
6
Falla de emisión — si el wallet o tu equity/issue fallan (tras los reintentos), el pase queda 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):
✉️ Mail passes.batch.completed al gestor con el conteo (total / con error).
7
Corrección del titular (mismo email, cambia nombre/observaciones) — NO cambia la revision en WALLET: el portal sincroniza el pass en el teléfono (wallet /passes/sync). En EQUITY sí incrementa revision y va (b) equity/update.
📡 Callback (c) UPDATED.
8
Re-nominación (cambia el email) — 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.
9
Puerta — tu control de acceso sincroniza el feed (e) (barcodes + secretos TOTP por revisión + tombstones) y valida offline. Cuando alguien entra, te avisa con el webhook (d): el pase pasa a 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).
10
Descarte de un FAILED — el gestor lo descarta: el pase pasa a CANCELLED, el cupo se libera y su ticketId vuelve al pool (puede reaparecer en un pase futuro).
📡 Callback (c) CANCELLED con el ticketId liberado.
11
Devolución al origen — el manager raíz devuelve saldo LIBRE (nunca pases nominados) desde el portal, con confirmación e idempotencia por 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.
12
Anulación de la orden — ticketing hace 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.
✉️ Mail passes.order.cancelled a cada owner de bolsa.
13
Recordatorios automáticos (worker diario, cadencia por orden) — ✉️ 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

MomentoLo disparaEstado del paseLlamadas del portalMails (motor de notificaciones)
Creación de ordenticketing (a)— (no hay pases)bag.invitation → manager
Delegacióngestor (portal)bag.invitation → delegado
Reclaim de sub-bolsagestor (portal)bag.reclaimed → delegado
Nominación (materialización)gestor (portal)nace QUEUED + ticketId FIFO(c) ASSIGNED → marketplace
Emisión WALLETworkerISSUING → ISSUEDwallet POST /passes · (c) ISSUEDpass.wallet → titular
Emisión EQUITYworkerPENDING_EXTERNAL → ISSUED(b) equity/issue → ticketing · (c) ISSUEDpass.equity → titular
Falla de emisiónworkerFAILED
Lote completoworkerbatch.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 → ISSUEDwallet /passes/revoke + re-emisión · (c) UPDATED + ISSUEDpass.wallet/equity → titular nuevo
Reenvío del mailgestor o titular (portal)pass.wallet/equity → titular
Validación en puertacontrol de acceso (d)ISSUED → USED
Sincronización de puertacontrol de acceso (e)lee ISSUED + tombstones
Descarte de FAILEDgestor (portal)FAILED → CANCELLED (ticket → pool)(c) CANCELLED
Devolución al origenmanager raíz (portal)— (solo saldo libre)(c) RETURNEDbag.returned → manager
Reingreso de devueltosticketing (a) PATCH
Anulación de ordenticketing (a) canceltodos → CANCELLEDwallet revoke × N · (b) equity/cancel × Norder.cancelled → todos los owners
Recordatoriosworker diarioreminder.pending-bag / reminder.balance
Los mails salen del motor de notificaciones de PagoUno (plantillas 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ódigoCuándoQué hacer
400 invalid_ticket_idscount≠qty, duplicados, mezcla con/sin ids en el payloadLa respuesta trae detail[] — corregí y reintentá
400 ticket_ids_required / ticket_ids_not_expectedPATCH que rompe el todo-o-nada del sectorRespetá cómo nació el sector
400 invalid_webhook_urlwebhookUrl no-https o host privado/loopbackUsá una URL https pública
409 order_conflictMismo externalRef con body distintoEl replay debe ser byte-exacto
409 ticket_id_existsPATCH con un ticketId ya vigenteTratalo como "ya aplicado"
401API key inválida o HMAC que no verificaRevisá 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

① API keysPedí tus keys: passes.ingest (ticketing) y passes.access (control de acceso).
② Secretos HMACIntercambiá el secreto de tu integración (firma de (b)/(c)/(d)/(e)).
③ Creá una ordenProbá el contrato (a) con la colección Postman (incluye el body v2 con ticketIds + webhookUrl y los casos 400/409).
④ Recibí callbacksImplementá el endpoint (c): dedup por eventId + (passRef, revision, type), last-write-wins por revision.
⑤ Equity (si aplica)Implementá (b) issue/update/cancel — idempotente por passRef+revision.
⑥ PuertaCableá el feed (e) + webhook (d) en tu control de acceso (revision monotónica fail-closed).

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.