Receta: vender desde tu web
El circuito completo de una venta hecha desde tu propia web: disponibilidad, reserva, cobro y confirmación por webhook.
Última actualización: 23 de agosto de 2026
Vender sin que el comprador salga de tu web. Es la integración más completa que permite la API y la que más cuidado requiere, porque hay dinero de por medio.
Lo que necesitas: una clave con orders:write —que ya incluye catalog:read y orders:read— y un endpoint de webhooks.
La arquitectura, antes que el código
Navegador ──→ TU backend ──→ API de Tickeep
La clave de API vive solo en tu servidor. Tu frontend llama a tus propios endpoints, y tu backend traduce a llamadas a Tickeep. CORS está deshabilitado en /api/v1 precisamente para que esto no se pueda hacer de otra manera.
Tu backend necesita tres endpoints propios, más o menos:
| Tuyo | Llama a |
|---|---|
GET /api/entradas/:performanceId | GET /performances/{id}/availability |
POST /api/carrito | POST /orders |
POST /api/pagar | POST /orders/{ref}/payment-link o /confirm |
Más el receptor de webhooks.
1. Enseñar qué hay a la venta
// TU endpoint
export async function GET(req: Request, { params }) {
const { availability, ticketTypes } = await tickeep.availability(params.performanceId)
const tipos = new Map(ticketTypes.map((t) => [t.id, t]))
return Response.json(
availability
.filter((a) => a.onSale)
.map((a) => ({
id: a.ticketTypeId,
nombre: a.name,
precio: a.price,
zona: tipos.get(a.ticketTypeId)?.zone?.name ?? null,
incluye: tipos.get(a.ticketTypeId)?.includes ?? [],
// Tope del selector: lo menor entre lo que queda y el límite por pedido
maximo: Math.min(a.available, tipos.get(a.ticketTypeId)?.maxPerOrder ?? a.available),
})),
)
}
Filtra por onSale: si es false, esa tarifa está fuera de su ventana de venta o agotada, y venderla fallará.
No mandes el available exacto al navegador. Úsalo para topar el selector, no para pintar «solo quedan 3» si no quieres que se te quede desactualizado en pantalla.
2. Reservar
Aquí empieza la venta de verdad. Un pedido nace pending con el stock retenido 30 minutos.
export async function POST(req: Request) {
const { performanceId, items, email, nombre } = await req.json()
// La clave nace UNA vez, con el intento de compra
const idempotencyKey = crypto.randomUUID()
try {
const order = await tickeep.createOrder(
{
venueId: process.env.TICKEEP_VENUE_ID!,
items, // [{ ticketTypeId, quantity }]
customer: { email, name: nombre },
channel: 'online',
},
idempotencyKey,
)
// Guárdalo TODO en tu base de datos antes de responder
await db.compras.crear({
referencia: order.reference,
idempotencyKey,
estado: 'pendiente',
expiraEn: order.reservationExpiresAt,
total: order.total,
})
return Response.json({ referencia: order.reference, total: order.total })
} catch (err) {
if (err.code === 'sold_out') {
return Response.json({ error: 'Se han agotado mientras comprabas' }, { status: 409 })
}
throw err
}
}
Si tu proceso se cae entre la llamada y la respuesta, la única forma de recuperar ese pedido sin crear otro es reintentar con la misma clave. Si no la has guardado, la has perdido.
sold_out no es un fallo tuyo
Puede pasar aunque la disponibilidad dijera que sí: entre que la consultaste y vendiste, alguien se llevó las últimas. La reserva es atómica y es la única verdad. Enséñale al comprador un mensaje decente y vuelve a pedir disponibilidad.
Enséñale el reloj
reservationExpiresAt es información del comprador, no tuya. Un contador en pantalla evita la escena de rellenar los datos con calma y encontrarse la reserva caducada.
3. Cobrar
Dos caminos. Elige según quién tenga la pasarela.
Opción A — que pague por la pasarela de la sala
Lo más habitual, y lo que menos responsabilidad te deja: no tocas dinero.
const { url, expiresAt } = await tickeep.paymentLink(referencia)
return Response.json({ url }) // y rediriges el navegador ahí
El comprador paga por Redsys o Stripe, según lo que tenga configurado la sala. Cuando el pago entra, Tickeep marca el pedido y emite las entradas. Tú te enteras por el webhook.
Opción B — cobras tú
Si tienes tu propia pasarela y quieres controlar el cobro:
// 1. Cobras con TU pasarela
const cobro = await miPasarela.cobrar({ importe: order.total.amount, ... })
// 2. Y solo si ha ido bien, lo registras en Tickeep
const { tickets, ticketsStatus } = await tickeep.confirmOrder(referencia, {
paymentMethod: 'external',
reference: cobro.id, // para cuadrar con tu extracto
})
/confirm no cobra nada: registra que el dinero se movió fuera de Tickeep y emite las entradas al momento, devolviéndolas en la respuesta.
Cobra primero, confirma después. Al revés emitirías entradas de un cobro que puede fallar, y recuperarlas es mucho peor que perder una venta.
Y si /confirm falla después de haber cobrado tú, no lo dejes ahí: reintenta —es idempotente por construcción— y si sigue fallando, avisa a alguien. Ese es el estado peligroso, no el contrario.
4. Enterarse del pago
Con la opción A, esto no es opcional: es la única forma de saber que el comprador pagó.
// app/api/webhooks/tickeep/route.ts
export async function POST(req: Request) {
const rawBody = await req.text()
const ok = await verificarFirmaWebhook({
rawBody,
signature: req.headers.get('tickeep-signature'),
secret: process.env.TICKEEP_WEBHOOK_SECRET!,
})
if (!ok) return new Response('firma inválida', { status: 401 })
const evento = JSON.parse(rawBody)
// Deduplica: se garantiza AL MENOS una entrega
if (!(await db.eventos.insertarSiNoExiste(evento.id))) {
return new Response('ok', { status: 200 })
}
if (evento.type === 'order.paid') {
await db.compras.marcarPagada(evento.data.order.reference)
await miEmailDeConfirmacion(evento.data.order)
}
if (evento.type === 'order.cancelled') {
await db.compras.marcarCancelada(evento.data.order.reference)
}
return new Response('ok', { status: 200 })
}
Suscríbete al menos a order.paid y order.cancelled. Los detalles —firma, reintentos, los tres fallos que se cometen siempre— están en webhooks.
5. Cuando el comprador se echa atrás
await tickeep.cancelOrder(referencia)
Llámalo cuando cierre la pestaña, cuando el pago falle o cuando expire tu propio flujo. Si no lo haces, esas entradas siguen bloqueadas media hora y no se le pueden vender a nadie. En una noche que se agota, media hora es dinero.
Es idempotente: cancelar dos veces devuelve 409 conflict la segunda y no rompe nada.
Los estados por los que pasa una compra
┌─ el comprador elige ─┐
↓ │
POST /orders → pending (30 min)
│
┌──────────────┼────────────────┐
↓ ↓ ↓
paga cancelas caduca
│ │ │
order.paid cancelled cancelled
│
entradas emitidas y enviadas
Refleja esos mismos estados en tu base de datos. Ir a buscarlos a Tickeep en cada carga de página es lento y te come el límite de uso.
La lista antes de producción
La clave está solo en el servidor
Búscala en tu bundle de frontend. Si aparece, no salgas a producción.
La Idempotency-Key nace fuera del bucle de reintentos
Una por intento de compra, guardada junto al pedido en tu base de datos.
El webhook verifica la firma con el cuerpo crudo
Y comprueba el timestamp. Y compara en tiempo constante.
El receptor deduplica por el id del evento
Antes de actuar, no después.
Cancelas las reservas abandonadas
No dejes que caduquen solas si puedes evitarlo.
Manejas sold_out con un mensaje decente
Va a pasar. Que no sea una pantalla de error genérica.
Guardas el X-Request-Id de los errores
Es lo primero que te pediremos si algo va mal.
Lo has probado contra una sala de pruebas
No contra la sala con la que vendes: no hay entorno aislado todavía.
Seguir por aquí
El flujo de venta en dos tiempos: reservar, cobrar y emitir. Con los cuatro endpoints que lo componen y qué hace cada uno.
Cómo funciona la cabecera Idempotency-Key, por qué es obligatoria al crear un pedido y por qué no lo es en el resto.
Que Tickeep avise a tu servidor cuando pasa algo, en vez de preguntar en bucle. Eventos, firma, reintentos y los tres fallos que se cometen siempre.