Contenido

Documentación

API

Los trece endpoints, con sus respuestas, sus errores y sus límites de uso.

Autenticación

Base: https://pov.uy/api/v1. Todo entra y sale en JSON. El dinero va siempre en centavos enteros (64000 = $ 640,00) más su currency; nunca hay decimales. Los identificadores son cadenas opacas: guardalas tal cual y no les supongas forma ni largo.

ClaveCómo se manda
pk_cabecera X-POV-Key: pk_live_…
sk_cabecera Authorization: Bearer sk_live_…

Además, las rutas con pk_ que escriben (POST /holds, POST /holds/{token}/pay) exigen una cabecera Origin válida y presente. Es lo que impide usar server-side una clave pública filtrada: un navegador siempre manda Origin; un script, no.

Orígenes permitidos. Si llamás a la API con la pk_ desde tu propio JavaScript, el dominio tiene que estar en Integración → Orígenes. Las llamadas que hace el widget desde el iframe de POV son del mismo origen que la API y no necesitan estar en la lista.

Qué se corta y qué no. Si la cuenta se suspende, o si vence el plazo de venta de la suscripción, la pk_ deja de autenticar y el widget deja de servirse: no se puede empezar ninguna venta nueva. La sk_ sigue viva a propósito, por dos motivos concretos: confirmar un bloqueo que estaba en vuelo (si no, alguien queda cobrado y sin butaca) y validar entradas en la puerta (si no, quien compró la semana pasada se queda afuera por un problema comercial ajeno).

La función

GET /api/v1/showtimes/{showtimeId}
X-POV-Key: pk_test_xxx
200 OK
{
  "showtime": { "id": "cmf9k2p7x0004qz8lhd3v6r1a", "startsAt": "2026-08-25T23:30:00.000Z",
                "businessDate": "2026-08-25", "format": "2D",
                "language": "es", "currency": "UYU" },
  "event":    { "id": "cmf9k2p6t0001qz8l2fn8w5jd", "title": "La Última Órbita", "type": "MOVIE",
                "posterUrl": "…", "videoUrl": "…", "rating": "13", "durationMin": 128 },
  "venue":    { "id": "cmf9jw1n50002qz8l7b4tc9ky", "name": "Sala 4 — Cine", "type": "CINEMA" },
  "sectors":  [ { "id": "STD", "name": "Platea", "priceCents": 32000, "color": "#3B82F6" } ]
}

event.type: MOVIE · THEATER · CONCERT · SPECIAL_EVENT · OTHER. venue.type: CINEMA · THEATER · AUDITORIUM.

Si la cuenta definió tipos de entrada (jubilado, estudiante, menor…), la respuesta suma ticketTypes con el precio ya resuelto por sector:

"ticketTypes": [
  { "code": "JUBILADO", "name": "Jubilado", "note": "Con credencial vigente",
    "kind": "PERCENT", "value": -3000 },
  { "code": "ESCUELA", "name": "Escuela",
    "kind": "ABSOLUTE", "value": 15000 },
  { "code": "MENOR", "name": "Menor",
    "kind": "PERCENT", "value": -5000,
    "overrides": { "VIP": 19900 } }
]

El precio se calcula sobre el de la butaca —el priceCents de disponibilidad—, así:

PERCENT   precio = butaca + butaca * value / 10000     // -3000 = 30 % menos
FIXED     precio = butaca + value                      // en centavos, puede ser negativo
ABSOLUTE  precio = value                               // no mira el precio de la butaca

// y nunca menos de 0: un descuento más grande que el precio deja la entrada en cero.

overrides es el precio exacto cargado para ESA función y ESE sector, cuando existe: le gana al cálculo. Sólo aparecen los sectores que tienen uno.

Se manda la regla y no el precio ya resuelto, y vale explicar por qué: el precio de una butaca no siempre es el de su sector. Una butaca que se vendió y se devolvió vuelve al inventario con el precio al que se vendió, y una que estaba tomada cuando se cambió el precio de la función conserva el anterior. Con un precio resuelto por sector, esas butacas mostrarían un número y se cobraría otro.

Lo que mostrás es una previsualización: el importe que vale es el que devuelve el bloqueo, recalculado en el servidor.

Ausente = la cuenta no definió ninguno, y todo se vende al precio del sector. GENERAL no aparece en la lista: es el precio del sector, el que ya está en sectors.

businessDate no es la fecha de startsAt. Es el día comercial, calculado en la zona horaria de tu cuenta: la trasnoche del viernes a la 01:30 pertenece al viernes. Es la fecha por la que agrupan los reportes y por la que filtra el panel. Si vas a cuadrar un día contra tus números, cuadralo por businessDate.

Los precios de sectors son los de la sala; el precio que se cobra es el de la butaca en disponibilidad, que puede diferir si esa función tiene precio propio o si le aplicó una regla por día.

Disponibilidad

GET /api/v1/showtimes/{showtimeId}/availability
X-POV-Key: pk_test_xxx
200 OK
{ "showtimeId": "cmf9k2p7x0004qz8lhd3v6r1a", "venueId": "cmf9jw1n50002qz8l7b4tc9ky", "currency": "UYU",
  "seats": [ { "seatId": "A-5", "status": "AVAILABLE", "priceCents": 32000 } ] }

status: AVAILABLE libre · HELD bloqueada por alguien que está comprando · SOLD vendida · BLOCKED sacada de la venta por el cine.

Tipos de butaca. Donde aparece type —en las butacas de un bloqueo y en las entradas— los valores son STANDARD, VIP, ACCESSIBLE (accesible) y COMPANION (acompañante). Se definen en la sala y viajan con ese estado a todos lados: el widget las marca para que el espectador las reconozca.

seatId es la etiqueta visible de la butaca ("A-5"), no un identificador interno. Es la misma cadena que mandás en seatIds al crear un bloqueo, la que aparece en las entradas y la que se ve en el plano. Un solo nombre para la misma butaca, en todos lados.

Usá el ETag. La respuesta trae ETag y Cache-Control: private, no-cache. Reenviá el valor en If-None-Match y si nada cambió recibís 304 sin cuerpo. La disponibilidad de una sala grande son decenas de KB; con esto, consultarla seguido casi no cuesta. La respuesta además viene gzipeada si la pedís con Accept-Encoding: gzip.

Sobre sondear. El widget no sondea: refresca cuando el espectador vuelve a la pestaña y otra vez justo antes de reservar, que son los dos momentos en que la foto puede haber envejecido. Es una decisión medida —leer la sala cuesta en el servidor aunque nada haya cambiado, y el ETag ahorra los bytes, no la consulta—. Si tu integración necesita sondear, hacelo con If-None-Match y mirá los límites.

El plano de la sala

GET /api/v1/venues/{venueId}/layout
X-POV-Key: pk_test_xxx
200 OK
{ "id": "cmf9jw1n50002qz8l7b4tc9ky", "name": "Sala 4 — Cine", "type": "CINEMA",
  "dimensions": { "width": 18, "depth": 22, "height": 8 },
  "focus": { "x": 0, "y": 2.2, "z": 0, "width": 10, "height": 5 },
  "sectors": [
    { "id": "STD", "name": "Platea", "priceCents": 32000, "color": "#3B82F6" }
  ],
  "seats": [
    { "id": "A-1", "row": "A", "number": "1", "sectorId": "STD",
      "type": "STANDARD", "visibility": "PARTIAL",
      "geometry": { "x": -2.17, "y": 0, "z": 3.2,
                    "rotationX": 0, "rotationY": 0, "rotationZ": 0,
                    "eyeHeight": 1.15 } }
  ],
  "modelUrl": null, "has3d": true }

focus es la pantalla (o el escenario): centro y tamaño del plano focal, en metros. has3d: false significa sala solo-2D: el widget no ofrece el recorrido 3D ni aunque tu HTML lo pida.

Las coordenadas de cada butaca van adentro de geometry, no sueltas. Dos campos que suelen buscarse: visibility (FULL, PARTIAL, RESTRICTED) para avisar de una visual parcial en tu propio selector, y eyeHeight, la altura del ojo sentado, si querés pararte en la butaca. El id de la butaca es su etiqueta visible ("A-1") y es la misma que mandás al bloquear.

Es la respuesta más cara de la API —trae todas las butacas— y se pide una sola vez, así que es también la más acotada: 30 por minuto. Si tu página muestra varias funciones de la misma sala, pedilo una vez y cacheálo de tu lado.

El plano es la excepción del modo de prueba, a propósito: una sala no es de prueba ni real, es la misma sala. Una pk_test_ y una pk_live_ ven el mismo plano.

Bloquear butacas

POST /api/v1/holds
X-POV-Key: pk_test_xxx
Origin: https://tucine.com
Content-Type: application/json

{ "showtimeId": "cmf9k2p7x0004qz8lhd3v6r1a",
  "seats": ["F-12", { "seatId": "F-13", "ticketType": "JUBILADO" }],
  "code": "ESTUDIANTE",
  "items": [ { "itemId": "itm_pop", "qty": 2 } ] }

seats — máximo 10 butacas por bloqueo. Cada una va como la etiqueta sola (que significa general: el precio del sector) o con el tipo de entrada elegido para ELLA. Por butaca y no por compra: dos generales y un jubilado es el caso normal de una familia, no la excepción.

seatIds es el nombre de siempre y sigue funcionando igual —acepta las dos escrituras—; lo que no se puede es mandar las dos listas a la vez.

code — código de descuento, opcional, validado y aplicado en el servidor. items — confitería, opcional, sólo si la cuenta la tiene habilitada.

El precio del tipo no se manda: se calcula. Vos elegís el código; el importe de cada butaca lo resuelve el servidor sobre el precio de su sector. Un código que no existe o que la cuenta desactivó responde 422, en vez de cobrar el general en silencio.

Lo mismo con los items, y por la misma razón. Un itemId que no existe —o que está inactivo— responde 422, y una cantidad fuera de rango (0 a 20) también. Si la cuenta no tiene confitería habilitada, pedir items responde 409 NOT_ENABLED. Ninguno se descarta en silencio: el comprador ve lo que eligió en tu pantalla, y un item que se evapora cobra de menos y promete algo que no se entrega. Si no querés items, no mandes items.

201 Created
{ "holdToken": "hld_9c1f…", "showtimeId": "cmf9k2p7x0004qz8lhd3v6r1a",
  "seats": [
    { "id": "F-12", "row": "F", "number": "12", "type": "STANDARD", "priceCents": 32000 },
    { "id": "F-13", "row": "F", "number": "13", "type": "STANDARD", "priceCents": 22400,
      "ticketType": "JUBILADO" }
  ],
  "amountCents": 54400, "currency": "UYU",
  "expiresAt": "2026-08-25T23:20:00.000Z", "status": "ACTIVE" }

Ojo con no confundir type y ticketType: type es qué butaca es —VIP, accesible, acompañante— y lo decide la sala; ticketType es quién la ocupa —jubilado, estudiante— y lo elige el comprador.

amountCents es lo que hay que cobrar, con el descuento, los items y el impuesto ya aplicados, calculado en el servidor. El cliente no fija importes, nunca. El bloqueo dura 10 minutos; vencido, las butacas vuelven solas al inventario.

409 Conflict
{ "error": { "code": "SEAT_TAKEN",
             "message": "Asientos no disponibles",
             "details": { "seatIds": ["F-13"] } } }

Las butacas en conflicto van bajo details, como en cualquier otro error: la forma del sobre es siempre la misma. Sacalas de la selección y volvé a pedir el bloqueo con el resto.

TOO_MANY_HOLDS es 429 y no 422 a propósito: el pedido es válido, lo que sobra son bloqueos abiertos. Con el código correcto sabés que hay que esperar, no que hay que corregir la petición. El tope de bloqueos vivos (5) se cuenta por quien llama —aproximado por IP—, no por función: mil compradores con cuatro butacas cada uno ni se enteran; una sola fuente tratando de bloquear media sala, sí. Detrás de un NAT grande varios compradores comparten el mismo contador.

Consultar y soltar un bloqueo

GET    /api/v1/holds/{holdToken}     X-POV-Key: pk_test_xxx
DELETE /api/v1/holds/{holdToken}     X-POV-Key: pk_test_xxx

GET devuelve el bloqueo con su status: ACTIVE · CONFIRMED · RELEASED · EXPIRED. Cuando está CONFIRMED suma reservationId — es la reconciliación por bloqueo: si se te perdió el webhook reservation.confirmed, con el holdToken que ya tenés preguntás si la venta existe.

No trae publicToken ni los qr: son credenciales de puerta y esta ruta se sirve con la clave pública, que vive en el navegador. El reservationId por sí solo no autoriza nada.

200 OK
{ "holdToken": "hld_9c1f…", "status": "RELEASED" }

DELETE informa el estado real: RELEASED si lo soltó esta llamada, o EXPIRED si ya había vencido. Es idempotente. Si el bloqueo ya se confirmó como venta, responde 409 ALREADY_CONFIRMED y te señala /reservations/{id}/refund: una venta cerrada no se «suelta», deshacerla anula entradas y puede implicar devolver plata.

Confirmar la venta

Cuerpo opcional (buyer, externalPaymentRef) y cabecera Idempotency-Key recomendada. El detalle, con el caso del pago tardío, está en Tu primera venta.

CódigoHTTPQué pasó
HOLD_EXPIRED410el bloqueo venció y no se pudo rescatar. details.unavailableSeats dice cuáles se perdieron.
IDEMPOTENCY_CONFLICT409esa Idempotency-Key ya se usó para otra reserva.
VALIDATION422mandaste un cuerpo y algún dato no es válido (un email que no lo es).
NOT_FOUND404no existe, es de otra cuenta o es de otro modo.
NOT_ENABLED409el pedido está bien, pero tu cuenta —o el modo de la clave— no tiene habilitado eso. No hay nada que corregir en el cuerpo: es algo a contratar o a configurar. Ejemplo: el cobro con la Pasarela POV usando una clave de prueba.
UNAVAILABLE503falta algo de nuestro lado. No es tu pedido: reintentá más tarde.

El código manda, no el número. Cada código sale siempre con el mismo estado —están en una sola tabla del servidor— así que podés despachar por error.code con confianza. Si algún día un caso necesita otro estado, va a llegarte con un código nuevo, no con un número distinto para el mismo.

IDEMPOTENCY_CONFLICT merece un párrafo. Si reusás una clave con un bloqueo distinto, POV rechaza en vez de devolverte la reserva vieja: darte entradas de butacas que el comprador no eligió sería peor que un error. Además suelta el bloqueo nuevo, para no dejar inventario trabado por un descuido. Usá una clave por venta (el id de tu pago sirve), guardala junto al pago y reutilizala en todos los reintentos de esa venta: generarla dentro de la función que reintenta anula la protección.

Check-in de entradas

POST /api/v1/tickets/{token}/validate
Authorization: Bearer sk_live_xxx
Content-Type: application/json

{ "gate": "Puerta 2", "seats": ["F-12"] }

token puede ser el de una entrada (qr_…, una butaca) o el de la reserva (rsv_…, todas). gate es una etiqueta libre de quién o dónde validó, y queda registrada. seats habilita sólo esas butacas de una compra de varias.

200 OK
{ "valid": true, "status": "CHECKED_IN",
  "checkedIn": [
    { "ticketId": "cmf9k7t2v0008qz8lq1m4e3xp", "seatLabel": "F-12",
      "usedAt": "2026-09-14T03:41:57.581Z", "usedBy": "Puerta 2" }
  ],
  "alreadyUsed": [],
  "reservation": { … } }

checkedIn y alreadyUsed traen objetos, no etiquetas. Si vas a mostrarlos en la pantalla de la puerta, usá seatLabel: checkedIn.join() imprime [object Object] justo cuando hay gente esperando. usedAt y usedBy son lo que necesitás para auditar después quién marcó qué y cuándo. La misma forma tiene details.alreadyUsed del 409.

Validar quema la entrada. El segundo escaneo del mismo código responde 409 ALREADY_USED con el instante del primero. Un escaneo parcial —algunas ya usadas— habilita las que faltan y te informa las dos listas.

Por qué existe seats. Escanear el QR de una compra de cuatro entradas habilita las cuatro de una vez: perfecto para una familia que llega junta, y un problema para un grupo que llega separado — el primero en llegar quema las de todos. Con seats entra uno y los demás conservan la suya. Además, el comprador recibe un QR por butaca, así que lo normal es que cada uno escanee el propio.

Programar por referencia

Da de alta o actualiza una función usando tu identificador. Es la mitad servidor de Programación externa.

PUT /api/v1/showtimes/by-ref/{ref}
Authorization: Bearer sk_live_xxx
Content-Type: application/json

{ "venueId": "cmf9jw1n50002qz8l7b4tc9ky", "startsAt": "2026-09-12T23:30:00Z",
  "title": "La Última Órbita", "format": "2D" }
200 OK
{ "showtimeId": "cmf9k2p7x0004qz8lhd3v6r1a", "externalRef": "funcion-4271", "created": true,
  "embed": { "venueId": "cmf9jw1n50002qz8l7b4tc9ky", "ref": "funcion-4271" } }

Siempre 200, con created en el cuerpo diciendo si era nueva. Un 201/200 según el caso te obligaría a distinguir dos respuestas para una llamada que es idempotente a propósito.

Cuatro cosas que conviene tener claras:

El precio no se recibe. Sale de los sectores de la sala, configurados en POV. Es lo que permite abrir esta puerta sin abrir ninguna otra: ni siquiera desde tu backend se fijan importes. Va con sk_: crear inventario no puede quedar detrás de una clave del navegador. El modo lo manda la clave: una sk_test_ crea funciones de prueba y una sk_live_ reales; mandar testMode contradiciendo la clave es un 422. Y no mueve una función de sala: si la referencia ya existe en otra, 409 CONFLICT, porque rehacer el inventario dejaría las entradas ya vendidas apuntando a butacas de otro lugar.

El contenido se reutiliza por título: treinta funciones de la misma película siguen siendo un solo contenido en los reportes. Una cuenta suspendida no programa: acá la sk_ sí se corta — confirmar y validar entradas son las dos excepciones que se dejan vivas, y crear inventario no es ninguna de las dos.

Consultar reservas

Es la pieza para reconciliar: cuadrar tus pagos contra las ventas de POV. Hasta acá sólo se podía preguntar por bloqueo, que sirve para cerrar una venta en curso pero no para cuadrar un día — el holdToken es de la compra, y lo que tenés en la mano es el pago.

GET /api/v1/reservations/{reservationId}
Authorization: Bearer sk_live_xxx
200 OK
{
  "reservationId": "cmt63dj3b000vcpqsxbasv1fq",
  "status": "CONFIRMED",
  "createdAt": "2026-08-25T23:31:02.000Z",
  "updatedAt": "2026-08-25T23:31:02.000Z",
  "amountCents": 49900,
  "currency": "UYU",
  "showtimeId": "cmf9k2p7x0004qz8lhd3v6r1a",
  "businessDate": "2026-08-25",
  "externalPaymentRef": "mp_12345",
  "publicToken": "rsv_5f3a…",
  "buyer": { "name": "Ana Pérez", "email": "[email protected]" },
  "seats": [
    { "id": "F-12", "row": "F", "number": "12", "type": "STANDARD", "priceCents": 32000 },
    { "id": "F-13", "row": "F", "number": "13", "type": "STANDARD", "priceCents": 17900,
      "ticketType": "JUBILADO" }
  ],
  "tickets": [
    { "id": "tkt_1", "seat": { "id": "F-12", "row": "F", "number": "12", "type": "STANDARD" },
      "qr": "qr_7c1e…", "usedAt": null }
  ]
}

buyer es null si no nos mandaste datos del comprador al confirmar, y externalPaymentRef también: la diferencia entre «no nos dieron» y «nos dieron vacío» es la que te dice si conviene empezar a mandarlos.

Buscar, para cuadrar un día

GET /api/v1/reservations?externalPaymentRef=mp_12345
GET /api/v1/reservations?updated_since=2026-08-25T00:00:00Z&limit=50
Authorization: Bearer sk_live_xxx
200 OK
{ "data": [ … ], "nextCursor": "MjAyNi0wOC0yNVQyMzozMTowMi4wMDBafGNtdDYz…" }

Las dos formas existen porque resuelven problemas distintos. Por referencia de pago es la consulta puntual: «este pago que tengo, ¿qué venta cerró?». Por updated_since es el barrido con el que se cuadra un día entero.

Ordena por updatedAt, no por fecha de venta. Una reserva reembolsada hoy tiene que aparecer en el barrido de hoy aunque se haya vendido la semana pasada — si no, cuadrar un día no serviría de nada.

Se pagina por cursor. Pedí la siguiente con ?cursor= y el nextCursor que te devolvió; cuando llega null, no hay más. Es un valor opaco: es una posición, no un dato. Con páginas numeradas, una venta nueva entre dos páginas corre todo un lugar y el barrido saltea una fila sin que nadie se entere.

Hace falta al menos uno de los dos filtros: sin ninguno, esto sería la cuenta entera.

Deshacer una venta

POST /api/v1/reservations/{reservationId}/refund     Authorization: Bearer sk_live_xxx
POST /api/v1/reservations/{reservationId}/cancel     Authorization: Bearer sk_live_xxx

{ "motivo": "función suspendida" }

Las dos hacen el mismo trabajo de inventario —butacas de vuelta, entradas anuladas— y se diferencian en el estado que dejan registrado. refund → REFUNDED: le devolviste la plata al comprador con tu pasarela y avisás. cancel → CANCELLED: la venta se anula sin devolución de por medio (se suspendió la función, se cargó mal, el comprador no fue).

200 OK
{ "reservationId": "cmt63dj3b000vcpqsxbasv1fq", "status": "REFUNDED",
  "seats": ["F-12","F-13"], "amountCents": 64000, "currency": "UYU",
  "alreadyDone": false }

Idempotente por naturaleza: repetirlo devuelve alreadyDone: true y no cambia nada, así que un reintento de red es seguro y no hace falta Idempotency-Key. refund además dispara el webhook reservation.refunded.

Con la Pasarela POV responden 409 GATEWAY_PAYMENT. Ahí el dinero lo tiene POV: el reembolso se hace en Stripe y POV deshace la venta sola al recibir el aviso. Una sola forma de devolver cada peso.

Errores

Todos los errores tienen la misma forma:

{ "error": { "code": "SEAT_TAKEN", "message": "Asientos no disponibles",
             "details": { "seatIds": ["F-12"] } } }

También los 404 de rutas inexistentes: si te equivocás de URL vas a recibir este sobre y no una página de error, así que await res.json() es siempre seguro. Un método equivocado sobre una ruta que sí existe responde 405, que es inequívoco por el código de estado.

codeHTTPSignificado
INVALID_KEY401clave ausente, inválida, revocada, o cuenta suspendida / sin plan vigente
INVALID_ORIGIN403falta Origin o no está en tus orígenes permitidos
FORBIDDEN403la clave existe pero no puede hacer eso (eventos simulados con clave live)
NOT_FOUND404no existe, es de otra cuenta o es del otro modo
CONFLICT409choca con el estado actual (la referencia ya existe en otra sala)
SEAT_TAKEN409butacas ya no disponibles · details.seatIds
ALREADY_CONFIRMED409el bloqueo ya es una venta: usá refund
ALREADY_USED409la entrada ya se había validado · details.alreadyUsed
IDEMPOTENCY_CONFLICT409la Idempotency-Key ya se usó para otra reserva
GATEWAY_PAYMENT409la venta la cobró la Pasarela POV: el reembolso se hace en Stripe
HOLD_EXPIRED410el bloqueo venció · details.unavailableSeats
VALIDATION422datos inválidos · details con el detalle
TOO_MANY_HOLDS429demasiados bloqueos abiertos por el mismo llamante
RATE_LIMITED429pasaste el límite de uso · mirá Retry-After

Límites de uso

Se aplican por cuenta + IP en ventanas de 60 segundos, y también a las lecturas. Hay además un techo por cuenta, diez veces más alto.

# Escrituras                     # Lecturas
POST /holds                      40    GET /showtimes/{id}/availability  120
POST /holds/{token}/pay          20    GET /holds/{token}                120
POST /holds/{token}/confirm      60    GET /showtimes/{id}                60
POST /tickets/{token}/validate  120    GET /venues/{id}/layout            30
PUT  /showtimes/by-ref/{ref}    120
POST /reservations/{id}/refund   30
POST /reservations/{id}/cancel   30

Las lecturas que acompañan una compra van holgadas a propósito, pero /venues/{id}/layout es el tope más bajo de toda la API: es la respuesta más cara y está pensada para pedirse una vez por sesión y quedar cacheada de tu lado.

No hace falta que adivines cuándo reintentar. Toda respuesta de esas rutas —no sólo el 429, y también los 304 Not Modified— trae el estado del contador:

RateLimit-Limit: 40        # tope de la ventana
RateLimit-Remaining: 12    # lo que te queda
RateLimit-Reset: 37        # segundos hasta que se reinicie
Retry-After: 37            # sólo en el 429

Idempotencia

Tres rutas están pensadas para que reintentar sea seguro:

confirm — el mismo holdToken devuelve siempre la misma reserva y las mismas entradas; mandá además Idempotency-Key, una por venta. Vale también para reintentos simultáneos: si tu cliente HTTP dispara otra confirmación mientras la primera sigue en vuelo, todas responden 200 con el mismo reservationId. Nunca vas a recibir un HOLD_EXPIRED por una venta que en realidad se hizo.

refund / cancel — repetirlo devuelve alreadyDone: true. PUT /showtimes/by-ref/{ref} — mil llamadas iguales dejan una sola función. DELETE /holds/{token} también es seguro de repetir: informa el estado real.

Todos los endpoints

Base: /api/v1. La clave pk_ viaja en el header X-POV-Key; la sk_ en Authorization: Bearer … (sólo server-side).

MétodoRutaClavePara qué
GET/showtimes/{id}pk_Función + contenido + sala + resumen de precios.
GET/showtimes/{id}/availabilitypk_Estado y precio por butaca (AVAILABLE/HELD/SOLD/BLOCKED).
GET/venues/{id}/layoutpk_Geometría 2D/3D: sectores, butacas, coordenadas y has3d.
POST/holdspk_Bloquear butacas → holdToken, expiresAt, amountCents (201).
GET/holds/{token}pk_Estado del bloqueo. Si ya es venta, agrega reservationId.
DELETE/holds/{token}pk_Soltar el bloqueo.
POST/holds/{token}/paypk_Pasarela POV: crea el pago del espectador dentro de POV.
POST/holds/{token}/confirmsk_Confirmar (idempotente) → reserva + entradas con QR.
POST/tickets/{token}/validatesk_Check-in en la puerta: marca la entrada como usada.
GET/reservations/{id}sk_Una reserva por su id, con su comprador y sus entradas.
GET/reservationssk_Buscar reservas para reconciliar: por referencia de pago o por lo que cambió.
POST/reservations/{id}/refundsk_Deshacer una venta que ya reembolsaste (idempotente).
POST/reservations/{id}/cancelsk_Anular una venta sin devolución de por medio (idempotente).
PUT/showtimes/by-ref/{ref}sk_Programar una función con tu identificador (idempotente).
POST/test/eventssk_test_Sandbox: dispara un webhook simulado para probar tu receptor.
Todavía no existe la consulta de la disponibilidad histórica de una función (cómo estaba la sala en un momento pasado). Si tu integración la necesita, escribinos antes de planificar sobre ella.