Contenido

Documentación

Webhooks

Los cuatro eventos, cómo verificar la firma y cómo rotar el secreto sin cortar nada.

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.