Saltar al contenido
Tickeep
Índice de la API

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

Tu navegador nunca habla con Tickeep
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:

TuyoLlama a
GET /api/entradas/:performanceIdGET /performances/{id}/availability
POST /api/carritoPOST /orders
POST /api/pagarPOST /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
  }
}
Guarda la Idempotency-Key junto al pedido

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.

El orden importa

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.