Contenido

Documentación

Ejemplos

Proyectos completos para clonar y correr: HTML, Node, PHP, Python, Next.js, Mercado Pago y Stripe.

Qué son

Proyectos completos, para clonar y correr. No son fragmentos de esta documentación: son lo que hay que escribir, entero, con los casos molestos ya resueltos.

Vienen con marcadores en lugar de claves: antes de correr cualquiera hay que poner las tuyas. La pk_test_ y la sk_test_ salen de Integración → Claves, y el id de la función de Programación → Código. Empezá por las de prueba: venden contra funciones de prueba, sin tocar inventario real ni cobrar un peso.

La clave secreta no sale de tu servidor. Ninguno de estos ejemplos la manda al navegador, y vale la pena mirar cómo lo evita cada uno.

Si nunca viste POV, empezá por HTML: un archivo, sin build. Si vas a integrar en serio, Node (o el de tu lenguaje) y después el de tu pasarela.

Cada uno se puede bajar como .zip y correr tal cual, o leer acá abajo archivo por archivo: es el mismo código, servido desde los mismos archivos.

Los cuatro errores que estos ejemplos evitan

El importe no viaja desde el navegador. Todos releen el bloqueo en el servidor y cobran hold.amountCents. Si el importe llegara desde el cliente, cualquiera podría pagar un peso por una platea.

La clave de idempotencia nace con la venta, no con el reintento. Generarla dentro de la función que reintenta anula la protección entera: para POV cada intento sería una venta distinta.

Un 410 con unavailableSeats no se reintenta: se reembolsa. El rescate automático ya se intentó y las butacas se las llevó otro. Reintentar es girar en falso mientras el comprador espera.

La firma del webhook se calcula sobre el cuerpo CRUDO, y hay que recorrer todos los v1=. Quedarse con el primero funciona hasta el día que rotás el secreto — y ahí se rompe justo cuando no querés.

El código de abajo es el de los archivos reales. Cada verificación de firma que ves acá se prueba contra una firma emitida por POV, en los cuatro lenguajes, antes de publicarse.

HTML

El widget, en dos líneas. Sin build, sin dependencias, sin servidor.

Descargar el proyecto (.zip)Ver en GitHub

abrí index.html en el navegador
html/index.htmlHTML
<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>POV — el widget en dos líneas</title>
    <style>
      body { font: 16px/1.6 system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; }
      pre  { background: #f4f4f5; padding: 1rem; border-radius: .5rem; overflow-x: auto; }
      .vacio { color: #71717a; }
    </style>
  </head>
  <body>
    <h1>Función de las 20:30</h1>

    <!-- ─────────────────────────────────────────────────────────────────────
         ESTO ES TODO lo que hace falta para que aparezca el selector.
         `data-showtime` lo copiás del panel (Programación → Código); acá va el
         de la función demo de POV, que es pública y de prueba.
    ────────────────────────────────────────────────────────────────────────── -->
    <div data-pov
         data-showtime="shw_pegá_la_tuya"
         data-key="pk_test_pegá_la_tuya"></div>
    <script src="https://pov.uy/v1/embed.js" async></script>

    <h2>Lo que recibe tu página</h2>
    <pre id="salida" class="vacio">Elegí una butaca y apretá «Reservar».</pre>

    <script>
      // El widget avisa por eventos del DOM sobre el propio <div>. El payload va PLANO en
      // `e.detail`: nunca anidado.
      const nodo = document.querySelector('[data-pov]');
      const salida = document.getElementById('salida');

      nodo.addEventListener('pov:hold', (e) => {
        salida.classList.remove('vacio');
        salida.textContent = JSON.stringify(e.detail, null, 2);

        // ACÁ arrancaría tu checkout. `e.detail.amountCents` es lo que hay que cobrar, en
        // CENTAVOS enteros, ya con descuentos, items e impuestos aplicados por el servidor.
        // No lo recalcules del lado del cliente.
        //
        // Y si el comprador cancela, soltá el bloqueo en vez de esperar los 10 minutos:
        //   nodo.pov.release(e.detail.holdToken);
      });

      nodo.addEventListener('pov:error', (e) => {
        salida.classList.remove('vacio');
        salida.textContent = `${e.detail.code}: ${e.detail.message}`;
      });
    </script>
  </body>
</html>

Node (Express)

La venta completa —bloqueo, cobro, confirmación— y el receptor de webhooks.

Descargar el proyecto (.zip)Ver en GitHub

cp .env.example .env && npm install && npm start
node/server.jsJavaScript
/**
 * POV + Node (Express) — el flujo de venta completo, sin pasarela real.
 *
 * Los tres pasos que van después del widget:
 *
 *   1. el navegador recibe `pov:hold` y se lo manda a TU servidor
 *   2. tu servidor cobra          ← acá está simulado; en `../mercadopago` y `../stripe` es de verdad
 *   3. tu servidor llama a `confirm` con la clave secreta
 *
 * Más el receptor de webhooks, con la verificación de firma que es lo único que de verdad puede
 * salirte mal.
 *
 * Ejecutar:  cp .env.example .env  &&  npm install  &&  npm start
 */
import 'dotenv/config';
import express from 'express';
import { randomUUID } from 'node:crypto';
import { verificarFirma } from './firma.js';

const app = express();
const PORT = process.env.PORT ?? 4000;

const POV = process.env.POV_BASE_URL ?? 'https://pov.uy';
const PK = process.env.POV_PUBLIC_KEY ?? '';
const SK = process.env.POV_SECRET_KEY ?? '';
const SHOWTIME = process.env.POV_SHOWTIME ?? '';
const WHSEC = process.env.POV_WEBHOOK_SECRET ?? '';

/**
 * Tu "base de datos" de ventas. Lo único importante de esta estructura es lo que guarda:
 *
 *  · `holdToken`      — lo que ata tu pago con el inventario de POV
 *  · `idempotencyKey` — se genera UNA vez, al empezar la venta, y se reutiliza en TODOS los
 *                       reintentos. Generarla dentro de la función que reintenta anula la
 *                       protección: cada intento sería una venta distinta para POV.
 */
const ventas = new Map();

/* ────────────────────────────── 1 · la página con el widget ───────────────────────────── */

app.get('/', (_req, res) => {
  res.type('html').send(`<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>Ejemplo POV + Node</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}
pre{background:#f4f4f5;padding:1rem;border-radius:.5rem;overflow-x:auto}</style></head><body>
<h1>Comprá tu entrada</h1>
<div data-pov data-showtime="${SHOWTIME}" data-key="${PK}"></div>
<script src="${POV}/v1/embed.js" async></script>
<h2>Estado</h2><pre id="log">Elegí butacas y apretá «Reservar».</pre>
<script>
  const nodo = document.querySelector('[data-pov]');
  const log = document.getElementById('log');

  nodo.addEventListener('pov:hold', async (e) => {
    log.textContent = 'Bloqueo creado. Cobrando…';
    // El navegador NUNCA manda el importe: sólo el token. El servidor lo relee de POV.
    // Si el importe viajara desde acá, cualquiera podría pagar 1 peso por una platea.
    const r = await fetch('/comprar', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ holdToken: e.detail.holdToken, buyer: { name: 'Ana Pérez', email: '[email protected]' } }),
    });
    const data = await r.json();
    log.textContent = JSON.stringify(data, null, 2);
  });

  nodo.addEventListener('pov:error', (e) => { log.textContent = e.detail.code + ': ' + e.detail.message; });
</script></body></html>`);
});

/* ─────────────────────── 2 y 3 · cobrar y confirmar, del lado del servidor ─────────────────────── */

app.post('/comprar', express.json(), async (req, res) => {
  const { holdToken, buyer } = req.body ?? {};
  if (!holdToken) return res.status(400).json({ error: 'falta holdToken' });

  try {
    // El importe se relee del bloqueo, en el servidor. Es la regla que evita que el navegador
    // fije lo que se cobra.
    const hold = await povGet(`/api/v1/holds/${holdToken}`);
    if (hold.status !== 'ACTIVE') {
      return res.status(409).json({ error: `el bloqueo está ${hold.status}` });
    }

    // La clave de idempotencia se crea ACÁ, al empezar la venta, y se guarda con ella.
    const venta = { holdToken, idempotencyKey: randomUUID(), estado: 'cobrando' };
    ventas.set(holdToken, venta);

    // ── 2 · tu cobro ──────────────────────────────────────────────────────────────────────
    // Acá va tu pasarela. Con `hold.amountCents` y `hold.currency`.
    // Ejemplos reales: ../mercadopago y ../stripe
    venta.pagoRef = `simulado_${Date.now()}`;
    venta.estado = 'cobrado';

    // ── 3 · confirmar ─────────────────────────────────────────────────────────────────────
    const reserva = await confirmar(venta, buyer);
    venta.estado = 'confirmada';
    venta.reservationId = reserva.reservationId;

    res.json({
      ok: true,
      reservationId: reserva.reservationId,
      entradas: reserva.tickets.map((t) => ({ butaca: t.seat.id, qr: t.qr })),
      // El enlace que le mandás al comprador: le muestra sus entradas y sus QR.
      verEntradas: `${POV}/r/${reserva.publicToken}`,
      cobrado: `${hold.amountCents / 100} ${hold.currency}`,
    });
  } catch (err) {
    res.status(500).json({ error: String(err.message ?? err) });
  }
});

/**
 * El confirm, con reintentos.
 *
 * Reintentar es seguro **porque la clave de idempotencia es la de la venta**, no una nueva por
 * intento. Si POV ya procesó el primero y se cortó la red antes de la respuesta, el segundo
 * devuelve la MISMA reserva en vez de crear otra.
 */
async function confirmar(venta, buyer, intento = 1) {
  const r = await fetch(`${POV}/api/v1/holds/${venta.holdToken}/confirm`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${SK}`,
      'Idempotency-Key': venta.idempotencyKey,
      'Content-Type': 'application/json',
    },
    // Cuerpo OPCIONAL: quién compró y con qué pago. Si no querés dárnoslo, no lo mandes.
    body: JSON.stringify({ buyer, externalPaymentRef: venta.pagoRef }),
  });

  if (r.ok) return r.json();

  const cuerpo = await r.json().catch(() => ({}));
  const code = cuerpo?.error?.code;

  // 410 con `unavailableSeats`: el bloqueo venció Y las butacas ya se las llevó otro. El rescate
  // automático ya se intentó. Es la señal de REEMBOLSAR, no de reintentar.
  if (r.status === 410) {
    const perdidas = cuerpo?.error?.details?.unavailableSeats ?? [];
    throw new Error(`hay que reembolsar: se perdieron las butacas ${perdidas.join(', ') || '(desconocidas)'}`);
  }
  // 429: esperá lo que dice el servidor. Reintentar en el acto sólo consume la próxima ventana.
  if (r.status === 429 && intento < 3) {
    const espera = Number(r.headers.get('retry-after') ?? 2);
    await new Promise((ok) => setTimeout(ok, espera * 1000));
    return confirmar(venta, buyer, intento + 1);
  }
  throw new Error(`${code ?? r.status}: ${cuerpo?.error?.message ?? 'confirm falló'}`);
}

async function povGet(ruta) {
  const r = await fetch(`${POV}${ruta}`, { headers: { 'X-POV-Key': PK } });
  if (!r.ok) throw new Error(`GET ${ruta} → ${r.status}`);
  return r.json();
}

/* ───────────────────────────────── el receptor de webhooks ───────────────────────────────── */

/**
 * `express.raw` y no `express.json`: la firma se calcula sobre **los bytes exactos** que llegaron.
 * Si dejás que Express parsee el JSON y después lo volvés a serializar, un espacio de más y la
 * firma no coincide — y el síntoma es "mis webhooks no validan", que no dice nada.
 */
app.post('/pov-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const firma = req.get('X-POV-Signature') ?? '';
  if (!verificarFirma(req.body, firma, WHSEC)) return res.status(400).send('firma inválida');

  const evento = JSON.parse(req.body.toString('utf8'));

  // Contestá 200 YA y hacé el trabajo después: si tardás más de 5 segundos, para POV la entrega
  // falló y va a reintentar aunque vos la hayas procesado bien.
  res.status(200).send('ok');

  // Deduplicá por `evento.id`: un reintento trae el MISMO id.
  if (yaProcesado(evento.id)) return;
  console.log(`[webhook] ${evento.type}`, evento.data);
});

const vistos = new Set();
function yaProcesado(id) {
  if (vistos.has(id)) return true;
  vistos.add(id);
  return false;
}

/* ─────────────────────────────────────────────────────────────────────────────────────────── */

app.listen(PORT, () => {
  const faltan = [
    !PK && 'POV_PUBLIC_KEY',
    !SK && 'POV_SECRET_KEY',
    !SHOWTIME && 'POV_SHOWTIME',
  ].filter(Boolean);
  if (faltan.length) console.warn(`⚠ faltan en .env: ${faltan.join(', ')}`);
  console.log(`▸ http://localhost:${PORT}`);
});

export { app };
node/firma.jsJavaScript
import crypto from 'node:crypto';

/**
 * Verificación de la firma de un webhook de POV.
 *
 * Está en su propio archivo por dos motivos: lo vas a reusar en cada endpoint que reciba eventos, y
 * así **se puede probar** — que es lo que hace `examples/examples.test.ts`, firmando un payload con
 * el mismo código que usa POV y verificándolo con esta función.
 *
 * La cabecera viene como `t=1787012345,v1=3f8a…` y **puede traer más de un `v1=`**: durante una
 * rotación de secreto, POV firma cada evento con el viejo y el nuevo para que puedas actualizar tu
 * copia cuando te quede cómodo, sin perder un solo evento.
 *
 * @param {Buffer|string} rawBody el cuerpo CRUDO, sin parsear. Reserializar el JSON rompe la firma.
 * @param {string} header valor de `X-POV-Signature`.
 * @param {string} secreto tu `whsec_…`.
 * @param {number} toleranciaSeg cuánto se acepta de desfasaje de reloj, para acotar los reenvíos.
 */
export function verificarFirma(rawBody, header, secreto, toleranciaSeg = 300) {
  if (!secreto) return false;
  const campos = String(header ?? '')
    .split(',')
    .map((kv) => kv.split('='));
  const t = Number(campos.find(([k]) => k === 't')?.[1]);
  // OJO: un objeto (`Object.fromEntries`) se quedaría con UN solo `v1` y rechazarías eventos
  // válidos justo el día que rotás el secreto.
  const firmas = campos.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || firmas.length === 0) return false;

  // Ventana temporal: descarta reenvíos viejos.
  if (Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;

  const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(esperada, 'hex');
  // 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);
  });
}
node/.env.examplevariables de entorno
# La función y la clave PÚBLICA (van al navegador). Del panel:
# Integración → Claves, y Programación → Código.
POV_BASE_URL=https://pov.uy
POV_SHOWTIME=shw_pegá_la_tuya
POV_PUBLIC_KEY=pk_test_pegá_la_tuya

# La clave SECRETA es tuya y no sale del servidor. Sacala de tu panel:
# Integración → Claves → crear una `sk_test_`.
# Sin esto el widget se ve igual, pero la venta no se cierra.
POV_SECRET_KEY=sk_test_pegá_la_tuya

# Sólo para el receptor de webhooks (Integración → Webhooks).
POV_WEBHOOK_SECRET=whsec_pegá_el_tuyo

PORT=4000

PHP

Lo mismo, en PHP plano: sin framework ni dependencias.

Descargar el proyecto (.zip)Ver en GitHub

php -S localhost:4000
php/index.phpPHP
<?php
/**
 * POV + PHP — la página con el widget.
 *
 * Configurá las claves en `config.php`.
 */
require __DIR__ . '/config.php';
?>
<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Ejemplo POV + PHP</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}
pre{background:#f4f4f5;padding:1rem;border-radius:.5rem;overflow-x:auto}</style></head>
<body>
  <h1>Comprá tu entrada</h1>

  <div data-pov
       data-showtime="<?= htmlspecialchars(POV_SHOWTIME) ?>"
       data-key="<?= htmlspecialchars(POV_PUBLIC_KEY) ?>"></div>
  <script src="<?= htmlspecialchars(POV_BASE_URL) ?>/v1/embed.js" async></script>

  <h2>Estado</h2>
  <pre id="log">Elegí butacas y apretá «Reservar».</pre>

  <script>
    const nodo = document.querySelector('[data-pov]');
    const log = document.getElementById('log');

    nodo.addEventListener('pov:hold', async (e) => {
      log.textContent = 'Bloqueo creado. Cobrando…';
      // Sólo el token: el importe lo relee el servidor. Si viajara desde acá, cualquiera podría
      // pagar un peso por una platea.
      const r = await fetch('comprar.php', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ holdToken: e.detail.holdToken }),
      });
      log.textContent = JSON.stringify(await r.json(), null, 2);
    });

    nodo.addEventListener('pov:error', (e) => { log.textContent = e.detail.code + ': ' + e.detail.message; });
  </script>
</body>
</html>
php/comprar.phpPHP
<?php
/**
 * POV + PHP — cobrar y confirmar, del lado del servidor.
 *
 * Recibe `{ holdToken }` del navegador. **El importe no viaja desde el navegador**: se relee del
 * bloqueo acá. Es la regla que evita que el cliente fije lo que se cobra.
 */
require __DIR__ . '/config.php';
header('Content-Type: application/json');

$entrada = json_decode(file_get_contents('php://input'), true);
$holdToken = $entrada['holdToken'] ?? '';
if ($holdToken === '') {
    http_response_code(400);
    exit(json_encode(['error' => 'falta holdToken']));
}

/** GET a la API con la clave pública. */
function pov_get(string $ruta): array {
    $ch = curl_init(POV_BASE_URL . $ruta);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['X-POV-Key: ' . POV_PUBLIC_KEY],
    ]);
    $cuerpo = curl_exec($ch);
    curl_close($ch);
    return json_decode($cuerpo, true) ?: [];
}

// 1 · releer el bloqueo
$hold = pov_get('/api/v1/holds/' . rawurlencode($holdToken));
if (($hold['status'] ?? '') !== 'ACTIVE') {
    http_response_code(409);
    exit(json_encode(['error' => 'el bloqueo está ' . ($hold['status'] ?? 'ausente')]));
}

// 2 · tu cobro, con $hold['amountCents'] y $hold['currency'].
//     Acá está simulado. Con Mercado Pago o Stripe: ../mercadopago, ../stripe
$pagoRef = 'simulado_' . time();

/**
 * La clave de idempotencia se genera UNA vez por venta y se GUARDA con ella. Acá se deriva del
 * token del bloqueo para que el ejemplo sea corto; en tu sistema, guardala en la fila de la venta y
 * reutilizala en todos los reintentos. Generar una nueva por intento anula la protección.
 */
$idempotencyKey = 'venta_' . $holdToken;

// 3 · confirmar
$ch = curl_init(POV_BASE_URL . '/api/v1/holds/' . rawurlencode($holdToken) . '/confirm');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . POV_SECRET_KEY,
        'Idempotency-Key: ' . $idempotencyKey,
        'Content-Type: application/json',
    ],
    // Cuerpo OPCIONAL: quién compró y con qué pago.
    CURLOPT_POSTFIELDS => json_encode([
        'buyer' => ['name' => 'Ana Pérez', 'email' => '[email protected]'],
        'externalPaymentRef' => $pagoRef,
    ]),
]);
$cuerpo = curl_exec($ch);
$estado = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$res = json_decode($cuerpo, true) ?: [];

if ($estado === 410) {
    // El bloqueo venció y las butacas ya no están: el rescate automático se intentó y falló.
    // Es la señal de REEMBOLSAR, no de reintentar.
    $perdidas = $res['error']['details']['unavailableSeats'] ?? [];
    http_response_code(409);
    exit(json_encode(['error' => 'hay que reembolsar', 'butacasPerdidas' => $perdidas]));
}
if ($estado !== 200) {
    http_response_code(502);
    exit(json_encode(['error' => $res['error']['code'] ?? 'confirm falló']));
}

echo json_encode([
    'ok' => true,
    'reservationId' => $res['reservationId'],
    'entradas' => array_map(fn($t) => ['butaca' => $t['seat']['id'], 'qr' => $t['qr']], $res['tickets']),
    'verEntradas' => POV_BASE_URL . '/r/' . $res['publicToken'],
    'cobrado' => ($hold['amountCents'] / 100) . ' ' . $hold['currency'],
]);
php/webhook.phpPHP
<?php
/**
 * POV + PHP — receptor de webhooks.
 *
 * Lo único que de verdad puede salir mal es la firma, y siempre por lo mismo: calcularla sobre el
 * JSON reserializado en vez de sobre los bytes que llegaron, o quedarse con el primer `v1=`.
 */
require __DIR__ . '/config.php';
require __DIR__ . '/firma.php';   // la verificación, en su propio archivo para poder probarla

// El cuerpo CRUDO. No lo parsees antes de firmar.
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_POV_SIGNATURE'] ?? '';

if (!pov_verificar($raw, $header, POV_WEBHOOK_SECRET)) {
    http_response_code(400);
    exit('firma inválida');
}

$evento = json_decode($raw, true);

// Contestá 200 YA. Si tardás más de 5 segundos, para POV la entrega falló y va a reintentar aunque
// vos la hayas procesado bien.
http_response_code(200);
echo 'ok';
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();

// Deduplicá por `id`: un reintento trae el MISMO.
$yaVistos = __DIR__ . '/eventos-vistos.txt';
$id = $evento['id'] ?? '';
if ($id !== '' && str_contains(@file_get_contents($yaVistos) ?: '', $id)) exit;
@file_put_contents($yaVistos, $id . "\n", FILE_APPEND);

error_log('[pov] ' . ($evento['type'] ?? '?') . ' ' . json_encode($evento['data'] ?? []));
php/firma.phpPHP
<?php
/**
 * Verificación de la firma de un webhook de POV.
 *
 * Está en su propio archivo para poder reusarlo y —sobre todo— **para poder probarlo**:
 * `examples/examples.test.ts` firma un payload con el mismo código que usa POV y lo verifica con
 * esta función.
 *
 * La cabecera viene como `t=1787012345,v1=3f8a…` y **puede traer más de un `v1=`**: durante una
 * rotación de secreto POV firma con el viejo y el nuevo, para que actualices tu copia cuando te
 * quede cómodo sin perder eventos.
 */
function pov_verificar(string $raw, string $header, string $secreto, int $tolerancia = 300): bool {
    if ($secreto === '') return false;

    $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 || count($firmas) === 0) return false;

    // Ventana temporal: descarta reenvíos viejos.
    if (abs(time() - $t) > $tolerancia) return false;

    $esperada = hash_hmac('sha256', $t . '.' . $raw, $secreto);
    foreach ($firmas as $f) {
        // Comparación en tiempo constante: nunca `===`.
        if (hash_equals($esperada, $f)) return true;
    }
    return false;
}
php/config.phpPHP
<?php
/**
 * Claves de la integración.
 *
 * La `pk_` y la función son las de la demo pública de POV: sirven para ver el widget andando.
 * La `sk_` es TUYA y no sale del servidor — sacala del panel, en Integración → Claves.
 */
define('POV_BASE_URL', getenv('POV_BASE_URL') ?: 'https://pov.uy');
define('POV_SHOWTIME', getenv('POV_SHOWTIME') ?: 'shw_pegá_la_tuya');
define('POV_PUBLIC_KEY', getenv('POV_PUBLIC_KEY') ?: 'pk_test_pegá_la_tuya');

define('POV_SECRET_KEY', getenv('POV_SECRET_KEY') ?: 'sk_test_pega_la_tuya');
define('POV_WEBHOOK_SECRET', getenv('POV_WEBHOOK_SECRET') ?: 'whsec_pega_el_tuyo');

Python (Flask)

Lo mismo, en Flask.

Descargar el proyecto (.zip)Ver en GitHub

pip install -r requirements.txt && flask --app app run --port 4000
python/app.pyPython
"""
POV + Python (Flask) — bloqueo → cobro → confirmación, y el receptor de webhooks.

Ejecutar:
    pip install -r requirements.txt
    export POV_SECRET_KEY=sk_test_pega_la_tuya
    flask --app app run --port 4000
"""

import os
import time
import uuid

import requests
from flask import Flask, jsonify, request

from firma import verificar_firma  # la verificación, en su propio módulo para poder probarla

app = Flask(__name__)

POV = os.environ.get("POV_BASE_URL", "https://pov.uy")
SHOWTIME = os.environ.get("POV_SHOWTIME", "shw_pegá_la_tuya")
PK = os.environ.get("POV_PUBLIC_KEY", "pk_test_pegá_la_tuya")
SK = os.environ.get("POV_SECRET_KEY", "")
WHSEC = os.environ.get("POV_WEBHOOK_SECRET", "")

# Tu "base de datos" de ventas. Lo importante es que la clave de idempotencia se guarda CON la
# venta: se genera una vez y se reutiliza en todos los reintentos.
VENTAS: dict[str, dict] = {}
EVENTOS_VISTOS: set[str] = set()


@app.get("/")
def pagina():
    return f"""<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>Ejemplo POV + Flask</title>
<style>body{{font:16px/1.6 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}}
pre{{background:#f4f4f5;padding:1rem;border-radius:.5rem;overflow-x:auto}}</style></head><body>
<h1>Comprá tu entrada</h1>
<div data-pov data-showtime="{SHOWTIME}" data-key="{PK}"></div>
<script src="{POV}/v1/embed.js" async></script>
<h2>Estado</h2><pre id="log">Elegí butacas y apretá «Reservar».</pre>
<script>
  const nodo = document.querySelector('[data-pov]');
  const log = document.getElementById('log');
  nodo.addEventListener('pov:hold', async (e) => {{
    log.textContent = 'Bloqueo creado. Cobrando…';
    // Sólo el token: el importe lo relee el servidor.
    const r = await fetch('/comprar', {{
      method: 'POST', headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{ holdToken: e.detail.holdToken }}),
    }});
    log.textContent = JSON.stringify(await r.json(), null, 2);
  }});
  nodo.addEventListener('pov:error', (e) => {{ log.textContent = e.detail.code + ': ' + e.detail.message; }});
</script></body></html>"""


@app.post("/comprar")
def comprar():
    hold_token = (request.get_json(silent=True) or {}).get("holdToken", "")
    if not hold_token:
        return jsonify(error="falta holdToken"), 400

    # 1 · releer el bloqueo EN EL SERVIDOR. El importe no viaja desde el navegador.
    r = requests.get(f"{POV}/api/v1/holds/{hold_token}", headers={"X-POV-Key": PK}, timeout=10)
    if r.status_code != 200:
        return jsonify(error=f"no se pudo leer el bloqueo ({r.status_code})"), 502
    hold = r.json()
    if hold.get("status") != "ACTIVE":
        return jsonify(error=f"el bloqueo está {hold.get('status')}"), 409

    # La clave de idempotencia nace ACÁ, con la venta, y se guarda. Generar una nueva por intento
    # anularía la protección: para POV cada intento sería otra venta.
    venta = VENTAS.setdefault(
        hold_token, {"idempotency_key": str(uuid.uuid4()), "pago_ref": f"simulado_{int(time.time())}"}
    )

    # 2 · tu cobro, con hold["amountCents"] y hold["currency"].
    #     Acá está simulado. Reales: ../mercadopago y ../stripe

    # 3 · confirmar
    r = requests.post(
        f"{POV}/api/v1/holds/{hold_token}/confirm",
        headers={
            "Authorization": f"Bearer {SK}",
            "Idempotency-Key": venta["idempotency_key"],
            "Content-Type": "application/json",
        },
        # Cuerpo OPCIONAL: quién compró y con qué pago.
        json={
            "buyer": {"name": "Ana Pérez", "email": "[email protected]"},
            "externalPaymentRef": venta["pago_ref"],
        },
        timeout=15,
    )

    if r.status_code == 410:
        # El rescate automático ya se intentó y las butacas se las llevó otro.
        # Es la señal de REEMBOLSAR, no de reintentar.
        perdidas = r.json().get("error", {}).get("details", {}).get("unavailableSeats", [])
        return jsonify(error="hay que reembolsar", butacasPerdidas=perdidas), 409
    if r.status_code != 200:
        return jsonify(error=r.json().get("error", {}).get("code", r.status_code)), 502

    reserva = r.json()
    return jsonify(
        ok=True,
        reservationId=reserva["reservationId"],
        entradas=[{"butaca": t["seat"]["id"], "qr": t["qr"]} for t in reserva["tickets"]],
        verEntradas=f"{POV}/r/{reserva['publicToken']}",
        cobrado=f"{hold['amountCents'] / 100:.2f} {hold['currency']}",
    )


@app.post("/pov-webhook")
def webhook():
    # `request.get_data()` devuelve el cuerpo CRUDO. La firma se calcula sobre esos bytes: si
    # parseás el JSON y lo volvés a serializar, no coincide.
    raw = request.get_data()
    if not verificar_firma(raw, request.headers.get("X-POV-Signature", ""), WHSEC):
        return "firma inválida", 400

    evento = request.get_json(force=True)

    # Deduplicá por `id`: un reintento trae el MISMO.
    if evento["id"] in EVENTOS_VISTOS:
        return "ok", 200
    EVENTOS_VISTOS.add(evento["id"])

    # Contestá rápido y hacé el trabajo después: más de 5 segundos y para POV la entrega falló.
    app.logger.info("[pov] %s %s", evento["type"], evento.get("data"))
    return "ok", 200
python/firma.pyPython
"""Verificación de la firma de un webhook de POV.

Está en su propio módulo para poder reusarlo y —sobre todo— **para poder probarlo**:
`examples/examples.test.ts` firma un payload con el mismo código que usa POV y lo verifica con esta
función.

La cabecera viene como ``t=1787012345,v1=3f8a…`` y **puede traer más de un ``v1=``**: durante una
rotación de secreto POV firma con el viejo y el nuevo, para que actualices tu copia cuando te quede
cómodo sin perder eventos.
"""

import hashlib
import hmac
import time


def verificar_firma(raw: bytes, header: str, secreto: str, tolerancia: int = 300) -> bool:
    """`raw` es el cuerpo CRUDO. Reserializar el JSON rompe la firma."""
    if not secreto:
        return False

    campos = [p.split("=", 1) for p in (header or "").split(",")]
    t = next((v for k, v in campos if k == "t"), None)
    # Un dict se quedaría con UN solo v1 y rechazarías eventos válidos justo el día que rotás.
    firmas = [v for k, v in campos if k == "v1"]
    if not t or not firmas:
        return False

    # Ventana temporal: descarta reenvíos viejos.
    if abs(time.time() - int(t)) > tolerancia:
        return False

    esperada = hmac.new(secreto.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    # Comparación en tiempo constante: nunca `==`.
    return any(hmac.compare_digest(esperada, f) for f in firmas)
python/requirements.txttexto
flask>=3.0
requests>=2.32

Next.js (App Router)

El widget en el cliente y el confirm en un Route Handler, donde va la clave secreta.

Descargar el proyecto (.zip)Ver en GitHub

cp .env.example .env.local && npm install && npm run dev
nextjs/app/page.tsxTypeScript
'use client';

import { useState } from 'react';
import Script from 'next/script';

/**
 * POV + Next.js (App Router) — la página con el widget.
 *
 * Es un componente de cliente porque escucha los eventos del widget. El `confirm` NO vive acá:
 * vive en `app/api/confirmar/route.ts`, del lado del servidor, que es donde tiene que estar la
 * clave secreta.
 */
export default function Page() {
  const [log, setLog] = useState('Elegí butacas y apretá «Reservar».');

  return (
    <main style={{ font: '16px/1.6 system-ui, sans-serif', maxWidth: '60rem', margin: '2rem auto', padding: '0 1rem' }}>
      <h1>Comprá tu entrada</h1>

      <div
        data-pov
        data-showtime={process.env.NEXT_PUBLIC_POV_SHOWTIME}
        data-key={process.env.NEXT_PUBLIC_POV_PUBLIC_KEY}
        ref={(nodo) => {
          if (!nodo || nodo.dataset.listo) return;
          nodo.dataset.listo = '1';

          nodo.addEventListener('pov:hold', async (e) => {
            const detalle = (e as CustomEvent).detail as { holdToken: string };
            setLog('Bloqueo creado. Cobrando…');
            // Al servidor va SÓLO el token. El importe lo relee él: si viajara desde acá,
            // cualquiera podría pagar un peso por una platea.
            const r = await fetch('/api/confirmar', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ holdToken: detalle.holdToken }),
            });
            setLog(JSON.stringify(await r.json(), null, 2));
          });

          nodo.addEventListener('pov:error', (e) => {
            const d = (e as CustomEvent).detail as { code: string; message: string };
            setLog(`${d.code}: ${d.message}`);
          });
        }}
      />

      {/* `afterInteractive`: el loader monta el iframe apenas la página es usable. */}
      <Script src={`${process.env.NEXT_PUBLIC_POV_BASE_URL}/v1/embed.js`} strategy="afterInteractive" />

      <h2>Estado</h2>
      <pre style={{ background: '#f4f4f5', padding: '1rem', borderRadius: '.5rem', overflowX: 'auto' }}>{log}</pre>
    </main>
  );
}
nextjs/app/api/confirmar/route.tsTypeScript
import { randomUUID } from 'node:crypto';

/**
 * POV + Next.js — cobrar y confirmar, en el servidor.
 *
 * Es un Route Handler y no una Server Action a propósito: lo llama `fetch` desde el listener del
 * widget, que es JavaScript del navegador y no un formulario.
 *
 * **`POV_SECRET_KEY` sin `NEXT_PUBLIC_`**: cualquier variable con ese prefijo termina en el bundle
 * del navegador, y una clave secreta en el bundle es una clave publicada.
 */

const POV = process.env.NEXT_PUBLIC_POV_BASE_URL ?? 'https://pov.uy';
const PK = process.env.NEXT_PUBLIC_POV_PUBLIC_KEY ?? '';
const SK = process.env.POV_SECRET_KEY ?? '';

/** Clave de idempotencia por venta. En un sistema real se guarda con la venta, no en memoria. */
const claves = new Map<string, string>();

export async function POST(req: Request) {
  const { holdToken } = (await req.json().catch(() => ({}))) as { holdToken?: string };
  if (!holdToken) return Response.json({ error: 'falta holdToken' }, { status: 400 });

  // 1 · releer el bloqueo. El importe se lee de POV, nunca del navegador.
  const rHold = await fetch(`${POV}/api/v1/holds/${holdToken}`, { headers: { 'X-POV-Key': PK } });
  if (!rHold.ok) return Response.json({ error: 'no se pudo leer el bloqueo' }, { status: 502 });
  const hold = (await rHold.json()) as { status: string; amountCents: number; currency: string };
  if (hold.status !== 'ACTIVE') {
    return Response.json({ error: `el bloqueo está ${hold.status}` }, { status: 409 });
  }

  // 2 · tu cobro, con hold.amountCents y hold.currency. Reales: ../../mercadopago, ../../stripe
  const pagoRef = `simulado_${Date.now()}`;

  // La clave nace con la venta y se REUTILIZA en los reintentos. Una nueva por intento anularía
  // la protección: para POV cada intento sería otra venta.
  if (!claves.has(holdToken)) claves.set(holdToken, randomUUID());

  // 3 · confirmar
  const r = await fetch(`${POV}/api/v1/holds/${holdToken}/confirm`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${SK}`,
      'Idempotency-Key': claves.get(holdToken)!,
      'Content-Type': 'application/json',
    },
    // Cuerpo OPCIONAL: quién compró y con qué pago.
    body: JSON.stringify({
      buyer: { name: 'Ana Pérez', email: '[email protected]' },
      externalPaymentRef: pagoRef,
    }),
  });

  const cuerpo = await r.json().catch(() => ({}));

  if (r.status === 410) {
    // El rescate automático ya se intentó: las butacas se las llevó otro. REEMBOLSAR, no reintentar.
    const perdidas = cuerpo?.error?.details?.unavailableSeats ?? [];
    return Response.json({ error: 'hay que reembolsar', butacasPerdidas: perdidas }, { status: 409 });
  }
  if (!r.ok) {
    return Response.json({ error: cuerpo?.error?.code ?? 'confirm falló' }, { status: 502 });
  }

  return Response.json({
    ok: true,
    reservationId: cuerpo.reservationId,
    entradas: cuerpo.tickets.map((t: { seat: { id: string }; qr: string }) => ({
      butaca: t.seat.id,
      qr: t.qr,
    })),
    verEntradas: `${POV}/r/${cuerpo.publicToken}`,
    cobrado: `${hold.amountCents / 100} ${hold.currency}`,
  });
}
nextjs/app/api/pov-webhook/route.tsTypeScript
import { verificarFirma } from '@/lib/firma';

/**
 * POV + Next.js — receptor de webhooks.
 *
 * **`await req.text()`, no `req.json()`**: la firma se calcula sobre los bytes exactos que llegaron.
 * Parsear y reserializar la rompe, y el síntoma —«mis webhooks no validan»— no dice nada.
 *
 * La verificación vive en `lib/firma.ts`, para poder reusarla y probarla.
 */

const WHSEC = process.env.POV_WEBHOOK_SECRET ?? '';
const vistos = new Set<string>();

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verificarFirma(raw, req.headers.get('x-pov-signature') ?? '', WHSEC)) {
    return new Response('firma inválida', { status: 400 });
  }

  const evento = JSON.parse(raw) as { id: string; type: string; data: unknown };

  // Deduplicá por `id`: un reintento trae el MISMO.
  if (vistos.has(evento.id)) return new Response('ok');
  vistos.add(evento.id);

  // Contestá rápido y hacé el trabajo después: más de 5 segundos y para POV la entrega falló.
  console.log('[pov]', evento.type, evento.data);
  return new Response('ok');
}
nextjs/lib/firma.tsTypeScript
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * Verificación de la firma de un webhook de POV.
 *
 * Está en `lib/` y no dentro del Route Handler para poder reusarla y **para poder probarla**:
 * `examples/examples.test.ts` firma un payload con el mismo código que usa POV y lo verifica con
 * esta función.
 *
 * La cabecera viene como `t=1787012345,v1=3f8a…` y **puede traer más de un `v1=`**: durante una
 * rotación de secreto POV firma con el viejo y el nuevo, para que actualices tu copia cuando te
 * quede cómodo sin perder eventos.
 *
 * @param raw el cuerpo CRUDO (`await req.text()`). `req.json()` rompe la firma.
 */
export function verificarFirma(
  raw: string,
  header: string,
  secreto: string,
  toleranciaSeg = 300,
): boolean {
  if (!secreto) return false;

  const campos = (header ?? '').split(',').map((kv) => kv.split('='));
  const t = Number(campos.find(([k]) => k === 't')?.[1]);
  // Un objeto se quedaría con UN solo v1 y rechazarías eventos válidos justo el día que rotás.
  const firmas = campos.filter(([k]) => k === 'v1').map(([, v]) => v ?? '');
  if (!t || firmas.length === 0) return false;

  // Ventana temporal: descarta reenvíos viejos.
  if (Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;

  const esperada = createHmac('sha256', secreto).update(`${t}.${raw}`).digest('hex');
  const a = Buffer.from(esperada, 'hex');
  // 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 && timingSafeEqual(a, b);
  });
}
nextjs/.env.examplevariables de entorno
# Públicas: terminan en el bundle del navegador, y está bien que así sea.
NEXT_PUBLIC_POV_BASE_URL=https://pov.uy
NEXT_PUBLIC_POV_SHOWTIME=shw_pegá_la_tuya
NEXT_PUBLIC_POV_PUBLIC_KEY=pk_test_pegá_la_tuya

# SIN `NEXT_PUBLIC_`. Cualquier variable con ese prefijo se inlinea en el bundle, y una clave
# secreta en el bundle es una clave publicada.
POV_SECRET_KEY=sk_test_pegá_la_tuya
POV_WEBHOOK_SECRET=whsec_pegá_el_tuyo

Cobrar con Mercado Pago

El flujo entero contra la API de Mercado Pago: Preference, notificación y confirm.

Descargar el proyecto (.zip)Ver en GitHub

cp .env.example .env && npm install && npm start
mercadopago/server.jsJavaScript
/**
 * POV + Mercado Pago — la venta completa, con cobro de verdad.
 *
 *   1. el widget crea el bloqueo y avisa
 *   2. tu backend crea una Preference por el importe del bloqueo,
 *      con `external_reference = holdToken`      ← lo que une los dos mundos
 *   3. el comprador paga en Mercado Pago
 *   4. MP te notifica; verificás que el pago está aprobado
 *   5. confirmás en POV con `Idempotency-Key` = id del pago de MP
 *
 * Ejecutar:  cp .env.example .env  &&  npm install  &&  npm start
 */
import 'dotenv/config';
import express from 'express';

const app = express();
const PORT = process.env.PORT ?? 4000;

const POV = process.env.POV_BASE_URL ?? 'https://pov.uy';
const PK = process.env.POV_PUBLIC_KEY ?? '';
const SK = process.env.POV_SECRET_KEY ?? '';
const SHOWTIME = process.env.POV_SHOWTIME ?? '';
const MP_TOKEN = process.env.MP_ACCESS_TOKEN ?? '';
const PUBLIC_URL = process.env.PUBLIC_URL ?? `http://localhost:${PORT}`;

app.get('/', (_req, res) => {
  res.type('html').send(`<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>POV + Mercado Pago</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}</style></head><body>
<h1>Comprá tu entrada</h1>
<div data-pov data-showtime="${SHOWTIME}" data-key="${PK}"></div>
<script src="${POV}/v1/embed.js" async></script>
<script>
  const nodo = document.querySelector('[data-pov]');
  nodo.addEventListener('pov:hold', async (e) => {
    // Sólo el token: el importe lo relee el servidor de POV.
    const r = await fetch('/pagar', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ holdToken: e.detail.holdToken }),
    });
    const { initPoint, error } = await r.json();
    if (error) return alert(error);
    window.location.href = initPoint;   // al checkout de Mercado Pago
  });
</script></body></html>`);
});

/* ─────────────────────────── 2 · crear la Preference ─────────────────────────── */

app.post('/pagar', express.json(), async (req, res) => {
  const { holdToken } = req.body ?? {};
  if (!holdToken) return res.status(400).json({ error: 'falta holdToken' });

  // El importe se relee del bloqueo. Nunca del navegador.
  const hold = await fetch(`${POV}/api/v1/holds/${holdToken}`, { headers: { 'X-POV-Key': PK } }).then((r) => r.json());
  if (hold.status !== 'ACTIVE') return res.status(409).json({ error: `el bloqueo está ${hold.status}` });

  const r = await fetch('https://api.mercadopago.com/checkout/preferences', {
    method: 'POST',
    headers: { Authorization: `Bearer ${MP_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      items: [
        {
          title: `Entradas (${hold.seats.map((s) => s.id).join(', ')})`,
          quantity: 1,
          // MP trabaja en unidades, POV en CENTAVOS enteros. Convertir es obligatorio, y es el
          // lugar donde se cuela el error de cobrar cien veces de más o de menos.
          unit_price: hold.amountCents / 100,
          currency_id: hold.currency,
        },
      ],
      // ── LO QUE UNE LOS DOS MUNDOS ────────────────────────────────────────────────────────
      // Sin esto, cuando MP te notifique el pago no vas a saber qué reserva cerrar.
      external_reference: holdToken,
      notification_url: `${PUBLIC_URL}/mp-webhook`,
      back_urls: { success: `${PUBLIC_URL}/listo`, failure: `${PUBLIC_URL}/` },
      auto_return: 'approved',
      // El bloqueo dura 10 minutos: no tiene sentido aceptar un pago después.
      expires: true,
      expiration_date_to: hold.expiresAt,
    }),
  });

  const pref = await r.json();
  if (!r.ok) return res.status(502).json({ error: pref?.message ?? 'MP rechazó la preferencia' });
  res.json({ initPoint: pref.init_point });
});

/* ──────────────────── 4 y 5 · la notificación de MP y el confirm ──────────────────── */

app.post('/mp-webhook', express.json(), async (req, res) => {
  // Contestá 200 rápido: MP reintenta si tardás.
  res.sendStatus(200);

  const id = req.body?.data?.id;
  if (req.body?.type !== 'payment' || !id) return;

  try {
    // NUNCA confíes en el cuerpo de la notificación: pedile el pago a MP y mirá su estado.
    const pago = await fetch(`https://api.mercadopago.com/v1/payments/${id}`, {
      headers: { Authorization: `Bearer ${MP_TOKEN}` },
    }).then((r) => r.json());

    if (pago.status !== 'approved') return console.log(`[mp] pago ${id} está ${pago.status}`);

    const holdToken = pago.external_reference;
    if (!holdToken) return console.error(`[mp] pago ${id} sin external_reference`);

    const r = await fetch(`${POV}/api/v1/holds/${holdToken}/confirm`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${SK}`,
        // La clave de idempotencia ES el id del pago de MP. MP puede notificarte el mismo pago
        // varias veces; atada al pago, confirmar dos veces devuelve la misma reserva.
        'Idempotency-Key': `mp_${pago.id}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        buyer: { name: pago.payer?.first_name, email: pago.payer?.email },
        externalPaymentRef: String(pago.id),
      }),
    });

    if (r.status === 410) {
      const cuerpo = await r.json();
      const perdidas = cuerpo?.error?.details?.unavailableSeats ?? [];
      // Cobraste y no hay butacas: el rescate automático ya se intentó. Hay que devolver la plata,
      // y ahora sabés QUÉ butaca se perdió para poder explicárselo al comprador.
      console.error(`[mp] REEMBOLSAR el pago ${pago.id}: se perdieron ${perdidas.join(', ')}`);
      // await fetch(`https://api.mercadopago.com/v1/payments/${pago.id}/refunds`, …)
      return;
    }
    if (!r.ok) return console.error(`[mp] confirm falló: ${r.status}`);

    const reserva = await r.json();
    console.log(`[mp] venta cerrada · ${reserva.reservationId} · ${POV}/r/${reserva.publicToken}`);
  } catch (err) {
    console.error('[mp] error procesando la notificación', err);
  }
});

app.get('/listo', (_req, res) => res.send('<h1>¡Listo!</h1><p>Te mandamos las entradas por correo.</p>'));

app.listen(PORT, () => console.log(`▸ http://localhost:${PORT}`));
mercadopago/.env.examplevariables de entorno
POV_BASE_URL=https://pov.uy
POV_SHOWTIME=shw_pegá_la_tuya
POV_PUBLIC_KEY=pk_test_pegá_la_tuya
POV_SECRET_KEY=sk_test_pegá_la_tuya

# Credencial de PRUEBA de Mercado Pago (TEST-…), de tu panel de MP.
MP_ACCESS_TOKEN=TEST-pegá_la_tuya

# URL pública de este servidor, para que MP pueda notificarte.
# Con un túnel: ngrok http 4000  ·  cloudflared tunnel --url http://localhost:4000
PUBLIC_URL=https://tu-tunel.example
PORT=4000

Cobrar con tu cuenta de Stripe

El flujo entero contra tu cuenta de Stripe: PaymentIntent, webhook y confirm.

Descargar el proyecto (.zip)Ver en GitHub

cp .env.example .env && npm install && npm start
stripe/server.jsJavaScript
/**
 * POV + Stripe (tu propia cuenta) — la venta completa.
 *
 *   1. el widget crea el bloqueo y avisa
 *   2. tu backend crea un PaymentIntent por el importe del bloqueo,
 *      con `metadata.holdToken`                  ← lo que une los dos mundos
 *   3. el comprador paga con el Payment Element
 *   4. tu webhook recibe `payment_intent.succeeded`
 *   5. confirmás en POV con `Idempotency-Key` = id del PaymentIntent
 *
 * Este 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 no requiere nada de esto.
 *
 * Ejecutar:  cp .env.example .env  &&  npm install  &&  npm start
 */
import 'dotenv/config';
import express from 'express';
import Stripe from 'stripe';

const app = express();
const PORT = process.env.PORT ?? 4000;

const POV = process.env.POV_BASE_URL ?? 'https://pov.uy';
const PK = process.env.POV_PUBLIC_KEY ?? '';
const SK = process.env.POV_SECRET_KEY ?? '';
const SHOWTIME = process.env.POV_SHOWTIME ?? '';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');

app.get('/', (_req, res) => {
  res.type('html').send(`<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>POV + Stripe</title>
<script src="https://js.stripe.com/v3/"></script>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}
#pago{margin-top:1.5rem}button{padding:.6rem 1.2rem;font:inherit}</style></head><body>
<h1>Comprá tu entrada</h1>
<div data-pov data-showtime="${SHOWTIME}" data-key="${PK}"></div>
<script src="${POV}/v1/embed.js" async></script>
<div id="pago"></div>
<script>
  const stripe = Stripe('${process.env.STRIPE_PUBLISHABLE_KEY ?? ''}');
  const nodo = document.querySelector('[data-pov]');

  nodo.addEventListener('pov:hold', async (e) => {
    // Sólo el token: el importe lo relee el servidor de POV.
    const { clientSecret, error } = await fetch('/pagar', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ holdToken: e.detail.holdToken }),
    }).then((r) => r.json());
    if (error) return alert(error);

    const elements = stripe.elements({ clientSecret });
    document.getElementById('pago').innerHTML =
      '<div id="element"></div><button id="btn">Pagar</button>';
    elements.create('payment').mount('#element');

    document.getElementById('btn').onclick = async () => {
      const { error } = await stripe.confirmPayment({
        elements,
        confirmParams: { return_url: window.location.origin + '/listo' },
      });
      // Sólo se llega acá si el pago NO redirigió (por ejemplo, tarjeta rechazada).
      if (error) alert(error.message);
    };
  });

  // Si el comprador se arrepiente, soltá el bloqueo en vez de esperar los 10 minutos.
  window.addEventListener('beforeunload', () => {});
</script></body></html>`);
});

/* ─────────────────────────── 2 · crear el PaymentIntent ─────────────────────────── */

app.post('/pagar', express.json(), async (req, res) => {
  const { holdToken } = req.body ?? {};
  if (!holdToken) return res.status(400).json({ error: 'falta holdToken' });

  // El importe se relee del bloqueo. Nunca del navegador.
  const hold = await fetch(`${POV}/api/v1/holds/${holdToken}`, { headers: { 'X-POV-Key': PK } }).then((r) => r.json());
  if (hold.status !== 'ACTIVE') return res.status(409).json({ error: `el bloqueo está ${hold.status}` });

  const pi = await stripe.paymentIntents.create(
    {
      // Stripe y POV usan la MISMA unidad: la menor de la moneda. No hay que dividir por cien.
      amount: hold.amountCents,
      currency: hold.currency.toLowerCase(),
      automatic_payment_methods: { enabled: true },
      // ── LO QUE UNE LOS DOS MUNDOS ──────────────────────────────────────────────────────
      // El webhook lo lee para saber qué reserva cerrar.
      metadata: { holdToken },
    },
    // Idempotencia del lado de Stripe: un doble clic no crea dos cobros.
    { idempotencyKey: `pov_${holdToken}` },
  );

  res.json({ clientSecret: pi.client_secret });
});

/* ──────────────────── 4 y 5 · el webhook de Stripe y el confirm ──────────────────── */

/**
 * `express.raw`: Stripe firma el cuerpo crudo, igual que POV. Si Express parsea el JSON antes, la
 * verificación falla y el síntoma no dice nada sobre la causa.
 */
app.post('/stripe-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let evento;
  try {
    evento = stripe.webhooks.constructEvent(
      req.body,
      req.get('stripe-signature') ?? '',
      process.env.STRIPE_WEBHOOK_SECRET ?? '',
    );
  } catch (err) {
    return res.status(400).send(`firma inválida: ${err.message}`);
  }
  res.sendStatus(200); // contestá rápido; el trabajo va después

  if (evento.type !== 'payment_intent.succeeded') return;
  const pi = evento.data.object;
  const holdToken = pi.metadata?.holdToken;
  if (!holdToken) return console.error(`[stripe] ${pi.id} sin metadata.holdToken`);

  const r = await fetch(`${POV}/api/v1/holds/${holdToken}/confirm`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${SK}`,
      // La clave es el id del PaymentIntent: Stripe puede reentregar el evento, y atada al pago
      // confirmar dos veces devuelve la misma reserva.
      'Idempotency-Key': pi.id,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      buyer: { email: pi.receipt_email ?? undefined },
      externalPaymentRef: pi.id,
    }),
  });

  if (r.status === 410) {
    const cuerpo = await r.json();
    const perdidas = cuerpo?.error?.details?.unavailableSeats ?? [];
    // Cobraste y no hay butacas. El rescate automático ya se intentó: hay que devolver la plata.
    console.error(`[stripe] REEMBOLSAR ${pi.id}: se perdieron ${perdidas.join(', ')}`);
    // await stripe.refunds.create({ payment_intent: pi.id });
    return;
  }
  if (!r.ok) return console.error(`[stripe] confirm falló: ${r.status}`);

  const reserva = await r.json();
  console.log(`[stripe] venta cerrada · ${reserva.reservationId} · ${POV}/r/${reserva.publicToken}`);
});

app.get('/listo', (_req, res) => res.send('<h1>¡Listo!</h1><p>Te mandamos las entradas por correo.</p>'));

app.listen(PORT, () => console.log(`▸ http://localhost:${PORT}`));
stripe/.env.examplevariables de entorno
POV_BASE_URL=https://pov.uy
POV_SHOWTIME=shw_pegá_la_tuya
POV_PUBLIC_KEY=pk_test_pegá_la_tuya
POV_SECRET_KEY=sk_test_pegá_la_tuya

# TU cuenta de Stripe, en modo de prueba.
STRIPE_SECRET_KEY=sk_test_de_stripe
STRIPE_PUBLISHABLE_KEY=pk_test_de_stripe
# `stripe listen --forward-to localhost:4000/stripe-webhook` te lo imprime.
STRIPE_WEBHOOK_SECRET=whsec_de_stripe

PORT=4000

El repositorio

Los siete ejemplos viven en github.com/noxvpher/pov, bajo licencia MIT: copiá lo que te sirva a tu proyecto, sin pedir permiso.

git clone https://github.com/noxvpher/pov.git
cd pov/node && cp .env.example .env && npm install && npm start

Es sólo los ejemplos: el código de POV no es abierto. Y es una copia automática de los mismos archivos que ves acá arriba y que arma cada .zip, publicada en cada despliegue — no hay una versión del repositorio y otra de la documentación que se puedan contradecir.

Probar tu receptor de webhooks

Exponé tu puerto con el túnel que uses, cargá esa URL en Integración → Webhooks y dispará un evento por el camino real:

curl -X POST https://pov.uy/api/v1/test/events \
  -H "Authorization: Bearer $POV_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"reservation.confirmed"}'

Llega firmado igual que uno de verdad, con el mismo despacho y los mismos reintentos. Es lo que te deja probar la verificación de firma, que es lo único que de verdad puede salirte mal. Sólo funciona con una clave sk_test_; con una live responde 403.

Y si algo no anda

Mirá Problemas frecuentes: los cuatro errores de integración que aparecen siempre, con su causa.