Contenido

Documentación

Guía completa

Todo el manual en una sola página, para buscar con Ctrl+F, imprimir o leer sin conexión. Es el mismo contenido que las páginas separadas.

Si sabés qué estás buscando, el índice te lleva directo. Esta página es larga a propósito.

Qué es POV

POV es un sistema de reservas de asientos que se embebe en tu web. No es un sitio de venta de entradas al que mandás a tu público: es un módulo que vive dentro de tu página, con tu diseño y tu dominio a la vista.

Trae dos cosas que normalmente vienen separadas: un selector de butacas 2D —el plano de la sala, con lo que está libre, tomado y vendido— y una sala en 3D donde el espectador puede pararse en la butaca y ver desde ahí. Las dos leen el mismo asiento: no hay dos mapas que mantener.

El modelo mental

Todo lo demás se entiende a partir de esta secuencia. Vale la pena leerla antes de copiar código.

1. widget        el espectador elige butacas en tu página
2. hold          POV bloquea esas butacas 10 minutos y te avisa
3. cobro         VOS cobrás, con tu pasarela, tu checkout, tus reglas
4. confirm       tu servidor le dice a POV "cobrado" y la venta se cierra
5. entradas      POV emite un QR por butaca

Dónde termina POV y dónde empezás vos: POV administra el inventario —qué butacas hay, cuáles están libres, cuánto vale cada una, quién las tiene bloqueadas y quién las compró— y emite las entradas. La plata no pasa por POV en el plan estándar: el cobro es tuyo, con tu pasarela. Nosotros bloqueamos, avisamos y confirmamos.

Si preferís no montar un cobro, existe el plan Pasarela POV, donde el espectador paga dentro del widget y POV te liquida. Ahí el cobro con tarjeta lo procesa Stripe, que es la pasarela de POV. Está en Administración → Pasarela POV.

Las tres piezas

PiezaQué esDónde vive
Widgetel iframe con el selector y la sala 3Del navegador de tu espectador
APIfunciones, disponibilidad, bloqueos, confirmación, entradastu servidor (y el widget)
Panelsalas, programación, precios, reportes, clavesempresa.pov.uy

Las dos claves

ClaveDónde vaQué puede hacer
pk_live_… / pk_test_…en el navegador (tu HTML, tu JS)leer funciones y disponibilidad, crear y soltar bloqueos
sk_live_… / sk_test_…sólo en tu servidorconfirmar ventas, validar entradas en la puerta, deshacer ventas, programar funciones

La regla es corta: si una operación mueve dinero o quema una entrada, va con sk_. Una clave secreta en el navegador es una clave publicada; si se te escapó una, revocala desde Integración → Claves y creá otra.

Las claves públicas están además acotadas por orígenes permitidos: si tu sitio llama a la API con la pk_ desde https://tucine.com, ese dominio tiene que estar en la lista.

Quickstart (5 minutos)

En dos líneas de HTML tenés el selector funcionando. Poné esto en la página de la función:

<div data-pov data-showtime="cmf9k2p7x0004qz8lhd3v6r1a" data-key="pk_test_xxxxxxxx"></div>
<script src="https://pov.uy/v1/embed.js" async></script>

Eso ya te da: el plano de la sala con la disponibilidad en vivo, la vista 3D, el cálculo del total con impuestos y descuentos, y el bloqueo temporal de las butacas elegidas.

Lo que todavía no te da es la venta cerrada: para eso hay que escuchar el bloqueo, cobrarlo y confirmarlo desde tu servidor. Son los tres pasos de Tu primera venta. Si sólo querés mostrar la sala, con estas dos líneas alcanza y podés parar acá.

¿Preferís partir de algo que ya anda? Hay proyectos completos para clonar.

De dónde sale cmf9k2p7x0004qz8lhd3v6r1a: del panel, en Programación → la función → botón Código, que te copia el <div> entero con tu clave real adentro. Si tu cartelera ya vive en tu CMS y no querés copiar un id por función, mirá Programación externa.

Modo de prueba

POV tiene un sandbox de verdad, no un interruptor de fachada.

Cada clave nace en un modo, y el prefijo lo dice: pk_test_ / sk_test_ operan sólo sobre funciones marcadas de prueba; pk_live_ / sk_live_ sólo sobre las reales. Una función de prueba se marca al crearla en el panel.

Se comparte entre modosEstá aislado
tus salas y su planoel inventario: butacas, bloqueos, ventas
tu contenido (películas, obras)los reportes y los ingresos
tu marca, tus impuestos, tus itemslas entradas y el check-in

Una sk_test_ no puede quemar una entrada real, y una pk_test_ no puede bloquear una butaca que alguien está por pagar. El cruce responde 404, no «modo incorrecto»: la función del otro modo, para esa clave, no existe.

Necesitás una clave pública de cada modo que uses. El iframe de una función de prueba se sirve con tu pk_test_ y el de una real con tu pk_live_; si te falta la que corresponde, la fila de Programación te lo dice en vez de darte un snippet que carga bien y falla al tocar la primera butaca. Cuando terminaste de probar, borrá la función de prueba y listo.

El cobro no se simula. POST /holds/{token}/pay (Pasarela POV) responde 409 con clave de prueba: POV tiene una sola cuenta de cobro, así que un pago «de prueba» sería plata de verdad. Para ejercitar tu receptor de webhooks está POST /test/events.

Tu primera venta

Los tres pasos que van después del widget.

Paso 1 — escuchar el bloqueo

Cuando el espectador aprieta «Reservar», el widget crea el bloqueo y avisa a tu página:

document.querySelector('[data-pov]').addEventListener('pov:hold', (e) => {
  const { holdToken, seats, amountCents, currency, expiresAt } = e.detail;
  // amountCents ya viene con descuentos, items e impuestos aplicados,
  // calculado en el servidor.
  arrancarCheckout({ holdToken, amountCents, currency, expiresAt });
});

El importe no lo calculás vos. amountCents es lo que hay que cobrar, en centavos enteros. Recalcularlo del lado del cliente es la forma más común de terminar cobrando otra cosa que la que se vendió.

Tenés hasta expiresAt —10 minutos desde el bloqueo— para cobrar y confirmar. Pasado ese punto las butacas vuelven al inventario.

Paso 2 — cobrar

Con tu pasarela, tu checkout y tus reglas. POV no participa. Guardá el holdToken junto al pago: es lo que los une.

Paso 3 — confirmar, desde tu servidor

El cuerpo es opcional. Sin él la venta se cierra igual; con él nos decís quién compró y con qué pago, y eso es lo que después ves en Reservas y lo que te deja cuadrar tus pagos contra nuestras reservas. Ningún importe viaja acá: el total sale del bloqueo, calculado en el servidor.

POST /api/v1/holds/hld_abc123/confirm
Authorization: Bearer sk_live_xxxxxxxx
Idempotency-Key: pago-99182
Content-Type: application/json

{ "buyer": { "name": "Ana Pérez", "email": "[email protected]" },
  "externalPaymentRef": "mp_12345" }
200 OK
{
  "reservationId": "cmt63dj3b000vcpqsxbasv1fq",
  "publicToken": "rsv_5f3a…",
  "status": "CONFIRMED",
  "tickets": [
    { "id": "cmt63dj3e000zcpqsw13w3epw",
      "seat": { "id": "F-12", "row": "F", "number": "12", "type": "STANDARD" },
      "qr": "qr_7c1e…" }
  ],
  "amountCents": 64000,
  "currency": "UYU"
}

Con eso la venta está cerrada: las butacas pasan a SOLD y quedan emitidas las entradas, una por butaca, cada una con su qr.

El qr de cada entrada habilita esa butaca en la puerta. El publicToken es la compra entera: sirve para el enlace https://pov.uy/r/{publicToken}, que le muestra al comprador sus entradas y sus QR.

Los datos del comprador se guardan una sola vez, al crear la reserva. Un confirm repetido devuelve la misma venta y no pisa lo registrado: si el reintento trajera otros datos, la reserva terminaría describiendo un pago que no es el que la cerró. Y si preferís no mandarnos nada, no lo mandes: la venta se cierra igual y la columna queda vacía a propósito.

Reintentá sin miedo. Confirmar es idempotente: el mismo holdToken devuelve siempre la misma reserva y las mismas entradas. Mandá Idempotency-Key igual —una por venta— y un timeout de red deja de ser un problema.

Qué pasa si el pago entra justo cuando vence el bloqueo

Es el caso caro y está resuelto. Si el bloqueo venció hace menos de 10 minutos y nadie tomó esas butacas, el confirm las retoma y cierra la venta igual: el comprador pagó, y lo que pagó es lo que recibe. Se factura con el precio cotizado en el bloqueo, no con el vigente hoy.

Si en cambio alguien ya se las llevó, no hay nada que rescatar:

410 Gone
{ "error": { "code": "HOLD_EXPIRED",
             "message": "El bloqueo venció y las butacas ya no están disponibles",
             "details": { "unavailableSeats": ["F-12", "F-13"] } } }

Ese unavailableSeats está para que reembolses con información: podés decirle al comprador qué butaca se perdió, en vez de un error genérico. Un bloqueo que vos soltaste nunca se rescata: soltarlo es una cancelación deliberada.

Si tu checkout se cancela

Soltá el bloqueo para que las butacas vuelvan enseguida al inventario, en vez de esperar los 10 minutos:

document.querySelector('[data-pov]').pov.release(holdToken);

El embed

El loader es un archivo de JavaScript sin dependencias que busca en tu página todos los elementos con data-pov y le pone adentro un iframe a pov.uy.

<div data-pov data-showtime="cmf9k2p7x0004qz8lhd3v6r1a" data-key="pk_test_xxxxxxxx"></div>
<script src="https://pov.uy/v1/embed.js" async></script>

La URL está versionada y es un contrato. Dentro de v1 sólo se agregan atributos, eventos y métodos: nunca se quita ni se cambia el significado de lo que ya existe. Un cambio incompatible saldría en /v2/embed.js y habría que optar por él cambiando la URL. Tu sitio no se rompe solo. (https://pov.uy/embed.js sirve exactamente el mismo archivo, congelado en v1.)

Podés poner varios widgets en la misma página —una cartelera con tres funciones, por ejemplo—: el loader monta cada data-pov por separado y cada uno emite sus eventos sobre su propio nodo. Un <script> alcanza para todos.

El alto se ajusta solo. El iframe le avisa a la página cuánto mide y el loader lo acomoda (entre 200 y 4000 px). No hace falta que le pongas alto ni que escuches nada.

Qué trae el iframe puesto: sandbox acotado (scripts, mismo origen, formularios y los popups de 3-D Secure; no puede navegar tu página), referrerpolicy que no le manda tu URL completa a POV, y carga diferida.

Atributos del div

AtributoRequeridoQué hace
data-povSíMarca el elemento. Sin valor.
data-showtimeSí*Id de la función en POV. Es una cadena opaca: copiala de Programación → «Código» y no intentes construirla ni validar su formato.
data-venue + data-refSí*Alternativa a data-showtime: la sala de POV más tu referencia de la función. Ver Programación externa.
data-keySíTu clave pública pk_….
data-modeNoQué superficie se muestra. Ver la tabla de abajo.
data-title · data-starts-atNoSólo con data-venue+data-ref: título a mostrar y comienzo (ISO 8601). Sin fecha se toma el momento del primer embebido.
data-localeNoIdioma/región con que se escribe la fecha de la función. Sin él manda la configuración regional de tu cuenta: lo normal es no ponerlo. La zona horaria no se cambia desde acá — es a qué hora empieza la función, no cómo se escribe.
data-accentNoColor de acento del selector (#2c69d6), sin entrar al panel. Lo permanente se configura en Marca → Diseño.
data-baseNoOrigen alternativo del widget (desarrollo). Restringido a pov.uy y localhost.

* Va uno de los dos: data-showtime, o el par data-venue + data-ref. Los dos a la vez es un error y el loader lo dice por consola en vez de elegir en silencio.

data-mode

ValorComportamiento
sin atributoSelector 2D + previsualización 3D + compra. Es el modo por defecto.
viewer (o 3d)Sólo el visor 3D, con la disponibilidad de la función. Sin precio, sin selección de compra, sin bloqueo.
3d-compraLa sala 3D es la superficie de selección, con el mismo panel de compra y los mismos eventos que el modo por defecto.

Sobre data-key

data-key es una declaración, no un parámetro: la clave pública que el widget usa la resuelve el servidor a partir de la función, y además por modo (una función de prueba se sirve con tu clave de prueba, una real con la real). Por eso la clave no viaja en la URL del iframe —quedaría en el Referer, en el historial y en los logs de acceso.

Consecuencia práctica que conviene saber: si te equivocás de pk_, el widget funciona igual. Ponela bien de todos modos: es lo que mira soporte cuando algo no anda, y es la clave que usarías si llamás a la API directamente desde tu página.

Eventos que emite

El widget avisa a tu página por dos caminos, y podés usar cualquiera de los dos:

const nodo = document.querySelector('[data-pov]');

// 1. eventos del DOM sobre el propio div
nodo.addEventListener('pov:hold', (e) => console.log(e.detail));

// 2. un callback global, que recibe todos los eventos de todos los widgets
window.POVOnEvent = (payload, nodo) => {
  if (payload.source !== 'pov') return;          // ignorá cualquier otra cosa
  switch (payload.type) {
    case 'pov:hold':  cobrar(payload.holdToken, payload.amountCents); break;
    case 'pov:error': avisar(payload.code, payload.message);          break;
  }
};

El payload va siempre PLANO en e.detail: e.detail.holdToken, e.detail.seats, e.detail.amountCents. Nunca anidado. Además de sus campos propios, todo evento trae source: 'pov' y type, así que payload.type es seguro de leer y sirve para despachar con un solo manejador.

EventoCuándoCampos
pov:readyel widget terminó de montarshowtimeId
pov:selectioncambió la selección de butacasseats, amountCents, currency
pov:holdse creó el bloqueoholdToken, expiresAt, seats, amountCents, currency, discountCents?, concessions?
pov:hold-expiredse agotó la cuenta regresivaholdToken
pov:errorfalló una operacióncode, message
pov:resizecambió el altoheight — lo consume el loader; no necesitás escucharlo

pov:selection se emite en cada cambio, también cuando la selección queda vacía: sirve para pintar un resumen en tu página que siempre esté al día.

amountCents y priceCents son enteros en centavos: 32000 es $ 320,00. Los code de pov:error son los mismos de la API. El más frecuente es SEAT_TAKEN, y viene con la lista exacta de butacas en conflicto — el widget ya las saca de la selección, refresca el plano y se lo dice al espectador con nombre y apellido («La butaca F-12 la tomaron recién»).

Tipos de entrada

Si la cuenta definió tipos —jubilado, estudiante, menor—, el panel de compra suma un desplegable por butaca: dos generales y un jubilado en la misma compra es el caso normal de una familia, no la excepción. El precio de la línea se actualiza al elegir, y pov:selection y pov:hold traen el ticketType de cada butaca.

No hay nada que hacer del lado del anfitrión: se configuran en el panel (Tipos de entrada) y aparecen solos. El importe lo sigue calculando el servidor.

Métodos JavaScript

Cada nodo montado expone su propia instancia:

const nodo = document.querySelector('[data-pov]');

nodo.pov.release(holdToken);                          // soltar el bloqueo
nodo.pov.setTheme({ accent: '#2c69d6' });             // colores de marca, en vivo
nodo.pov.setCss('.pov-seat { border-radius: 2px }');  // CSS propio, en vivo
nodo.pov.focusSeat({ row: 'F', number: '12' });       // enfocar una butaca en el 3D
nodo.pov.key;                                         // la pk_ declarada, para diagnóstico

Y el loader expone su propia API, por si montás widgets a mano:

POV.init();        // monta todos los [data-pov] que todavía no estén montados
POV.mount(nodo);   // monta un div concreto (p. ej. tras traer contenido con JS)
POV.version;       // versión del loader, útil para soporte

release(holdToken) manda el bloqueo de vuelta al inventario en el acto. Llamalo cuando tu checkout se cancela o el comprador cierra el modal: sin eso las butacas quedan tomadas los 10 minutos completos, y a las otras personas que están mirando la sala eso les pesa.

setTheme(colores) acepta accent, screen, available, occupied, selected, seatIcon y background. Es para ajustar en vivo (una previsualización, un tema que cambia con el modo oscuro de tu sitio); lo permanente se configura en Marca → Diseño.

setCss(css) inyecta CSS dentro del widget, saneado antes de aplicarse. Lo permanente vive en Marca → CSS.

focusSeat(butaca) acepta focusSeat('seat_abc') o focusSeat({ row, number }), y repetir la misma butaca vuelve a centrar la cámara. Sólo tiene efecto en data-mode="viewer", que es para lo que se hizo: mostrar en 3D la butaca que alguien eligió en tu sistema de reservas.

Sólo la sala 3D

Para el cine o teatro que ya tiene su sistema de reservas y quiere sumar la sala 3D como argumento de venta, sin cambiar su flujo.

<div data-pov data-mode="viewer" data-showtime="cmf9k2p7x0004qz8lhd3v6r1a" data-key="pk_test_xxxxxxxx"></div>
<script src="https://pov.uy/v1/embed.js" async></script>

Muestra la sala con la disponibilidad real de esa función —libre, tomada, vendida— y deja recorrerla y pararse en cualquier butaca. No muestra precios, no deja seleccionar para comprar y nunca emite pov:hold. Emite pov:ready.

El iframe toma un alto fijo (72 % del alto de la ventana, mínimo 520 px): la escena llena el marco y no se auto-ajusta.

Combinado con focusSeat(), el patrón habitual es: el espectador elige la butaca en tu sistema, y vos le mostrás desde ahí. El mapeo entre tu asiento y el de POV lo resolvés por seatId (del plano de la sala) o por row + number.

Si la sala está marcada solo-2D en el panel, el modo visor no se sirve aunque lo pidas: cae al selector 2D, que vende igual. Es lo que hace que el interruptor de la sala mande de verdad.

Comprar desde la sala 3D

La escena reemplaza al mapa 2D como superficie de selección. El panel de compra de la derecha es exactamente el mismo, y los eventos también.

<div data-pov data-mode="3d-compra" data-showtime="cmf9k2p7x0004qz8lhd3v6r1a" data-key="pk_test_xxxxxxxx"></div>
<script src="https://pov.uy/v1/embed.js" async></script>

La venta nunca depende de que la escena se dibuje. Si el navegador no tiene WebGL, o la sala está marcada solo-2D, aparece el mapa 2D y se vende con él. Mientras se averigua si hay WebGL se muestra el mapa dentro de una caja del alto de la escena, así el cambio no mueve la página de lugar.

Ese orden es a propósito: lo primero que aparece es lo que funciona siempre. Un placeholder se vería más prolijo, pero dejaría la pantalla sin nada usable si la hidratación tarda. Y dentro de la sala, el botón «Elegir butaca» abre un mapa accesible por teclado, así que tampoco hace falta apuntar en 3D.

Programación externa

Para cuando tu cartelera ya vive en tu sistema y no querés cargarla dos veces.

En vez de nombrar la función con el id de POV, la nombrás con el tuyo. El único id de POV que copiás es el de la sala, y lo copiás una vez:

<div data-pov
     data-venue="cmf9jw1n50002qz8l7b4tc9ky"
     data-ref="funcion-4271"
     data-key="pk_test_xxxxxxxx"></div>
<script src="https://pov.uy/v1/embed.js" async></script>

La plantilla de tu web escribe data-ref desde tu propia base. No hay ningún id nuestro que guardar por función. Hay dos formas de que esa referencia exista, y elegís una.

Nivel 1 — tu backend da de alta la función

Tu CMS llama a POV cada vez que publica o mueve una función:

PUT /api/v1/showtimes/by-ref/funcion-4271
Authorization: Bearer sk_live_xxxxxxxx
Content-Type: application/json

{ "venueId": "cmf9jw1n50002qz8l7b4tc9ky", "startsAt": "2026-09-12T23:30:00Z", "title": "La Última Órbita" }

Es idempotente: llamalo mil veces con lo mismo y hay una sola función. Eso es justamente lo que te permite dispararlo en cada guardado, sin llevar la cuenta de si ya existía. Detalle completo en Programar por referencia.

Nivel 2 — la función nace al embeberla

Si activás «Programación desde tu web» en la sala (panel → Salas → la sala), embeber una referencia que todavía no existe la crea en el momento, con las butacas y los precios de esa sala. Sin ninguna llamada previa, sin cargar programación.

<div data-pov data-venue="cmf9jw1n50002qz8l7b4tc9ky" data-ref="funcion-4271"
     data-title="La Última Órbita" data-starts-at="2026-09-12T23:30:00Z"
     data-key="pk_test_xxxxxxxx"></div>

Es la integración más corta que existe y también la más expuesta —el id de la sala está en el HTML de quien la embebe, a la vista de cualquiera—, así que viene con frenos:

el interruptor está apagado por defecto, y es por sala (apagado, una referencia desconocida es un 404 y no pasa nada) · hay un tope de funciones auto-creadas por cuenta · una cuenta suspendida no crea nada · las funciones así creadas quedan marcadas en Programación como «Desde tu web», para que nadie se encuentre con una lista de funciones que no recuerda haber creado.

data-title y data-starts-at los declara el navegador, y es aceptable porque no son dinero: el precio sale de los sectores de la sala configurados en POV y no se recibe de ningún lado. Sin data-title el widget no dibuja el bloque de título — el nombre lo ponés vos, en tu página, alrededor del iframe.

Sala por día

Una muestra permanente, un museo, una sala que abre de corrido: no tiene funciones con horario, tiene días. Se activa por sala («Sala por día») e implica el alta automática, porque el día sólo puede nacer al embeberlo.

Cada día es su propio inventario —la butaca A-1 del martes no es la del miércoles— y el widget muestra el día sin la hora: poner un horario en una sala que abre de corrido haría creer que hay que llegar a esa hora.

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.

Eventos

Un webhook es POV avisándole a tu servidor que algo pasó, sin que tengas que preguntar. Se configuran en el panel: Integración → Webhooks (URL https:// + secreto que se muestra una vez).

Los webhooks son una conveniencia, no la fuente de verdad. Son best-effort con reintentos acotados: si perdés uno, la verdad sigue estando en la API. Para eso está GET /holds/{token}, que con el bloqueo CONFIRMED te devuelve el reservationId.
EventoCuándodata
hold.createdse bloquearon butacasholdToken, showtimeId, seats, amountCents, currency, expiresAt
hold.expiredvenció un bloqueo sin confirmarholdToken, seats, amountCents, currency
reservation.confirmedla venta se cerróreservationId, publicToken, seats, amountCents, currency
reservation.refundedse deshizo una ventareservationId, seats, amountCents, currency, motivo?

reservation.confirmed se emite por los dos caminos de venta: tu llamada a confirm y —con la Pasarela POV, donde no llamás a nada— el cobro acreditado.

reservation.refunded sale por tres: tu llamada a /reservations/{id}/refund, el botón Deshacer de la pantalla Reservas del panel, y el reembolso hecho en Stripe si usás la Pasarela POV. Para tu sistema los tres son la misma novedad. En ese momento las butacas vuelven a estar disponibles y las entradas dejan de validar en la puerta.

Sobre hold.created: te llega todo bloqueo de esa cuenta, también los que abandona el espectador. Si tu lógica reacciona a él, contá con que la mayoría no va a terminar en venta.

El sobre

{
  "id": "cmf9k2p6t0001qz8l2fn8w5jd",             // único por evento — deduplicá por acá
  "type": "reservation.confirmed",
  "created": 1787012345,           // epoch en SEGUNDOS
  "data": { … }                    // carga propia del tipo
}

Cómo llega POV a tu servidor

La entrega es un POST de servidor a servidor: no hay navegador, no hay cookies y no se ejecuta JavaScript. Cada pedido viaja así:

POST /tu-endpoint
User-Agent:        POV-Webhooks/1 (+https://pov.uy/docs/webhooks)
Content-Type:      application/json
X-POV-Signature:   t=1787012345,v1=3f8a…

POV no sigue redirecciones en un POST firmado: configurá la URL final, no una que redirija. Y esperá 2xx en menos de 5 segundos: contestá apenas recibís el evento y hacé el trabajo después. Se reintenta 3 veces con espera creciente; después se marca como no entregado.

Y podés verlo desde el panel. En Integración → Webhooks cada endpoint muestra sus últimas entregas con el código que devolvió tu servidor, así que si algo no llega, ahí dice por qué.

Verificar la firma

Cada entrega trae:

X-POV-Signature: t=1787012345,v1=3f8a…

La firma es HMAC-SHA256(secreto, "{t}.{cuerpo}") en hexadecimal, donde {cuerpo} son los bytes exactos que recibiste. No la calcules sobre el JSON reserializado: un espacio de más y no coincide.

Para tener el cuerpo crudo: en Express, express.raw({ type: 'application/json' }); en Flask, request.get_data(); en PHP, file_get_contents('php://input').

// Node — el cuerpo CRUDO, sin parsear
const crypto = require('node:crypto');

function verificar(rawBody, header, secreto, toleranciaSeg = 300) {
  const campos = String(header || '').split(',').map((kv) => kv.split('='));
  const t = Number(campos.find(([k]) => k === 't')?.[1]);
  // OJO: puede haber VARIOS v1 (durante una rotación de secreto). No uses
  // Object.fromEntries: se quedaría con uno solo y rechazarías eventos válidos.
  const firmas = campos.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || firmas.length === 0) return false;
  // 1) Ventana temporal: descartá lo viejo para acotar los replays.
  if (Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;
  // 2) Firma esperada sobre "{t}.{body}".
  const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(esperada, 'hex');
  // 3) Alcanza con que UNA coincida. Comparación en tiempo constante (nunca ===).
  return firmas.some((f) => {
    const b = Buffer.from(f, 'hex');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}
# Python (Flask) — request.get_data() devuelve el cuerpo crudo
import hashlib, hmac, time

def verificar(raw_body: bytes, header: str, secreto: str, tolerancia=300) -> bool:
    campos = [p.split("=", 1) for p in (header or "").split(",")]
    t = next((v for k, v in campos if k == "t"), None)
    # Puede haber VARIOS v1 (rotación de secreto): un dict se quedaría con uno solo.
    firmas = [v for k, v in campos if k == "v1"]
    if not t or not firmas or abs(time.time() - int(t)) > tolerancia:
        return False
    esperada = hmac.new(
        secreto.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return any(hmac.compare_digest(esperada, f) for f in firmas)
<?php
// PHP — file_get_contents('php://input') devuelve el cuerpo crudo
function pov_verificar(string $raw, string $header, string $secreto, int $tol = 300): bool {
    $t = null; $firmas = [];
    foreach (explode(',', $header) as $par) {
        [$k, $v] = array_pad(explode('=', $par, 2), 2, '');
        if ($k === 'v1') $firmas[] = $v; elseif ($k === 't') $t = (int) $v;
    }
    if (!$t || abs(time() - $t) > $tol) return false;
    $esperada = hash_hmac('sha256', $t . '.' . $raw, $secreto);
    foreach ($firmas as $f) if (hash_equals($esperada, $f)) return true;
    return false;
}

Recorré TODOS los v1=. Es el error clásico: quedarse con el primero funciona hasta el día que rotás el secreto, y ahí se rompe justo cuando no querés. Compará en tiempo constante y revisá t contra tu reloj para descartar reenvíos viejos.

Reintentos y deduplicación

3 intentos por entrega, con 0,5 s y 2 s de espera entre ellos. 5 segundos de timeout por intento. Se considera entregado con cualquier 2xx.

Respondé rápido y hacé el trabajo después. Si tu handler tarda más de 5 segundos, para POV la entrega falló y va a reintentar aunque vos la hayas procesado bien. Contestá 200 apenas verificás la firma y encolá el resto.

Deduplicá por event.id. Un reintento trae el mismo id, así que guardarlo y descartar repetidos alcanza para que procesar dos veces no duplique nada de tu lado.

Probarlos sin esperar

POST /api/v1/test/events
Authorization: Bearer sk_test_xxx
Content-Type: application/json

{ "type": "reservation.confirmed" }

Dispara ese evento por el camino real: mismo despacho, misma firma, mismos reintentos, misma deduplicación. Es lo que te deja probar tu verificación de firma —que es lo único que de verdad puede salirte mal— sin fabricar la situación ni esperar diez minutos a que venza un bloqueo.

Con data mandás tu propio payload; sin data recibís uno de ejemplo con la forma real. El payload lleva siempre simulated: true, para que puedas distinguirlo de uno real aunque te equivoques de entorno. Con una clave live responde 403: un evento inventado sobre datos reales sería una orden de «entregá la entrada» que nadie compró. Y no toca inventario: no crea bloqueos ni reservas.

Rotar el secreto sin cortar nada

En Integración → Webhooks → Rotar secreto, POV genera uno nuevo y firma cada evento con los dos (dos v1= en la cabecera). Actualizás tu copia cuando te queda cómodo, verificás que todo sigue validando y recién ahí cerrás la rotación desde el panel. No se pierde un solo evento y no hay ninguna ventana en la que nada valide.

Eso es exactamente lo que hace que el ejemplo de arriba recorra todos los v1= en vez de quedarse con el primero.

Cada una de estas recetas está también como proyecto corriendo, listo para clonar: Ejemplos. Acá está el porqué; allá, el código entero.

Las dos primeras recetas son del plan Autogestionado: cobrás vos, con tu cuenta y tu pasarela, y POV no toca la plata. Sirve cualquier pasarela —acá están las dos más pedidas—. Si preferís que cobre POV, eso es el plan Pasarela POV, no se integra: se configura en el panel.

Cobrar con Mercado Pago

1. Embebés POV en la página de la función.
2. Escuchás `pov:hold` y te quedás con { holdToken, amountCents, currency }.
3. Tu backend crea una Preference de Mercado Pago por `amountCents`,
   con external_reference = holdToken.
4. El comprador paga en Mercado Pago.
5. Tu backend recibe la notificación de MP y verifica que el pago está aprobado.
6. Tu backend llama a POST /holds/{holdToken}/confirm con Idempotency-Key
   igual al id del pago de MP.
7. Le mostrás al comprador sus entradas, o el enlace https://pov.uy/r/{publicToken}.

Detalles que evitan los problemas típicos:

external_reference = holdToken es lo que une los dos mundos. Sin eso, la notificación de MP no sabe qué reserva cerrar.

Idempotency-Key = id del pago de MP. MP puede notificarte el mismo pago varias veces; con la clave atada al pago, confirmar dos veces devuelve la misma reserva.

Mirá los 10 minutos. Si el comprador se demora en el checkout de MP, el bloqueo puede vencer. El confirm rescata hasta 10 minutos después si las butacas siguen libres; si devuelve 410, usá details.unavailableSeats para reembolsar diciendo qué butaca se perdió.

Si el comprador vuelve sin pagar, llamá a nodo.pov.release(holdToken) para devolver las butacas enseguida.

Cobrar con tu cuenta de Stripe

Igual que arriba, cambiando las piezas:

3. Tu backend crea un PaymentIntent por `amountCents` en `currency`,
   con metadata.holdToken = holdToken.
5. Tu webhook recibe `payment_intent.succeeded` y lee metadata.holdToken.
6. POST /holds/{holdToken}/confirm con Idempotency-Key = payment_intent.id.

Confirmá desde el webhook, no desde el retorno del navegador. El navegador puede cerrarse justo después de pagar; el webhook no.

Esto es tu Stripe, no el nuestro: es el plan Autogestionado y la plata te llega directo. Si preferís que POV cobre, eso es la Pasarela POV y es otro plan.

Tu cartelera, tus ids

Ya tenés la programación en tu CMS y no querés cargarla dos veces.

1. Creás la sala en POV una sola vez, con sus sectores y precios.
   Copiás su id: es el único id nuestro que vas a guardar.
2. En cada guardado de una función en tu CMS, tu backend llama a
   PUT /api/v1/showtimes/by-ref/{tu-id} con { venueId, startsAt, title }.
3. Tu plantilla escribe el <div> con data-venue (fijo) y data-ref (tu id).

Los precios se administran en POV, por sector y por función. Es lo que hace que esta puerta sea segura de abrir: tu sistema nombra las funciones, POV pone los importes.

Si además activás «Programación desde tu web» en la sala, te podés saltear el paso 2: la función nace la primera vez que alguien abre esa página. Leé los frenos en Programación externa antes de encenderlo.

Tu propio scanner en la puerta

El panel ya trae una consola de ingreso (Ingreso, funciona con cualquier lector USB). Si preferís tu propia app:

1. Tu app escanea el QR y obtiene el token (qr_… o rsv_…).
2. Tu backend llama a POST /api/v1/tickets/{token}/validate con sk_,
   pasando `gate` para saber después por qué puerta entró.
3. 200 → adelante.  409 ALREADY_USED → ya se usó, y te dice cuándo.

Con un rsv_ de una compra de varias, decidí: sin seats entran todos de una; con seats entra el que llegó. Si tu puerta necesita velocidad, validá derecho; si el grupo llega separado, preguntá. El QR de cada entrada habilita su butaca, que es el camino normal.

Reconciliación de pagos

Cuadrar tu día contra POV. Hay dos formas, y conviene saber cuál usar cuándo.

Cuadrar un día entero

Barrés lo que cambió desde tu último corte y lo comparás contra tus pagos:

GET /api/v1/reservations?updated_since=2026-08-25T00:00:00Z&limit=100
Authorization: Bearer sk_live_xxx

# → { "data": [ … ], "nextCursor": "…" }
# Seguí pidiendo con ?cursor=<nextCursor> hasta que venga null.

Cada reserva trae externalPaymentRef —el id que le pusiste al confirmar—, así que el cruce contra tus pagos es directo. Y como ordena por fecha de actualización y no por fecha de venta, una reserva reembolsada hoy aparece en el barrido de hoy aunque se haya vendido la semana pasada: si no fuera así, cuadrar un día no serviría de nada.

Un pago suelto que no cuadra

GET /api/v1/reservations?externalPaymentRef=mp_12345
Authorization: Bearer sk_live_xxx

Vacío significa que ese pago no cerró ninguna venta: cobraste y no hay entradas, así que hay que reembolsar. Si tenés el id de la reserva, también podés ir directo con GET /api/v1/reservations/{id}.

Si sólo guardaste el bloqueo

Sirve igual, y es lo que había antes de que existiera la consulta por reserva:

GET /api/v1/holds/{holdToken}
  · status CONFIRMED → hay venta, y `reservationId` es su id.
  · status EXPIRED o RELEASED → cobraste algo sin venta: hay que reembolsar.
  · status ACTIVE → todavía está en curso.

Reconfirmar también sirve: POST /holds/{token}/confirm es idempotente, así que si dudás si confirmaste, confirmá de nuevo — te devuelve la misma reserva.

Lo que hace que todo esto funcione es mandar el id de tu pago en el confirm, como externalPaymentRef. Sin eso, el único hilo entre los dos sistemas es el holdToken, y tenés que guardarlo vos. Con eso, la búsqueda va en la dirección que importa: del pago a la venta.

Personalizar el widget

Tres niveles, de menos a más.

1. Colores, desde el panel

Marca → Diseño: acento, pantalla, butaca libre, ocupada, seleccionada, ícono y fondo. Se aplican solos a todos tus widgets. Es la forma recomendada: no requiere CSS y se ve al instante.

Ahí mismo subís tu logo (PNG/JPG/WebP hasta 8 MB), que es lo que se ve en la pantalla de la sala 3D cuando el contenido no trae imagen propia.

2. Colores en vivo, desde tu página

document.querySelector('[data-pov]').pov.setTheme({
  accent: '#2c69d6',
  available: '#2A2F3A',
  occupied: '#555',
});

Para previsualizar, o para seguir el modo oscuro de tu sitio. Desde el HTML también podés fijar data-accent.

3. CSS propio

Marca → CSS para lo permanente (hasta 40 KB), o nodo.pov.setCss(css) en vivo. Se inyecta después de los estilos base, así que puede sobreescribir casi todo: tipografía, espaciados, bordes, formas.

Scopealo con [data-embed], que envuelve todo el widget. Para encontrar los selectores exactos, abrí el widget y usá el inspector del navegador.

[data-embed] { font-family: 'Poppins', system-ui, sans-serif; }
[data-embed] button { border-radius: 9999px; letter-spacing: 0.01em; }

El CSS se sanea antes de aplicarse: no se aceptan hojas que intenten salirse del bloque de estilo, y si el panel te rechaza una, te dice por qué.

El CSS afecta el selector 2D. La escena 3D no se configura por CSS sino desde el editor de la sala: modelo de butaca, tapizado, luces, pantalla.

Locales y salas

El panel de tu empresa vive en empresa.pov.uy. No hace falta ser técnico: se opera con formularios, y lo que cargás acá es lo que muestra el widget.

La estructura es Empresa → Local → Sala. Un cine con tres complejos tiene un local por complejo, y las salas cuelgan de cada uno.

Crear una sala (Salas → Nueva) pide tipo (cine / teatro / auditorio), medidas, filas y butacas por fila, precio base, pendiente del piso, un sector VIP opcional (las últimas N filas con su propio precio) y las butacas accesibles. POV genera el plano completo, con sus butacas numeradas.

También podés partir de una foto de la sala («Crear con IA»): POV estima filas, butacas, VIP y pendiente, y te deja el plano listo para ajustar en el editor antes de guardar. La foto no se guarda. Está disponible en las cuentas con generación de salas por IA habilitada.

El editor de sala (Salas → la sala → Editor) es un plano interactivo que podés mover y girar. Ahí se mueve, rota, agrega y numera butacas; se definen sectores con su color, nombre y precio; se ajustan pasillos, escalones, la inclinación del piso y la distancia a la primera fila. También se ubica la pantalla o el escenario y el foco de cámara —desde dónde arranca el visor—, y se puede importar o exportar un modelo GLB propio para usar la sala real en 3D.

Interruptores por sala que conviene conocer:

InterruptorQué hace
Mostrar en 3Dapagado, la sala se vende sólo con el mapa 2D, aunque el sitio pida el visor
Modelo de butacarealista (modelo 3D) o simple (más liviana; no descarga el modelo)
Tapizadocolor y relieve de la butaca libre
Luz de escalonestira de luz por escalón en los pasillos, con su color e intensidad
Programación desde tu webapagado por defecto — ver Programación externa
Sala por díala sala no tiene horarios, tiene días

Cada butaca que definís alimenta a la vez el mapa 2D, la escena 3D, el inventario, el bloqueo y la reserva. No hay dos mapas que sincronizar: cargás la sala una vez y todo queda consistente.

Contenido y programación

Contenido es lo que se proyecta o se presenta: película, obra, concierto. Trae buscador de películas (título, póster, duración, calificación) y acepta un video propio que se reproduce en la pantalla de la sala 3D.

Programación es cada función: contenido + sala + día y hora. Desde ahí podés crear una función —sola o importando un CSV, con archivo de ejemplo—, marcarla de prueba (se puede cambiar después, mientras no haya vendido), fijarle un precio propio, copiar el código de integración de esa función con tu clave real adentro, y asignar butacas a mano (invitados, prensa, protocolo) y liberarlas.

Dos funciones no se pueden pisar en la misma sala. Una función ocupa desde que empieza hasta que termina —según la duración del contenido— más un margen para vaciar y limpiar. Los dos valores se configuran por cuenta. Sin esto, dos funciones encimadas compartían las butacas físicas y la misma butaca se vendía dos veces.

La hora que cargás es la de tu cuenta, no la del servidor ni UTC: si tu empresa está configurada en otra zona, «20:00» son las 20:00 de tu reloj. Lo mismo vale para el CSV.

Precios y reglas

El precio se define en tres niveles, del más general al más específico:

1. Sector de la sala — «la platea vale 320, el balcón 520». Es la base. 2. Precio de esta función — Programación → la función → «Precio de esta función»: un estreno, una matinée, una función especial. 3. Reglas por día — «los viernes la platea vale 30 % más».

Los precios se guardan como enteros en centavos (32000 = $ 320,00) y el importe de cada compra lo recalcula POV en el servidor con el precio vigente de cada butaca. El navegador nunca fija el total.

Por eso cambiar una regla no mueve lo que ya está a la venta — el precio de una función publicada no puede cambiar solo. Para propagarla hay un botón explícito, «Aplicar a las funciones futuras», y al aplicarla sólo se tocan las butacas libres o bloqueadas: la vendida se cerró a su precio y la que está en un bloqueo tiene un total ya cotizado.

Una regla puede acotarse a una sala, a un sector, y a una franja horaria (matinée, trasnoche). La franja puede cruzar la medianoche (22:00 → 02:00).

Dos cosas que se miden distinto de lo que uno espera, y están bien así: el día sale de la fecha comercial, no de la hora de inicio —la trasnoche del viernes a la 01:30 es viernes y se cobra como viernes—; y la hora es la de tu cuenta, no UTC, porque medirla en UTC correría todas las franjas.

Precedencia: sala + sector → sala → sector → general. Dentro de cada nivel, la regla con franja le gana a la que no la tiene: así se escribe «los domingos +30 %, salvo la matinée».

Lo que se rechaza al crear una regla es lo ambiguo, no lo superpuesto: chocan dos franjas que se pisan, o dos reglas sin franja para el mismo alcance. «Todo el día» conviviendo con una franja no es ambiguo — lo resuelve la precedencia.

Las reglas se aplican por igual en los tres caminos de alta (panel/CSV, programación por referencia y alta automática al embeber): una regla que valiera en uno solo cobraría distinto el mismo viernes según por dónde entró la función.

Tipos de entrada

Jubilado, estudiante, menor, escuela… los que quieras, con el precio que quieras. Se crean en Tipos de entrada, y el espectador elige el de cada butaca en el widget.

El precio se escribe en relación al del sector y por eso no hay que cargarlo sector por sector: si la platea vale $ 320 y el balcón $ 520, «30 % menos» da $ 224 y $ 364 sin tocar nada más, y si movés el precio del sector los tipos lo siguen solos. Las tres formas son:

FormaSe escribeEjemplo
% sobre el sectorel porcentaje-30 → «30 % menos»
Suma o resta fijael importe-50 → «$ 50 menos»
Precio fijoel importe150 → sale $ 150, no mira el sector

Cuando una función necesita un número exacto —un estreno, una matinée— se carga en Programación → la función → Precio de esta función, y ese número le gana al cálculo del tipo.

El código no cambia nunca. Se deriva del nombre al crearlo (JUBILADO) y queda congelado en cada entrada vendida: si cambiara al renombrar, una entrada de la semana pasada diría un tipo que ya no existe. El nombre sí se puede corregir.

Ocultar saca el tipo del widget sin borrar nada, y lo ya vendido con él sigue diciendo con qué se vendió. Un tipo oculto tampoco se puede comprar por API: responde 422 en vez de cobrar el general en silencio.

Sin ningún tipo creado, cada butaca se vende al precio de su sector — que es exactamente como funcionaba antes de que existieran.

Descuentos

Códigos que el espectador escribe en el widget. Pueden ser porcentaje o monto fijo, acotarse a una función y/o a un sector, tener vencimiento y un tope de usos, y activarse o desactivarse cuando quieras.

Igual que los precios, el descuento se valida y se calcula en el servidor al crear el bloqueo: el navegador no puede inventarse un importe con descuento.

Items (confitería)

Pop, gaseosas, combos. Se habilita por cuenta, y entonces el widget suma un segundo paso después de elegir butacas.

Van en el mismo pago, con el mismo impuesto, y viajan en pov:hold bajo concessions. Es venta libre: no llevan inventario ni stock.

Reservas y reportes

Al gestionar una función ves su mapa de butacas con el estado de cada una, y podés asignar butacas a mano y liberarlas después. Desde ese mapa sólo se sueltan asignaciones manuales: una butaca vendida no se libera ahí, para eso está Deshacer.

Reservas lista las ventas, con filtros por fecha comercial, sala y estado, y búsqueda por compra o por QR. Desde ahí se deshace una venta (mismo efecto que la API) y se anonimiza al comprador de una reserva o de todas: se borran nombre y contacto y se conservan importes, butacas y estado, que es lo que hace falta para la contabilidad.

Reportes agrupa por fecha comercial y exporta a CSV. Las funciones de prueba no entran: el sandbox no ensucia los números.

Ingreso (check-in)

Consola de puerta que funciona con cualquier lector USB. Escanea el QR de una entrada (qr_…) o el de la compra entera (rsv_…) y marca las entradas como usadas.

Cuando hay más de una entrada sin usar en la misma compra, pregunta cuáles habilitar: eso es lo que evita que el primero de un grupo queme las entradas de todos. Cuando no hay nada que decidir —una entrada suelta, o una compra con una sola sin usar— valida derecho, porque una puerta necesita velocidad. Antes de decidir se puede mirar sin consumir.

La misma operación está en la API si preferís tu propio scanner.

Equipo y permisos

Tres niveles: owner de la plataforma (nosotros) → administrador de la empresa → colaboradores.

Un colaborador se define con permisos finos: salas · programación · pagos · integración. Soporte e Ingreso no exigen permiso: los usa cualquiera del equipo. Gestionar colaboradores es sólo del administrador de la empresa.

Verificación en dos pasos. Es obligatoria a los 10 días del alta de la cuenta. No se pide en el primer login —enrolarse necesita el teléfono a mano y nadie quiere descubrir eso en el mostrador— pero después sí: una cuenta del panel administra dinero, datos de compradores y claves de API, y una contraseña filtrada alcanzaría para todo eso. Se puede entrar también con Google, y el segundo factor se exige igual. Hay códigos de recuperación de un solo uso, que se muestran una vez.

Claves, orígenes y webhooks

Claves. Se crean por tipo (pública / secreta) y por modo (live / prueba). La secreta se muestra una sola vez: se guarda hasheada y no se puede recuperar. Para rotar, creá la nueva y revocá la vieja después.

Orígenes permitidos. Los dominios desde los que tu sitio puede llamar a la API con la pk_. Webhooks. URL https://, secreto, activar/desactivar, rotar sin corte y borrar.

Guardas que evitan el susto: no se puede revocar la última clave live activa de cada tipo —te quedarías sin widget, o sin poder confirmar ventas ya cobradas—, ni quitar el último origen. El panel no ofrece el botón y el servidor lo rechaza igual, por si acaso. Cualquier cambio en la integración avisa por correo a todo el equipo.

Facturación

Tu suscripción a POV, tus facturas (descargables en PDF) y el cambio de plan o de período.

PlanPrecioQuién cobra
AutogestionadoUS$ 80/mes · US$ 912/añovos, con tu pasarela
Integración asistidaa consultarsegún la modalidad que elijas
Pasarela POVUS$ 120/mes · US$ 1.368/año + 5 % por entradaPOV, y te liquida

El anual son doce meses con 5 % de descuento (US$ 912/año = US$ 76 por mes).

La baja se pide desde el panel y no corta en el acto: el período ya está pagado, así que la cuenta sigue funcionando hasta el final. Se puede dar marcha atrás en cualquier momento antes de esa fecha.

Si un cobro falla la cuenta pasa a en mora y te avisamos, pero no se cierra el panel ni se corta la venta: que rebote una tarjeta significa que seguimos intentando cobrarte, no que te fuiste. En esa situación el botón principal es «Actualizar la tarjeta».

Si la suscripción termina, el widget sigue vendiendo 30 días más y te avisamos con la fecha exacta. Un cine publica su programación con semanas de anticipación: cortar el mismo día dejaría sin butacas funciones ya anunciadas, y el espectador no tiene nada que ver con el vínculo comercial. Pasado ese plazo, la pk_ deja de autenticar y el widget deja de servirse. La sk_ no se corta: las entradas ya vendidas se siguen validando en la puerta.

Pasarela POV

El plan donde el espectador paga dentro del widget y POV te liquida, reteniendo su comisión (5 % por entrada, por defecto).

La pasarela de POV es Stripe. El espectador paga con tarjeta y el cobro lo procesa Stripe; POV administra el inventario, emite las entradas y te liquida. Vale saberlo de antemano porque decide dos cosas prácticas: cómo cobrás (hay que conectar una cuenta de cobro, o que POV cobre y te transfiera) y cómo devolvés —el reembolso se hace en Stripe—.

Con este plan no llamás a confirm. El widget crea el pago con POST /holds/{token}/pay, y la venta se cierra sola —bloqueo → vendido, entradas emitidas y correo enviado— cuando el cobro se acredita. Tu única integración es embeber el widget.

Al arrancar el cobro el bloqueo se extiende: el 3-D Secure puede tardar varios minutos y las butacas no se pueden liberar con el pago en curso.

Se administra en la sección Checkout del panel:

Mail — el correo de entrada que recibe el comprador: remitente, dirección de respuesta, asunto y mensaje, con vista previa. Configuración — activar el cobro (requiere tus datos bancarios) y conectar la cuenta de cobro. Resumen — lo cobrado, los compradores y las liquidaciones, exportables a CSV y Excel.

Hay dos modos de liquidación: automática (el neto va directo a tu cuenta conectada) o manual (POV cobra y te transfiere). El dinero se libera después de un plazo configurable, y la liquidación del neto puede tomar hasta 60 días corridos según el medio de pago del espectador y sus tiempos de acreditación y contracargo (Términos, cláusula 2.3). Si necesitás liquidez inmediata, el plan Autogestionado te la da: ahí cobrás vos.

En este plan el reembolso se hace en Stripe, y POV deshace la venta al recibir el aviso. /reservations/{id}/refund responde 409: una sola forma de devolver cada peso.

Dos cosas que están así a propósito. El cobro no se simula: POST /holds/{token}/pay con clave de prueba responde 409, porque POV tiene una sola cuenta de cobro y un pago «de prueba» sería plata real. Y el espectador no paga tu problema comercial: con la cuenta en mora, o con la suscripción terminada dentro del plazo de gracia, bloquear butacas y cobrar siguen funcionando.

Tu cuenta y soporte

En Mi cuenta cambiás tu foto, tu nombre y tu contraseña, vinculás tu cuenta de Google para entrar con un clic, administrás la verificación en dos pasos y ves tus sesiones abiertas, con la posibilidad de cerrar una sola —la del teléfono que perdiste— sin echarte a vos mismo.

En Soporte abrís tickets con el equipo de POV y seguís la conversación desde el panel, con capturas adjuntas si hacen falta. El ticket queda en el hilo: no hace falta buscar un correo viejo.

Problemas frecuentes

«Origen no autorizado» — INVALID_ORIGIN (403)

El dominio desde el que corre tu página no está en tus orígenes permitidos. Agregalo exacto, con protocolo, en Integración → Orígenes. Los tres tropiezos habituales: http en vez de https, el www. que sobra o falta, y un subdominio distinto al declarado.

Si el error aparece en un POST /holds desde tu propio servidor, la causa es otra: esa ruta exige una cabecera Origin, y un script no la manda. Los bloqueos se crean desde el navegador.

«Clave inválida» — INVALID_KEY (401)

La pk_ está mal copiada, revocada, es de otra cuenta, o la cuenta no tiene plan vigente. Copiala de nuevo desde Integración. Y verificá el modo: una pk_test_ sobre una función real responde como si la función no existiera. En el navegador va la pk_, nunca la sk_.

«No existe» — NOT_FOUND (404)

Casi siempre es un cruce de modo o de cuenta: el recurso existe pero no para esa clave. POV responde «no existe» a propósito, para no delatar qué hay del otro lado.

El bloqueo venció — HOLD_EXPIRED (410)

Confirmá igual, siempre: si el bloqueo venció hace poco y las butacas siguen libres, la venta se rescata sola. Si aun así recibís 410, el rescate ya se intentó y no fue posible: es la señal de reembolsar, y details.unavailableSeats te dice qué butacas se perdieron. Nunca vuelvas a cobrar sobre ese bloqueo.

Con la Pasarela POV esto casi no ocurre: al arrancar el cobro el bloqueo se extiende.

«Demasiadas peticiones» — RATE_LIMITED (429)

Esperá lo que dice Retry-After. Si te pasa seguido con GET /venues/{id}/layout, estás pidiendo el plano más de una vez: pedilo una y cacheálo. Si te pasa con disponibilidad, sacá el setInterval y consultá al abrir la función y antes de reservar, con If-None-Match.

El widget no carga nada

Por orden de probabilidad: la cuenta está suspendida o sin plan vigente · falta la clave pública del modo de esa función · el data-showtime no es de tu cuenta · la referencia (data-ref) todavía no está programada y la sala no tiene la programación desde tu web activada.

La sala 3D no aparece

La sala puede estar marcada solo-2D en el panel, o el navegador no tener WebGL. En los dos casos se vende igual con el mapa 2D: es a propósito.

# Números que conviene recordar
Duración de un bloqueo                       10 minutos
Rescate de un pago tardío                    hasta 10 minutos después de vencido
Butacas por bloqueo                          10
Bloqueos vivos por llamante                  5
Reintentos de webhook                        3 (0,5 s y 2 s), 5 s de timeout
Venta después de vencida la suscripción      30 días
Límite más bajo de la API                    GET /venues/{id}/layout, 30/min