Public API

API documentation

Connect your sales site or your agency with Boleti: search departures, check seats and issue tickets with a simple REST API. We publish each section as we release it.

On this page

Introduction

The Boleti public API lets you sell a transport company's tickets from outside Boleti: from its own sales website or through an integration partner that resells on its behalf.

It is a REST API with JSON. Every route in this documentation hangs off the same base URL:

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/ping" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

When something goes wrong, the response always has the same shape: an HTTP statusCode, a stable code so your code can tell errors apart, and a message in Spanish meant to be shown as-is:

json
{
  "statusCode": 401,
  "code": "API_CREDENCIALES_INVALIDAS",
  "message": "Tu llave o tu secreto no son válidos. Revisa que los hayas copiado completos."
}

Money amounts always travel as text with two decimals (for example "150.00"), never as a number. This avoids rounding errors across languages:

json
{
  "precioBase": "150.00",
  "precioFinal": "135.00"
}

If you are an integration partner: the integration is REST with periodic polling. There are no webhooks for now — when you need the state of something, ask for it with GET.

Every POST request accepts the Idempotency-Key header with a UUID v4. If you retry the same request with the same key, nothing gets duplicated: no seat holds and no tickets.

Authentication

Every request is signed with two headers: your public key and your secret. Both are generated when you create an API connection in app.boleti.mx.

Go to app.boleti.mx and create an API connection for your company. There you get your key (starts with bk_live_) and your secret (starts with sk_live_).

Important: the secret is shown only once, right when the connection is created in app.boleti.mx. Store it somewhere safe; if you lose it you will have to regenerate it. And never put it in the browser or in code shipped to the client — it must live only on your server.

ParameterTypeRequiredDescription
X-Api-Keyheader · stringYesYour public key. It identifies the company and the connection type.
X-Api-Secretheader · stringYesYour secret. It must live only on your server, never in the browser.
Idempotency-Keyheader · UUID v4On POSTsSend a fresh UUID v4 per operation; if you retry the same operation, repeat the same one.

To check that your keys work, hit ping:

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/ping" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

If everything is fine, it answers 200 with your connection details:

json
{
  "empresa": "Esmeralda Travel",
  "tipo": "INTEGRADORA",
  "rateLimit": 300
}
HTTP statusError codeDescription
401API_CREDENCIALES_INVALIDASThe key does not exist or the secret does not match.
403PLAN_FEATURE_NOT_INCLUDEDThe connection is disabled or the company's plan does not include API access.
429API_RATE_LIMIT_EXCEDIDOYou went over your per-minute request limit. Wait for the Retry-After header and try again.

Errors

Full catalog of the API error codes, with their statusCode, stable code and response examples so your integration handles them without guessing.

When something goes wrong, the HTTP response ALWAYS has the same shape: a statusCode (the HTTP code), a stable UPPERCASE code so your program can tell the case apart, and a message in Spanish, plain and ready to show the user as-is. Branch your logic on code, never on the message text (the text may change; the code will not):

json
{
  "statusCode": 409,
  "code": "PRECIO_CAMBIADO",
  "message": "El precio de este asiento cambió mientras apartabas. Vuelve a consultar los asientos y confirma el nuevo precio."
}

Apply a simple rule: if the statusCode is 401 or 403 check your credentials or your connection type; if it is 404 the resource does not exist or is not from your company; if it is 409 there was a race (someone beat you to the seat, or the price changed) and you must re-check; if it is 422 you sent something your connection type cannot do; and if it is 429 you went too fast (see Rate limits).

These are the codes the public API can return. Remember that amounts ALWAYS travel as text with two decimals ("150.00"), never as a number — that also applies to any message that mentions money:

HTTP statusError codeDescription
401API_CREDENCIALES_INVALIDASYour key or your secret are not valid, or the connection is disabled. Check that you copied them in full.
403API_CREDENCIAL_TIPO_NO_PERMITIDOYour connection type cannot use this endpoint (for example, an integration partner trying a web-sale-only endpoint, or the other way around).
403VENTA_WEB_EMISION_DESACTIVADAWeb-site issuing is disabled for this company. The admin must enable it in Settings → Sales.
404CLIENTE_FRECUENTE_NO_ENCONTRADOThere is no frequent-customer card with that number (or it is not from your company). Same message for any card that does not resolve, by design.
404BOLETO_NO_ENCONTRADOThere is no ticket with that folio (or it is not from your company). Same message for any folio that does not resolve.
409RESERVATION_RACEAnother consumer held or sold one of those seats first. Re-check the seat map and pick others.
409PRECIO_CAMBIADOThe `precioEsperado` you sent no longer matches the current price. Re-check `/corridas/:id/asientos` and retry with the new price.
422CORTE_SOLO_INTEGRADORAThat settlement operation is for integration-partner connections only. A web-sale connection cannot do it.
422TARIFA_TIER_INACTIVOThe passenger type (`tarifaTierId`) you sent is no longer active for this departure. Re-check the passenger types and use an active one.
422CF_SOLO_VENTA_WEBThe `clienteFrecuenteId` only applies to the company's own web sale, not to integration partners. Remove that field.
429API_RATE_LIMIT_EXCEDIDOYou went over your per-minute request limit. Wait for the `Retry-After` header and try again.

The same statusCode may carry different codes (for example 403 can be a disallowed connection type or web-sale issuing disabled). That is why you always branch on code: it is the only stable key for your integration.

Rate limits

How many requests per minute each connection accepts, how to read the Retry-After header when you get a 429, and polling best practices for integration partners.

Each connection has a per-minute request limit. The default is 300 per minute per connection (you see it in your ping, in the rateLimit field). It is per connection, not per IP: if you spread your traffic across several servers with the same key, they all count against the same quota.

When you exceed it, the API answers 429 with the code API_RATE_LIMIT_EXCEDIDO. It is not a bug in your integration: you are just going too fast. Slow down and retry.

json
{
  "statusCode": 429,
  "code": "API_RATE_LIMIT_EXCEDIDO",
  "message": "Superaste tu límite de peticiones por minuto. Espera unos segundos y reintenta."
}

Every 429 response carries the Retry-After header with the seconds you must wait before retrying. Respect it: if you retry sooner you stay throttled and only burn quota. The ideal is a backoff (wait Retry-After, and if it throttles again, keep increasing the wait):

bash
# El header Retry-After viene en segundos: espera ese tiempo antes de reintentar.
< HTTP/1.1 429 Too Many Requests
< Retry-After: 12
< Content-Type: application/json

If you are an integration partner polling availability (for example /corridas/:id/asientos), use an interval of at least 5 seconds per departure. Anything less wastes your quota without giving you fresher data: availability is cached for a few seconds on the server side.

The frequent-customer lookup (/clientes-frecuentes/:numeroTarjeta) also has its own strict limit, lower than the general one, to protect personal data. If you run many lookups in a row you will get 429 with code CF_LOOKUP_RATE_LIMIT even if you have not hit your general limit — space them out.

Idempotency

How to use the Idempotency-Key header to safely retry POST requests without duplicating seat holds or issued tickets.

Every POST request in the API accepts the Idempotency-Key header with a UUID v4. It is your safety net: if your connection drops and you do not know whether the POST went through, you retry with THE SAME key and get exactly the same response as the first time — no double holds, no double issuing, no double cancellation.

It applies to ALL POSTs: hold, extend and release seats, issue tickets, and cancel. Send a fresh key for each distinct business operation; reserve retrying with the same key only to retry THAT same operation:

bash
# Primer intento: apartas los asientos 1 y 2.
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/asientos/bloquear" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 3f0d5b2a-1c6e-4a8d-9b7f-2e1c0a9d8b7c" \
  -H "Content-Type: application/json" \
  -d '{
    "corridaId": "c3333333-3333-4333-8333-333333333333",
    "numeros": [1, 2],
    "paradaOrigenTaquillaId": "a1111111-1111-4111-8111-111111111111",
    "paradaDestinoTaquillaId": "b2222222-2222-4222-8222-222222222222"
  }'

# Se cayó tu red y no supiste si funcionó. Reintenta con LA MISMA Idempotency-Key:
# recibes exactamente la misma respuesta (el mismo reservationToken) — NO se aparta dos veces.
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/asientos/bloquear" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 3f0d5b2a-1c6e-4a8d-9b7f-2e1c0a9d8b7c" \
  -H "Content-Type: application/json" \
  -d '{
    "corridaId": "c3333333-3333-4333-8333-333333333333",
    "numeros": [1, 2],
    "paradaOrigenTaquillaId": "a1111111-1111-4111-8111-111111111111",
    "paradaDestinoTaquillaId": "b2222222-2222-4222-8222-222222222222"
  }'

The key is to generate the UUID v4 once, BEFORE the first attempt, and store it alongside the operation. Every retry of that operation (on timeout, network error, 5xx) uses that same UUID. For a new operation, you generate a new UUID.

ParameterTypeRequiredDescription
Idempotency-Keyheader · UUID v4YesUUID v4 that identifies the business operation. Generate it once per operation and repeat it on every retry of THAT operation.

A key is bound to the body of the first POST you used it with. If you retry the same key with a different body, that is your mistake (you mixed two operations): generate a fresh key for each business operation.

Idempotency is remembered for a bounded time (on the order of hours), enough to cover retries and network outages. Do not use it as a permanent history: to check the real state of a hold or a ticket, use the GET endpoints.

Prices and passenger types

GET/catalogo/tipos-pasajero

Each company defines its passenger types (adult, student, senior…). Here you will see how to read fares per type, which types require an ID card when boarding, and how to protect your sale with precioEsperado.

Returns the company's active passenger types. The order is canonical: ADT (Adult) first, then the rest by code. It deliberately excludes discount percentages — the price is ALWAYS computed by the server per departure and leg (you see it in /corridas and in /corridas/:id/asientos).

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/catalogo/tipos-pasajero" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

Each type carries its id (the tarifaTierId you use when issuing), its codigo, its nombre, whether it requiereCredencial when boarding (for example student or senior), and the optional age range edadMinima/edadMaxima:

json
{
  "tiposPasajero": [
    {
      "id": "7c1e5a90-2b44-4f8e-9d31-0a6b2c3d4e5f",
      "codigo": "ADT",
      "nombre": "Adulto",
      "requiereCredencial": false,
      "edadMinima": null,
      "edadMaxima": null
    },
    {
      "id": "9f2a3b10-6c55-4a7d-8e21-1b7c3d4e5f60",
      "codigo": "EST",
      "nombre": "Estudiante",
      "requiereCredencial": true,
      "edadMinima": null,
      "edadMaxima": null
    },
    {
      "id": "1a2b3c40-7d66-4b8e-9f32-2c8d4e5f6071",
      "codigo": "INAPAM",
      "nombre": "Adulto mayor",
      "requiereCredencial": true,
      "edadMinima": 60,
      "edadMaxima": null
    }
  ]
}

Use id as each passenger's tarifaTierId when issuing. nombre is the text you show in your selector, and requiereCredencial warns you the passenger must show an ID when boarding.

The ADT (Adult) price for the CIUDAD DE MEXICO → GUADALAJARA leg in this example is $500.00. Never compute the price yourself: ask for it via /corridas or /corridas/:id/asientos, which already returns tarifasPorTier per seat and leg.

How to render the seat map

The bus layout travels as a self-describing snapshot: floors, rows, cell types (seat, aisle, bathroom, stairs…), colored zones and the numbering rule so you can draw the seat map exactly as it is.

The /corridas/:id/asientos endpoint returns two things together: the croquis (the bus layout as an immutable snapshot from when the departure was created) and asientos[] (the state and price of each seat for the requested leg). It also carries a leyenda: an object that explains the JSON itself so you can render the map without reading Boleti's code.

This is what the leyenda looks like (abridged). Each floor is a {rows, cols, cells[][]} grid where cells[row][col] is a cell typed by type; row 0 is the FRONT (driver) and the last row is the BACK:

json
{
  "descripcion": "El campo `croquis` es el plano del autobús al momento de crear la corrida (snapshot inmutable). Contiene `zonas`, `numberingRule` y uno o dos pisos (`floor1`, `floor2` opcional). Cada piso es una cuadrícula {rows, cols, cells[][]} donde cells[fila][columna] es una celda tipada por `type`.",
  "grid": "Renderiza cada piso como una cuadrícula de `cols` columnas por `rows` filas. La fila 0 es el FRENTE del autobús (chofer) y la última fila es la parte TRASERA.",
  "tiposDeCelda": {
    "seat": "Asiento vendible. Cruza `number` con el arreglo asientos[] de la misma respuesta para conocer estado y precios.",
    "aisle": "Pasillo — espacio vacío transitable, sin render de objeto.",
    "driver": "Cabina del chofer (típicamente fila 0).",
    "bathroom": "Baño. `gender` = 'mixto' | 'damas' | 'caballeros' cuando la empresa lo distingue.",
    "stairs": "Escaleras entre pisos. `direction` = 'up' | 'down'.",
    "empty": "Celda vacía sin significado — respeta el hueco en la cuadrícula."
  },
  "camposDeAsiento": {
    "number": "Número visible del asiento (string). Es la llave que conecta croquis ↔ asientos[] ↔ bloqueo/emisión.",
    "zonaId": "Id de la zona de precio del asiento (ver zonas del croquis) o null. Asientos de la misma zona comparten precio.",
    "position": "Ubicación física: 'ventanilla' | 'pasillo' | 'centro'."
  },
  "numberingRule": "Regla con la que se numeraron los asientos (informativa): 'LTR_TOP_DOWN' | 'RTL_TOP_DOWN' | 'LTR_BOTTOM_UP' | 'COLUMN_FIRST_LTR' | 'MANUAL_ONLY'.",
  "zonas": "Arreglo zonas[{id, label, color}] del croquis. `color` es un hex sugerido para pintar los asientos de esa zona; `label` es el nombre visible.",
  "estadosDeAsiento": {
    "DISPONIBLE": "El asiento puede bloquearse para el tramo consultado.",
    "RESERVADO": "Alguien tiene un bloqueo temporal activo que solapa el tramo. Puede liberarse al expirar — vuelve a consultar.",
    "VENDIDO": "Hay un boleto emitido cuyo tramo solapa el consultado. No disponible."
  }
}

To draw the map: walk each floor as a grid, render only the meaningful cells, and for each type: "seat" cell cross its number with the asientos[] array in the same response — that gives you the estado (DISPONIBLE/RESERVADO/VENDIDO) and the tarifasPorTier. The schema version travels in croquisSchemaVersion (today 1); it never changes incompatibly within /public/v1.

Origin and destination catalog

GET/catalogo/od-pairs

The company's terminals and which destinations you can sell from each origin, ready to fill your search selectors.

Returns the full graph of sellable origins and destinations in a single request: the fastest way to build your two search selectors. /catalogo/origenes and /catalogo/destinos?origenTaquillaId= also exist if you prefer to load them separately.

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/catalogo/od-pairs" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

The response carries origenes (only terminals with at least one sellable destination) and destinosPorOrigen (a map origin id → [destination ids]). In this example, from CIUDAD DE MEXICO you can sell to GUADALAJARA:

json
{
  "origenes": [
    {
      "id": "a1111111-1111-4111-8111-111111111111",
      "clave": "CDMX",
      "nombre": "CIUDAD DE MEXICO",
      "direccion": { "calle": "Av. Central 100", "ciudad": "Ciudad de México", "estado": "CDMX", "cp": "09070" },
      "geolocation": { "lat": 19.4361, "lng": -99.0719 },
      "telefono": "+525555551234",
      "gmapsUrl": "https://maps.google.com/?q=19.4361,-99.0719"
    },
    {
      "id": "b2222222-2222-4222-8222-222222222222",
      "clave": "GDL",
      "nombre": "GUADALAJARA",
      "direccion": { "calle": "Av. Juárez 200", "ciudad": "Guadalajara", "estado": "JAL", "cp": "44100" },
      "geolocation": { "lat": 20.6597, "lng": -103.3496 },
      "telefono": "+523333335678",
      "gmapsUrl": "https://maps.google.com/?q=20.6597,-103.3496"
    }
  ],
  "destinosPorOrigen": {
    "a1111111-1111-4111-8111-111111111111": ["b2222222-2222-4222-8222-222222222222"]
  }
}

Each terminal carries id, clave, nombre and optional location data (direccion, geolocation, telefono, gmapsUrl). Use nombre to display and id for the calls to /corridas.

With the origin id and the destination id the user picks, you can now search departures in /corridas with that origenTaquillaId and destinoTaquillaId.

Search departures

GET/corridas

Search available departures by date, origin and destination, with schedules, free seats and fares per passenger type.

Search a day's departures for a leg. The origin-destination pair is required: without it there would be no prices per passenger type, and the public response ALWAYS carries tarifasPorTier. You do not send canal: the server forces it based on your connection type (web sale or integration partner).

ParameterTypeRequiredDescription
fechaquery · YYYY-MM-DDYesDeparture day in `YYYY-MM-DD` format (for example `2026-07-15`).
origenTaquillaIdquery · UUIDYes`id` of the origin terminal, taken from `/catalogo/od-pairs`.
destinoTaquillaIdquery · UUIDYes`id` of the destination terminal reachable from that origin.
limitquery · number (1-50)NoMaximum departures to return (1 to 50). If you omit it, the server default is used.
bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/corridas?fecha=2026-07-15\
&origenTaquillaId=a1111111-1111-4111-8111-111111111111\
&destinoTaquillaId=b2222222-2222-4222-8222-222222222222" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

The response carries corridas[] with schedules, asientosDisponibles, the leg's paradas and the tarifasPorTier for the requested pair. Amounts travel as text with 2 decimals:

json
{
  "corridas": [
    {
      "id": "c3333333-3333-4333-8333-333333333333",
      "codigo": "0730-0001",
      "rutaId": "d4444444-4444-4444-8444-444444444444",
      "rutaCodigo": "RUT-0001",
      "rutaNombre": "CIUDAD DE MEXICO - GUADALAJARA",
      "autobusId": "e5555555-5555-4555-8555-555555555555",
      "autobusEconomico": "ECO-102",
      "fechaSalida": "2026-07-15",
      "horaSalida": "07:30",
      "fechaLlegada": "2026-07-15",
      "horaLlegada": "14:00",
      "capacidadTotal": 44,
      "asientosDisponibles": 39,
      "paradas": [
        { "orden": 0, "taquillaId": "a1111111-1111-4111-8111-111111111111", "label": "CIUDAD DE MEXICO" },
        { "orden": 1, "taquillaId": "b2222222-2222-4222-8222-222222222222", "label": "GUADALAJARA" }
      ],
      "tarifasPorTier": [
        {
          "tierId": "7c1e5a90-2b44-4f8e-9d31-0a6b2c3d4e5f",
          "tierCodigo": "ADT",
          "tierNombre": "Adulto",
          "precioBase": "500.00",
          "precioFinal": "500.00",
          "breakdown": { "zonaDelta": "0.00", "globalDelta": "0.00" }
        }
      ]
    }
  ]
}

Keep the id of the departure the user picks: you need it to check the seat map at /corridas/:id/asientos and to hold seats.

Seats of a departure

GET/corridas/:id/asientos

Check the seat map and the state of every seat (free, taken or on hold) with its price per passenger type — each zone of the bus may cost differently.

Returns the bus seat map + the state and price of each seat for the requested leg. The origin-destination pair is required (state and price depend on the leg). Poll it periodically: availability changes as others sell.

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/corridas/c3333333-3333-4333-8333-333333333333/asientos?\
origenTaquillaId=a1111111-1111-4111-8111-111111111111\
&destinoTaquillaId=b2222222-2222-4222-8222-222222222222" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

Each seat's public state collapses into three values: DISPONIBLE, RESERVADO (someone has a temporary hold overlapping the leg) or VENDIDO. You never see someone else's hold token — you track your holds with the token you get when you hold seats:

json
{
  "corrida": {
    "id": "c3333333-3333-4333-8333-333333333333",
    "codigo": "0730-0001",
    "capacidadSnapshot": 44,
    "rutaCodigoSnapshot": "RUT-0001",
    "rutaNombreSnapshot": "CIUDAD DE MEXICO - GUADALAJARA",
    "autobusEconomicoSnapshot": "ECO-102",
    "fechaSalida": "2026-07-15",
    "horaSalida": "07:30",
    "horaLlegada": "14:00"
  },
  "croquis": { "zonas": [], "numberingRule": "LTR_TOP_DOWN", "floor1": { "rows": 12, "cols": 5, "cells": [] } },
  "croquisSchemaVersion": 1,
  "leyenda": { "descripcion": "...", "estadosDeAsiento": { "DISPONIBLE": "...", "RESERVADO": "...", "VENDIDO": "..." } },
  "asientos": [
    {
      "numero": 1,
      "zonaId": null,
      "fila": 1,
      "columna": 0,
      "estado": "DISPONIBLE",
      "tarifasPorTier": [
        {
          "tierId": "7c1e5a90-2b44-4f8e-9d31-0a6b2c3d4e5f",
          "tierCodigo": "ADT",
          "tierNombre": "Adulto",
          "precioBase": "500.00",
          "precioFinal": "500.00",
          "breakdown": { "zonaDelta": "0.00", "globalDelta": "0.00" }
        }
      ]
    },
    {
      "numero": 2,
      "zonaId": null,
      "fila": 1,
      "columna": 1,
      "estado": "VENDIDO",
      "tarifasPorTier": [
        {
          "tierId": "7c1e5a90-2b44-4f8e-9d31-0a6b2c3d4e5f",
          "tierCodigo": "ADT",
          "tierNombre": "Adulto",
          "precioBase": "500.00",
          "precioFinal": "500.00",
          "breakdown": { "zonaDelta": "0.00", "globalDelta": "0.00" }
        }
      ]
    }
  ],
  "asientosDisponibles": 39
}

Cross each seat map cell with asientos[] by its numero. To sell, take the passenger type's tarifaTierId from tarifasPorTier and its precioFinal (here, ADT = $500.00).

Availability is cached for a few seconds to support polling. The final truth is validated at hold time: if two consumers request the same seat, the second gets a 409 conflict.

Hold seats

POST/asientos/bloquear

Hold seats for a few minutes while the passenger finishes the purchase. You will also be able to extend the hold or release it if the passenger changes their mind.

Hold up to 20 seats for a few minutes while the passenger finishes. Send an Idempotency-Key (UUID v4) per operation: if you retry the same call with the same key, the hold is not duplicated. Optionally send the X-Session-Ref header with an opaque reference of the buyer's session to trace it.

bash
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/asientos/bloquear" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 3f0d5b2a-1c6e-4a8d-9b7f-2e1c0a9d8b7c" \
  -H "Content-Type: application/json" \
  -d '{
    "corridaId": "c3333333-3333-4333-8333-333333333333",
    "numeros": [1, 2],
    "paradaOrigenTaquillaId": "a1111111-1111-4111-8111-111111111111",
    "paradaDestinoTaquillaId": "b2222222-2222-4222-8222-222222222222"
  }'

The response carries the reservationToken (save it: it is your hold key and you use it when issuing), the list of held numeros, the expiresAt (when it lapses) and the ttlMinutes:

json
{
  "reservationToken": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
  "expiresAt": "2026-07-15T13:45:00.000Z",
  "numeros": [1, 2],
  "ttlMinutes": 8,
  "paradaOrigenOrden": 0,
  "paradaDestinoOrden": 1
}

While the passenger finishes you can renew the hold with /asientos/extender, or release it with /asientos/liberar (without numeros you release the whole token; with numeros you release only those seats). Both need the same reservationToken and their own Idempotency-Key:

bash
# Renovar el TTL del apartado
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/asientos/extender" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 5c4d3e2f-1a0b-4c9d-8e7f-6a5b4c3d2e1f" \
  -H "Content-Type: application/json" \
  -d '{ "reservationToken": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d" }'

# Soltar el apartado (sin "numeros" libera el token completo)
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/asientos/liberar" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 6d5e4f3a-2b1c-4d0e-9f8a-7b6c5d4e3f2a" \
  -H "Content-Type: application/json" \
  -d '{ "reservationToken": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d" }'

Errors you should handle:

HTTP statusError codeDescription
404RESERVATION_NOT_FOUNDThe hold does not exist, has lapsed or is not from your connection. Check the seats again and hold them anew.
409SEAT_CONFLICTAnother consumer held or sold one of those seats first. Check the map again and pick others.

Issue tickets

POST/boletos/emitir

Issue the tickets of a reservation: passenger details, passenger type per seat, price protected against changes, and PDFs ready to deliver.

Turns a hold into tickets. Send the reservationToken you got when holding and one passenger per held seat. The REAL price is ALWAYS set by the server; precioEsperado is optional and only detects that your price list went stale (if it differs, you get 409 PRECIO_CAMBIADO).

If your connection is an integration partner, each ticket is issued paid (estadoPago: COMPLETO) — you charge the passenger and it settles in your cut. The clienteFrecuenteId field does not apply to integration partners (you will get 422). Send an Idempotency-Key (UUID v4) so you do not duplicate tickets if you retry.

bash
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/boletos/emitir" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 7e6f5a4b-3c2d-4e1f-8a9b-0c1d2e3f4a5b" \
  -H "Content-Type: application/json" \
  -d '{
    "reservationToken": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
    "pasajeros": [
      {
        "numeroAsiento": 1,
        "nombre": "JUAN PEREZ",
        "telefono": "+5215578776956",
        "correo": "juan@example.com",
        "tarifaTierId": "7c1e5a90-2b44-4f8e-9d31-0a6b2c3d4e5f",
        "precioEsperado": "500.00"
      }
    ]
  }'

The response carries one ticket per seat with its folio, estado, estadoPago, montoTotal, the pdf (signed URLs of the ticket in color and 80mm) and the whatsapp send result:

json
{
  "boletos": [
    {
      "folio": "ESMER-2026-000001",
      "numeroAsiento": 1,
      "pasajeroNombre": "JUAN PEREZ",
      "estado": "EMITIDO",
      "estadoPago": "COMPLETO",
      "montoTotal": "500.00",
      "anticipoExpiraAt": null,
      "priceBreakdown": {
        "tierCodigo": "ADT",
        "precioBase": "500.00",
        "precioFinal": "500.00"
      },
      "pdf": {
        "color1920": "https://<storage>/boletos/ESMER-2026-000001-color.png?sig=...",
        "mm80": "https://<storage>/boletos/ESMER-2026-000001-80mm.png?sig=..."
      },
      "whatsapp": "queued"
    }
  ]
}

In this example the folio is ESMER-2026-000001 and the amount $500.00. The PDFs are temporary signed URLs — fetch them again with /boletos/:folio when you need them.

Errors you should handle:

HTTP statusError codeDescription
409PRECIO_CAMBIADOThe `precioEsperado` you sent no longer matches the current price. Re-check `/corridas/:id/asientos` and retry with the new price.
422CF_SOLO_VENTA_WEBThe `clienteFrecuenteId` only applies to the company's own web sale, not to integration partners. Remove that field.
403VENTA_WEB_EMISION_DESACTIVADAWeb-site issuing is disabled for this company. The admin must enable it in Settings → Sales.

Look up a ticket

GET/boletos/:folio

Look up a ticket by its folio and re-fetch its PDFs whenever you need them.

Look up the current state of a ticket by its folio and get its PDFs again (signed URLs expire; this endpoint gives you fresh ones). A folio from another company returns 404 — same as a non-existent one.

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/boletos/ESMER-2026-000001" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

The response carries the ticket state, the montoTotal, the canal it was sold through and the pdf with re-generable signed URLs:

json
{
  "folio": "ESMER-2026-000001",
  "numeroAsiento": 1,
  "pasajeroNombre": "JUAN PEREZ",
  "estado": "EMITIDO",
  "estadoPago": "COMPLETO",
  "montoTotal": "500.00",
  "anticipoExpiraAt": null,
  "canal": "API",
  "priceBreakdown": {
    "tierCodigo": "ADT",
    "precioBase": "500.00",
    "precioFinal": "500.00"
  },
  "pdf": {
    "color1920": "https://<storage>/boletos/ESMER-2026-000001-color.png?sig=...",
    "mm80": "https://<storage>/boletos/ESMER-2026-000001-80mm.png?sig=..."
  }
}

As an integration partner, use this endpoint to reconcile: it is the source of truth for the state of every ticket you issued, with no need for webhooks.

Frequent customers

GET/clientes-frecuentes/:numeroTarjeta

Look up a frequent customer by their card number to fill in their details on the web sale. Available only for web-sale connections.

Look up a frequent customer by their card number (16 digits) to autofill their details at checkout. Web-sale connections only — an integration-partner connection gets 403.

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/clientes-frecuentes/4152313412341234" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO"

The response carries the clienteFrecuenteId (which you then send per passenger when issuing), the nombre in uppercase and the telefono in international format. That data is what the company registered: do not edit it, the server substitutes it when issuing with a frequent customer:

json
{
  "numeroTarjeta": "4152313412341234",
  "clienteFrecuenteId": "f6666666-6666-4666-8666-666666666666",
  "nombre": "MARIA LOPEZ",
  "telefono": "+5215512345678"
}

This lookup has a strict per-minute limit to protect the data. An invalid, non-existent or another company's card always returns the same 404 (you cannot infer which cards exist).

HTTP statusError codeDescription
404CLIENTE_FRECUENTE_NO_ENCONTRADOThere is no card with that number (or it is not from your company). Same message for any card that does not resolve, by design.
429CF_LOOKUP_RATE_LIMITToo many card lookups in a row. Wait for the Retry-After header and try again.

Cancellation (integration partners)

POST/boletos/:folio/cancelar

Cancel a ticket sold by your agency: the full record of the sale and the cancellation is kept, and the ticket is excluded from the settlement you are charged for.

Cancel a ticket your agency issued. Integration-partner connections only — a web-sale connection gets 403. The cancellation keeps the full record of the sale and of the cancellation; it deletes nothing.

There is no automatic refund through the API: you handle the refund with your passenger as usual. On Boleti's side, the cancelled ticket is simply excluded from the accounting cut of the period you are charged for. Send an Idempotency-Key (UUID v4) so a retry does not fail if the ticket is already cancelled.

bash
curl -X POST "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/boletos/ESMER-2026-000001/cancelar" \
  -H "X-Api-Key: bk_live_TU_LLAVE" \
  -H "X-Api-Secret: sk_live_TU_SECRETO" \
  -H "Idempotency-Key: 8f7a6b5c-4d3e-4f2a-8b1c-9d0e1f2a3b4c"

The response confirms the folio, the new estado (CANCELADO) and the canceladoAt timestamp:

json
{
  "folio": "ESMER-2026-000001",
  "estado": "CANCELADO",
  "canceladoAt": "2026-07-15T18:20:00.000Z"
}

Since there are no webhooks, your reconciliation is by polling: the cut you are charged already reflects the period's cancellations. Check any ticket's state whenever you need at /boletos/:folio.

Errors you should handle:

HTTP statusError codeDescription
403CANCELACION_SOLO_INTEGRADORAAPI cancellation is for integration-partner connections only. A web-sale connection cannot cancel tickets here.
404BOLETO_NO_ENCONTRADOThere is no ticket with that folio (or it is not from your company). Same message for any folio that does not resolve.
409BOLETO_YA_CANCELADOThat ticket was already cancelled. The operation is idempotent: it does not record the cancellation again.