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 is relative to the same base URL:

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

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

json
{
  "statusCode": 401,
  "code": "API_CREDENCIALES_INVALIDAS",
  "message": "Las credenciales de la API no son válidas. Revise sus headers X-Api-Key y X-Api-Secret."
}

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: besides GET queries, you can configure Webhooks to learn about issuances and cancellations of your tickets in real time. For reconciliation, the source of truth remains the GET query.

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 will obtain your key (starts with bk_live_) and your secret (starts with sk_live_).

Important: the secret is shown only once, at the moment the connection is created in app.boleti.mx. Store it in a secure location; if you lose it, you will need to regenerate it. Never place it in the browser or in code shipped to the client — it must reside 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, send a request to ping:

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

If the credentials are valid, the API responds with 200 and 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 exceeded your per-minute request limit. Wait for the time indicated by the Retry-After header, then retry.

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 cambió desde que lo consultó. Revise el precio vigente y vuelva a intentar."
}

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 a race condition occurred (another client completed the purchase first, 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 the request rate exceeded the limit (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 a web-sale connection trying an integration-partner-only endpoint).
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.
422CORRIDA_SIN_PRECIOThe departure has no published price for the requested leg, so it cannot be bought online. Returned by `/corridas/:id/asientos`. Do not retry: offer another departure via `/corridas`, which already excludes departures without a price.
422CORTE_SOLO_INTEGRADORAThat settlement ("corte") operation is for integration-partner connections only. A web-sale connection cannot perform it.
422TARIFA_NOT_APPLICABLEAppears when issuing tickets: the seat and the passenger type (`tarifaTierId`) you sent have no price on that leg. Re-check `/corridas/:id/asientos` and use only seat and passenger-type combinations that carry a price in `tarifasPorTier`; if that passenger has none, offer another departure.
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 exceeded your per-minute request limit. Wait the number of seconds indicated by the `Retry-After` header and retry.

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: the request rate exceeded the limit. Reduce the request rate and retry.

json
{
  "statusCode": 429,
  "code": "API_RATE_LIMIT_EXCEDIDO",
  "message": "Su llave alcanzó su límite de peticiones por minuto. Espere unos segundos y vuelva a intentar."
}

Every 429 response carries the Retry-After header with the seconds you must wait before retrying. Respect it: if you retry sooner you remain throttled and only consume additional 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: espere 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 consecutively you will get 429 with code CF_LOOKUP_RATE_LIMIT even if you have not reached your general limit — increase the interval between requests.

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 protects you against duplicate operations: 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 duplicate holds, no duplicate issuing, no duplicate 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: se apartan 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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"
  }'

# La red se interrumpió y no supo si la petición funcionó. Reintente con LA MISMA Idempotency-Key:
# recibirá 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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 an error on your side (two operations were mixed): 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 (it is available 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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 indicates that the passenger must present 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: request 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": "Renderice cada piso como una cuadrícula CSS de `cols` columnas (siempre 5 — los buses en México usan 5 carriles) por `rows` filas. La fila 0 es el FRENTE del autobús (chofer) y la última fila es la parte TRASERA. Si existe `floor2`, muestre tabs o vistas separadas por piso.",
  "tiposDeCelda": {
    "seat": "Asiento vendible. Cruce `number` con el arreglo `asientos[]` de esta misma respuesta para conocer estado y precios por tipo de pasajero.",
    "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'` indica hacia dónde suben; `iconRotation` la orientación del ícono.",
    "empty": "Celda vacía sin significado — respete el hueco en la cuadrícula. Si trae `partOf`, es la cola de una celda expandida (ver camposComunes.partOf)."
  },
  "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 a la que pertenece el asiento (ver `zonas` del croquis) o null si no tiene zona. Asientos de la misma zona comparten precio.",
    "position": "Ubicación física derivada de la columna: 'ventanilla' | 'pasillo' | 'centro'."
  },
  "numberingRule": "Regla con la que se numeraron los asientos (informativa — los `number` ya vienen resueltos): 'LTR_TOP_DOWN' | 'RTL_TOP_DOWN' | 'LTR_BOTTOM_UP' | 'COLUMN_FIRST_LTR' | 'MANUAL_ONLY'.",
  "zonas": "Arreglo `zonas[{id, label, color}]` del croquis: zonas de precio del autobús. `color` es un hex sugerido para pintar los asientos de esa zona en el mapa; `label` es el nombre visible (ej. 'Premium').",
  "estadosDeAsiento": {
    "DISPONIBLE": "El asiento puede bloquearse para el tramo consultado.",
    "RESERVADO": "Alguien tiene un bloqueo temporal activo que solapa el tramo consultado. Puede liberarse al expirar — vuelva 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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 selects, 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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", "fechaPaso": "2026-07-15", "horaPaso": "07:30:00" },
        { "orden": 1, "taquillaId": "b2222222-2222-4222-8222-222222222222", "label": "GUADALAJARA", "fechaPaso": "2026-07-15", "horaPaso": "14:00:00" }
      ],
      "tarifasPorTier": [
        {
          "tierId": "7c1e5a90-2b44-4f8e-9d31-0a6b2c3d4e5f",
          "tierCodigo": "ADT",
          "tierNombre": "Adulto",
          "precioBase": "500.00",
          "precioFinal": "500.00",
          "breakdown": { "zonaDelta": "0.00", "globalDelta": "0.00" }
        }
      ],
      "vendible": true,
      "sinPrecio": null
    }
  ]
}

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.

Only one thing changes for your integration: the listing no longer includes departures without a price. If no fare for the requested leg is above zero, the server drops that departure and you never receive it in corridas[], even though the company still has it scheduled. Every departure you do receive therefore carries at least one chargeable price on that leg (although a specific seat and passenger-type combination may still lack one). Each departure also carries two informational fields, vendible and sinPrecio: in this API vendible always arrives as true and sinPrecio always as null — the origin-destination pair is mandatory and departures that cannot be sold are dropped before the response is built — so there is nothing for you to branch on.

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 have a different price.

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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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 receive when you create a hold:

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. Final validation occurs when seats are held: if two consumers request the same seat, the second receives a 409 conflict.

If the departure has no price for the requested leg, this endpoint returns no seat map: it responds 422 with the code CORRIDA_SIN_PRECIO. This happens when the company has no chargeable fare for that leg — it was never captured, it was deleted, it was deactivated, it is outside its validity dates for the departure date, or every fare that exists is zero — and also when the company has no active passenger types. You will mostly hit it through saved or shared links to a departure that is no longer on sale:

json
{
  "statusCode": 422,
  "code": "CORRIDA_SIN_PRECIO",
  "message": "Este viaje aún no tiene precio publicado, por eso no se puede comprar en línea. Elige otro viaje o compra en taquilla."
}

Do not retry: this is not a temporary error. The response stays the same until the company captures the price, so retrying only spends your request quota. Show the message to the passenger as it is (it is already written for them) and offer another departure: query /corridas again for the same date and leg, which already excludes departures without a price. If the departure came from a saved link, send the passenger to that search instead of leaving them on the seat map.

HTTP statusError codeDescription
422CORRIDA_SIN_PRECIOThe departure has no price for the requested leg and cannot be sold online. Do not retry: offer another departure via `/corridas`.

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 abandons the purchase.

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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_SECRETO" \
  -H "Idempotency-Key: 5c4d3e2f-1a0b-4c9d-8e7f-6a5b4c3d2e1f" \
  -H "Content-Type: application/json" \
  -d '{ "reservationToken": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d" }'

# Liberar el apartado (sin "numeros" se 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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 expired, or does not belong to your connection. Query the seats again and create a new hold.
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 received when placing the hold 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 is outdated (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 the amount is included in your settlement ("corte"). 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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. Webhooks notify you in real time when one of your tickets is issued or cancelled; even so, always close your reconciliation against this GET.

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 receives 403.

bash
curl "https://boleti-e7ebfya2hsgqbrgf.mexicocentral-01.azurewebsites.net/api/v1/public/v1/clientes-frecuentes/4152313412341234" \
  -H "X-Api-Key: bk_live_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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 consecutive card lookups. 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 ("corte") 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 according to your own process. On Boleti's side, the cancelled ticket is simply excluded from the accounting settlement 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_SU_LLAVE" \
  -H "X-Api-Secret: sk_live_SU_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"
}

To learn about a cancellation in real time — even when the company performs it at its own ticket counter — configure Webhooks: you will receive the boleto.cancelado event. For reconciliation, the source of truth remains the query: the settlement you are charged already reflects the period's cancellations, and you can check any ticket 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 through this endpoint.
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.

Webhooks

Receive real-time notifications when a ticket issued through your connection changes state: Boleti sends a signed POST request to the URL you configure, with no polling required.

A webhook is a JSON POST request that Boleti sends to your server when an event occurs on a ticket issued through your connection (an issuance or a cancellation). Each API connection supports one receiving endpoint. Delivery is at-least-once: in exceptional cases you may receive a duplicate event — deduplicate by the event's id field. Arrival order is NOT guaranteed: process each event by its content, not by its position.

Configure your endpoint in app.boleti.mx, under Integraciones → Webhooks, on the corresponding API connection. The URL must use https://. When the endpoint is created, your signing secret (starts with whsec_) is shown ONCE: store it in a secure location; if you lose it, regenerate it from the same panel. The Probar entrega (test delivery) button sends a test.ping event signed with your real secret so you can validate your integration. Every delivery arrives with these headers:

ParameterTypeRequiredDescription
Content-Typeheader · stringYesAlways `application/json`.
User-Agentheader · stringYesAlways `Boleti-Webhooks/1.0`.
X-Boleti-Eventheader · stringYesType of the delivered event (for example `boleto.emitido`). It matches the body's `type` field.
X-Boleti-Deliveryheader · UUIDYesUnique identifier of the delivery. It remains stable across retries of the same delivery — you may use it to trace the delivery in your logs.
X-Boleti-Signatureheader · stringYesHMAC signature of the body with the `t=<epoch>,v1=<hex>` scheme. Verify it BEFORE processing the event.

The X-Boleti-Signature header follows the t=<epoch in seconds>,v1=<hex> scheme: v1 is the HMAC-SHA256 of the string t + "." + raw body, computed with your signing secret. Recompute that signature over the exact bytes you received (do not re-serialize the JSON), compare it with v1 using a constant-time comparison, and reject deliveries whose t differs from your clock by more than 5 minutes — every retry is re-signed with a fresh t. Below, the header structure and a reference verification in Node.js:

json
{
  "header": "X-Boleti-Signature: t=1783707727,v1=83f1c5a97e2b4d6089ab3c7e5f1d2a4b6c8e0f9d7b5a3c1e2f4a6b8c0d9e7f31",
  "t": "Marca de tiempo Unix en segundos del intento de entrega. Cada reintento se firma de nuevo con un t fresco — tolere a lo más 5 minutos de diferencia contra su reloj.",
  "v1": "HMAC-SHA256 en hexadecimal de la cadena t + '.' + cuerpo crudo, calculado con su signing secret (whsec_…)."
}
js
const crypto = require("node:crypto");

// rawBody: los bytes EXACTOS del cuerpo del POST (string o Buffer).
// No re-serialice el JSON parseado — la firma es sobre los bytes crudos.
function verificarFirmaBoleti(secret, rawBody, signatureHeader) {
  const partes = new Map(
    signatureHeader.split(",").map((par) => par.split("=", 2)),
  );
  const t = Number(partes.get("t"));
  const v1 = partes.get("v1") || "";
  const ahoraSeg = Math.floor(Date.now() / 1000);
  // Tolerancia recomendada: 5 minutos (300 segundos).
  if (!Number.isInteger(t) || Math.abs(ahoraSeg - t) > 300) return false;
  const esperada = crypto
    .createHmac("sha256", secret)
    .update(t + "." + rawBody)
    .digest("hex");
  if (v1.length !== esperada.length) return false;
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperada));
}

Your endpoint must respond with a 2xx status in under 10 seconds; any other response, a redirect, or a timeout counts as a failed attempt. Boleti retries up to 8 total attempts with exponential backoff starting at 1 minute (the full window spans roughly 2 hours). Once a delivery exhausts its retries it is discarded — you can resend it manually from the panel. If 10 consecutive deliveries are exhausted, the endpoint is disabled automatically and you receive a notification inside app.boleti.mx; re-enable it from the same panel once your server recovers.

Best practices: respond 2xx immediately and process the event asynchronously; validate the signature BEFORE processing; deduplicate by the event id; tolerate new fields inside data (do not use strict parsers that reject unknown properties); and always reconcile against GET /boletos/:folio — the webhook notifies you, the query is the source of truth.

Webhook events

The v1 event catalog (boleto.emitido and boleto.cancelado), the common envelope every event shares, and an example payload for each one.

Every event travels with the same envelope: a unique event id (evt_ followed by 32 hexadecimal characters — the key you deduplicate by), the type, the apiVersion (v1), the createdAt in ISO 8601, and the data object with the detail. An issuance of N passengers generates N events, one per ticket:

json
{
  "id": "evt_4b8f1c6a2e9d7b3f5a0c8e2d6b4f9a1c",
  "type": "boleto.emitido",
  "apiVersion": "v1",
  "createdAt": "2026-07-10T18:22:05.318Z",
  "data": {
    "boleto": { "...": "..." }
  }
}

boleto.emitido is sent when a ticket is issued through your connection. data.boleto carries the ticket's public snapshot: folio, estado, estadoPago, canal, corridaId, fechaSalida, numeroAsiento, pasajeroNombre, origen and destino (each with taquillaId, codigo, nombre and orden), montoTotal (text with two decimals, never a number), moneda and anticipoExpiraAt:

json
{
  "id": "evt_9f2c4a6e8b0d1f3a5c7e9b2d4f6a8c0e",
  "type": "boleto.emitido",
  "apiVersion": "v1",
  "createdAt": "2026-07-10T18:22:05.318Z",
  "data": {
    "boleto": {
      "folio": "ESMER-2026-000123",
      "estado": "EMITIDO",
      "estadoPago": "COMPLETO",
      "canal": "API",
      "corridaId": "c3333333-3333-4333-8333-333333333333",
      "fechaSalida": "2026-07-15T13:30:00.000Z",
      "numeroAsiento": 7,
      "pasajeroNombre": "JUAN PEREZ",
      "origen": {
        "taquillaId": "a1111111-1111-4111-8111-111111111111",
        "codigo": "CDMX",
        "nombre": "CIUDAD DE MEXICO",
        "orden": 0
      },
      "destino": {
        "taquillaId": "b2222222-2222-4222-8222-222222222222",
        "codigo": "GDL",
        "nombre": "GUADALAJARA",
        "orden": 1
      },
      "montoTotal": "550.00",
      "moneda": "MXN",
      "anticipoExpiraAt": null
    }
  }
}

boleto.cancelado carries the same snapshot plus two fields: canceladoAt (when it was cancelled) and origenCancelacionEMPRESA if the company cancelled it (for example, a cashier at the ticket counter) or API if the cancellation came through your own connection. This event reaches you EVEN IF the cancellation did not go through the API: if the company cancels one of your tickets at its own counter, you learn about it immediately:

json
{
  "id": "evt_1d3f5a7c9e0b2d4f6a8c1e3b5d7f9a0c",
  "type": "boleto.cancelado",
  "apiVersion": "v1",
  "createdAt": "2026-07-12T16:05:11.902Z",
  "data": {
    "boleto": {
      "folio": "ESMER-2026-000123",
      "estado": "CANCELADO",
      "estadoPago": "COMPLETO",
      "canal": "API",
      "corridaId": "c3333333-3333-4333-8333-333333333333",
      "fechaSalida": "2026-07-15T13:30:00.000Z",
      "numeroAsiento": 7,
      "pasajeroNombre": "JUAN PEREZ",
      "origen": {
        "taquillaId": "a1111111-1111-4111-8111-111111111111",
        "codigo": "CDMX",
        "nombre": "CIUDAD DE MEXICO",
        "orden": 0
      },
      "destino": {
        "taquillaId": "b2222222-2222-4222-8222-222222222222",
        "codigo": "GDL",
        "nombre": "GUADALAJARA",
        "orden": 1
      },
      "montoTotal": "550.00",
      "moneda": "MXN",
      "anticipoExpiraAt": null,
      "canceladoAt": "2026-07-12T16:05:11.734Z",
      "origenCancelacion": "EMPRESA"
    }
  }
}

Compatibility: within v1, new fields may appear inside data — your integration must tolerate them without failing. Besides the business events there is test.ping, the synthetic event sent by the panel's Probar entrega (test delivery) button, signed with your real secret, so you can validate your signature verification end to end.