API pública

Documentación de la API

Conecte su página de venta o su agencia con Boleti: busque corridas, consulte asientos y emita boletos con una API REST sencilla. Publicamos cada sección conforme la vamos liberando.

En esta página

Introducción

La API pública de Boleti le permite vender los boletos de una empresa de transporte desde fuera de Boleti: desde su propia página de venta o desde una agencia integradora que revende a su nombre.

Es una API REST con JSON. Todas las rutas de esta documentación parten de la misma URL base:

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"

Cuando algo sale mal, la respuesta siempre trae el mismo formato: un statusCode HTTP, un code estable para que su código lo distinga, y un message en español pensado para mostrarse tal cual:

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."
}

Los montos de dinero siempre viajan como texto con dos decimales (por ejemplo "150.00"), nunca como número. Así evitamos errores de redondeo entre lenguajes:

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

Si usted es una integradora: además de las consultas con GET, puede configurar Webhooks para enterarse en tiempo real de las emisiones y cancelaciones de sus boletos. Para conciliar, la fuente de verdad sigue siendo la consulta con GET.

Todas las peticiones POST aceptan el header Idempotency-Key con un UUID v4. Si reintenta la misma petición con la misma llave, no se duplica nada: ni bloqueos ni boletos.

Autenticación

Cada petición se firma con dos headers: su llave pública y su secreto. Ambos se generan al crear una conexión API en app.boleti.mx.

Ingrese a app.boleti.mx y cree una conexión API para su empresa. Ahí obtendrá su llave (empieza con bk_live_) y su secreto (empieza con sk_live_).

Importante: el secreto se muestra una sola vez, justo al crear la conexión en app.boleti.mx. Consérvelo en un lugar seguro; si lo pierde, tendrá que regenerarlo. Nunca lo coloque en el navegador ni en código que viaje al cliente — solo debe residir en su servidor.

ParámetroTipoObligatorioDescripción
X-Api-Keyheader · stringSu llave pública. Identifica a la empresa y al tipo de conexión.
X-Api-Secretheader · stringSu secreto. Solo debe residir en su servidor, nunca en el navegador.
Idempotency-Keyheader · UUID v4En POSTsEnvíe un UUID v4 nuevo por operación; si reintenta la misma operación, repita el mismo.

Para comprobar que sus llaves funcionan, realice una petición a 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"

Si todo está bien, responde 200 con los datos de su conexión:

json
{
  "empresa": "Esmeralda Travel",
  "tipo": "INTEGRADORA",
  "rateLimit": 300
}
Código HTTPCódigo de errorDescripción
401API_CREDENCIALES_INVALIDASLa llave no existe o el secreto no coincide.
403PLAN_FEATURE_NOT_INCLUDEDLa conexión está desactivada o el plan de la empresa no incluye acceso a la API.
429API_RATE_LIMIT_EXCEDIDOSuperó su límite de peticiones por minuto. Espere el tiempo que indique el header Retry-After y reintente.

Errores

Catálogo completo de los códigos de error de la API, con su statusCode, su code estable y ejemplos de respuesta para que su integración los maneje sin adivinar.

Cuando algo sale mal, la respuesta HTTP trae SIEMPRE el mismo formato: un statusCode (el código HTTP), un code estable en MAYÚSCULAS para que su programa distinga el caso, y un message en español (redactado en tono coloquial, dirigido al pasajero), listo para mostrarse al usuario tal cual. Ramifique su lógica por code, nunca por el texto de message (el texto puede cambiar; el code no):

json
{
  "statusCode": 409,
  "code": "PRECIO_CAMBIADO",
  "message": "El precio cambió desde que lo consultó. Revise el precio vigente y vuelva a intentar."
}

Aplique una regla simple: si el statusCode es 401 o 403, revise sus credenciales o su tipo de conexión; si es 404, el recurso no existe o no pertenece a su empresa; si es 409, hubo una condición de carrera (otro cliente completó la compra antes o el precio cambió) y debe volver a consultar; si es 422, envió algo que su tipo de conexión no puede hacer; y si es 429, la frecuencia de peticiones excedió el límite (ver Límites de peticiones).

Estos son los code que puede devolver la API pública. Recuerde que los montos SIEMPRE viajan como texto con dos decimales ("150.00"), nunca como número — eso también aplica a cualquier message que mencione dinero:

Código HTTPCódigo de errorDescripción
401API_CREDENCIALES_INVALIDASSu llave o su secreto no son válidos, o la conexión está desactivada. Verifique que los copió completos.
403API_CREDENCIAL_TIPO_NO_PERMITIDOSu tipo de conexión no puede usar este endpoint (por ejemplo, una integradora intentando un endpoint exclusivo de venta web, o una conexión de venta web intentando uno exclusivo de integradora).
403VENTA_WEB_EMISION_DESACTIVADALa emisión por el sitio web está desactivada para esta empresa. El administrador debe activarla en Ajustes → Ventas.
404CLIENTE_FRECUENTE_NO_ENCONTRADONo hay una tarjeta de cliente frecuente con ese número (o no pertenece a su empresa). Mismo mensaje para toda tarjeta que no resuelve, por seguridad.
404BOLETO_NO_ENCONTRADONo hay un boleto con ese folio (o no pertenece a su empresa). Mismo mensaje para todo folio que no resuelve.
409RESERVATION_RACEOtro consumidor apartó o vendió alguno de esos asientos primero. Vuelva a consultar el mapa de asientos y elija otros.
409PRECIO_CAMBIADOEl `precioEsperado` que envió ya no coincide con el precio vigente. Vuelva a consultar `/corridas/:id/asientos` y reintente con el nuevo precio.
422CORTE_SOLO_INTEGRADORAEsa operación de corte es solo para conexiones de integradora. Una conexión de venta web no puede realizarla.
422TARIFA_TIER_INACTIVOEl tipo de pasajero (`tarifaTierId`) que envió ya no está activo para esta corrida. Vuelva a consultar los tipos de pasajero y utilice uno vigente.
422CF_SOLO_VENTA_WEBEl `clienteFrecuenteId` solo aplica a la venta web propia de la empresa, no a integradoras. Retire ese campo.
429API_RATE_LIMIT_EXCEDIDOSuperó su límite de peticiones por minuto. Espere el tiempo que indique el header `Retry-After` y reintente.

Un mismo statusCode puede traer distintos code (por ejemplo 403 puede ser tipo de conexión no permitido o venta web desactivada). Por eso siempre debe ramificar por code: es la única llave estable para su integración.

Límites de peticiones

Cuántas peticiones por minuto acepta cada conexión, cómo leer el header Retry-After cuando recibe un 429 y buenas prácticas de polling para integradoras.

Cada conexión tiene un límite de peticiones por minuto. El default es 300 por minuto por conexión (puede verlo en su ping, en el campo rateLimit). Es por conexión, no por IP: si reparte su tráfico entre varios servidores con la misma llave, todos suman al mismo cupo.

Cuando lo supera, la API responde 429 con el code API_RATE_LIMIT_EXCEDIDO. No es un error de su integración: la frecuencia de peticiones excedió el límite. Reduzca la frecuencia de peticiones y reintente.

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."
}

Toda respuesta 429 trae el header Retry-After con los segundos que debe esperar antes de reintentar. Respételo: si reintenta antes, seguirá recibiendo 429 y solo consumirá cupo adicional. Lo ideal es un backoff (espere Retry-After y, si el 429 se repite, aumente progresivamente el intervalo):

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

Si usted es una integradora y consulta disponibilidad por polling (por ejemplo /corridas/:id/asientos), utilice un intervalo de al menos 5 segundos por corrida. Un intervalo menor desperdicia su cupo sin entregarle datos más frescos: la disponibilidad se cachea unos segundos del lado del servidor.

El lookup de clientes frecuentes (/clientes-frecuentes/:numeroTarjeta) tiene además un límite estricto propio, más bajo que el general, para proteger los datos personales. Si realiza muchas búsquedas consecutivas recibirá 429 con code CF_LOOKUP_RATE_LIMIT aunque no haya alcanzado su límite general — aumente el intervalo entre consultas.

Idempotencia

Cómo usar el header Idempotency-Key para reintentar peticiones POST con seguridad, sin duplicar bloqueos de asientos ni boletos emitidos.

Toda petición POST de la API acepta el header Idempotency-Key con un UUID v4. Es su protección ante reintentos: si la conexión se interrumpe y no sabe si el POST se completó, reintente con LA MISMA llave y recibirá exactamente la misma respuesta que la primera vez — sin apartar dos veces, sin emitir dos veces, sin cancelar dos veces.

Aplica a TODOS los POST: bloquear, extender y liberar asientos, emitir boletos y cancelar. Envíe una llave nueva por cada operación de negocio distinta; reserve el reintento con la misma llave únicamente para reintentar ESA misma operación:

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"
  }'

La clave está en generar el UUID v4 una sola vez, ANTES del primer intento, y guardarlo junto a la operación. Todos los reintentos de esa operación (por timeout, error de red, 5xx) usan ese mismo UUID. Para una operación nueva, genere un UUID nuevo.

ParámetroTipoObligatorioDescripción
Idempotency-Keyheader · UUID v4UUID v4 que identifica la operación de negocio. Genérelo una vez por operación y repítalo en cada reintento de ESA operación.

Una llave queda ligada al cuerpo del primer POST con el que se usó. Si reintenta la misma llave con un cuerpo distinto, se trata de un error de su integración (se mezclaron dos operaciones): genere una llave nueva para cada operación de negocio.

La idempotencia se recuerda por un tiempo acotado (del orden de horas), suficiente para cubrir reintentos y cortes de red. No la utilice como historial permanente: para consultar el estado real de un apartado o un boleto utilice los endpoints GET.

Precios y tipos de pasajero

GET/catalogo/tipos-pasajero

Cada empresa define sus tipos de pasajero (adulto, estudiante, adulto mayor…). Aquí verá cómo leer las tarifas por tipo, qué tipos requieren credencial al abordar y cómo proteger su venta con precioEsperado.

Devuelve la lista de tipos de pasajero activos de la empresa. El orden es el canónico: primero el ADT (Adulto) y luego el resto por código. No incluye porcentajes de descuento a propósito — el precio SIEMPRE lo computa el servidor por corrida y tramo (puede verlo en /corridas y en /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"

Cada tipo incluye su id (el tarifaTierId que usará al emitir), su codigo, su nombre, si requiereCredencial al abordar (por ejemplo estudiante o INAPAM), y el rango de edad opcional 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
    }
  ]
}

Utilice id como el tarifaTierId de cada pasajero al emitir. El nombre es el texto que se muestra en su selector, y requiereCredencial le indica que ese pasajero deberá presentar una identificación al abordar.

El precio del tipo ADT (Adulto) para el tramo CIUDAD DE MEXICO → GUADALAJARA de este ejemplo es $500.00. Nunca calcule el precio por su cuenta: solicítelo con /corridas o /corridas/:id/asientos, que ya incluye tarifasPorTier por asiento y tramo.

Cómo renderizar el croquis

El plano del autobús viaja como un snapshot auto-descriptivo: pisos, filas, tipos de celda (asiento, pasillo, baño, escaleras…), zonas con color y regla de numeración para que renderice el mapa de asientos tal cual es.

El endpoint /corridas/:id/asientos trae dos cosas juntas: el croquis (el plano del autobús como snapshot inmutable del momento en que se creó la corrida) y asientos[] (el estado y el precio de cada asiento para el tramo consultado). Además viaja una leyenda: un objeto que explica el propio JSON para que pueda renderizar el mapa sin leer código de Boleti.

Así luce la leyenda (resumida). Cada piso es una cuadrícula {rows, cols, cells[][]} donde cells[fila][columna] es una celda tipada por type; la fila 0 es el FRENTE (chofer) y la última la parte TRASERA:

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."
  }
}

Para renderizar el mapa: recorra cada piso como una cuadrícula, dibuje solo las celdas con significado y, para cada celda type: "seat", cruce su number con el arreglo asientos[] de la misma respuesta — de ahí obtiene el estado (DISPONIBLE/RESERVADO/VENDIDO) y las tarifasPorTier. La versión del schema viaja en croquisSchemaVersion (hoy 1); nunca cambia de forma incompatible dentro de /public/v1.

Catálogo de orígenes y destinos

GET/catalogo/od-pairs

Las terminales de la empresa y qué destinos puede vender desde cada origen, listos para llenar sus selectores de búsqueda.

Devuelve el grafo completo de orígenes y destinos vendibles en un solo request: la forma más rápida de armar sus dos selectores de búsqueda. También existen /catalogo/origenes y /catalogo/destinos?origenTaquillaId= si prefiere cargarlos por separado.

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"

La respuesta trae origenes (solo terminales con al menos un destino vendible) y destinosPorOrigen (un mapa id de origen → [ids de destino]). En este ejemplo, desde CIUDAD DE MEXICO puede vender a 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"]
  }
}

Cada terminal trae id, clave, nombre y datos de ubicación opcionales (direccion, geolocation, telefono, gmapsUrl). Utilice el nombre para mostrar y el id para las llamadas a /corridas.

Con el id del origen y el del destino que elija el usuario, ya puede buscar salidas en /corridas con ese origenTaquillaId y destinoTaquillaId.

Buscar corridas

GET/corridas

Busque las salidas disponibles por fecha, origen y destino, con horarios, asientos libres y tarifas por tipo de pasajero.

Busca las salidas de un día para un tramo. El par origen-destino es obligatorio: sin él no habría precios por tipo de pasajero, y la respuesta pública SIEMPRE trae tarifasPorTier. No se envía canal: el servidor lo fuerza según su tipo de conexión (venta web o integradora).

ParámetroTipoObligatorioDescripción
fechaquery · YYYY-MM-DDDía de salida en formato `YYYY-MM-DD` (por ejemplo `2026-07-15`).
origenTaquillaIdquery · UUID`id` de la terminal de origen, tomado de `/catalogo/od-pairs`.
destinoTaquillaIdquery · UUID`id` de la terminal de destino alcanzable desde ese origen.
limitquery · number (1-50)NoMáximo de corridas a devolver (1 a 50). Si lo omite, se aplica el default del servidor.
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"

La respuesta trae corridas[] con horarios, asientosDisponibles, las paradas del tramo y las tarifasPorTier del par consultado. Los montos viajan como texto con 2 decimales:

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" }
        }
      ]
    }
  ]
}

Conserve el id de la corrida que elija el usuario: lo necesitará para consultar el mapa de asientos en /corridas/:id/asientos y para bloquear.

Asientos de una corrida

GET/corridas/:id/asientos

Consulte el croquis y el estado de cada asiento (libre, ocupado o apartado) con su precio por tipo de pasajero — cada zona del autobús puede tener un precio distinto.

Devuelve el croquis del autobús + el estado y precio de cada asiento para el tramo consultado. El par origen-destino es obligatorio (el estado y el precio dependen del tramo). Consúltelo periódicamente (polling): la disponibilidad cambia conforme otros venden.

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"

El estado público de cada asiento colapsa en tres valores: DISPONIBLE, RESERVADO (alguien tiene un apartado temporal que solapa el tramo) o VENDIDO. Nunca verá el token de un apartado ajeno — usted rastrea sus apartados con el token que recibe al bloquear:

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
}

Cruce cada asiento del croquis con asientos[] por su numero. Para vender, tome el tarifaTierId del tipo de pasajero de tarifasPorTier y su precioFinal (aquí, ADT = $500.00).

La disponibilidad se cachea unos segundos para soportar el polling. La validación definitiva ocurre en el bloqueo: si dos consumidores piden el mismo asiento, el segundo recibe un 409 de conflicto.

Bloquear asientos

POST/asientos/bloquear

Aparte asientos por unos minutos mientras el pasajero termina su compra. También podrá extender el apartado o liberarlo si el pasajero desiste.

Aparte hasta 20 asientos por unos minutos mientras el pasajero termina. Envíe un Idempotency-Key (UUID v4) por operación: si reintenta la misma llamada con la misma llave, no se duplica el apartado. Opcionalmente envíe el header X-Session-Ref con una referencia opaca de la sesión del comprador para trazarla.

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"
  }'

La respuesta trae el reservationToken (consérvelo: es su llave del apartado y la usará al emitir), la lista de numeros apartados, el expiresAt (cuándo vence) y el ttlMinutes:

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

Mientras el pasajero termina, puede renovar el apartado con /asientos/extender, o liberarlo con /asientos/liberar (sin numeros se libera el token completo; con numeros se liberan solo esos asientos). Ambas requieren el mismo reservationToken y su propio 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" }'

Errores que debe manejar:

Código HTTPCódigo de errorDescripción
404RESERVATION_NOT_FOUNDEl apartado no existe, ya venció o no pertenece a su conexión. Vuelva a consultar los asientos y bloquéelos de nuevo.
409SEAT_CONFLICTOtro consumidor apartó o vendió alguno de esos asientos primero. Vuelva a consultar el mapa y elija otros.

Emitir boletos

POST/boletos/emitir

Emita los boletos de una reserva: datos de los pasajeros, tipo de pasajero por asiento, precio protegido contra cambios y PDFs listos para entregar.

Este endpoint convierte un apartado en boletos. Envíe el reservationToken que recibió al bloquear y un pasajero por asiento apartado. El precio REAL lo pone SIEMPRE el servidor; precioEsperado es opcional y solo sirve para detectar que su lista de precios quedó desactualizada (si difiere, recibirá 409 PRECIO_CAMBIADO).

Si su conexión es de integradora, cada boleto se emite pagado (estadoPago: COMPLETO) — usted cobra al pasajero y se liquida en su corte. El campo clienteFrecuenteId no aplica a integradoras (recibirá 422). Envíe un Idempotency-Key (UUID v4) para no duplicar boletos si reintenta.

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"
      }
    ]
  }'

La respuesta trae un boleto por asiento con su folio, estado, estadoPago, montoTotal, el pdf (URLs firmadas del boleto en color y en 80mm) y el resultado del envío por whatsapp:

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"
    }
  ]
}

En este ejemplo el folio es ESMER-2026-000001 y el monto $500.00. Los PDFs son URLs firmadas temporales — vuelva a consultarlas con /boletos/:folio cuando las necesite de nuevo.

Errores que debe manejar:

Código HTTPCódigo de errorDescripción
409PRECIO_CAMBIADOEl `precioEsperado` que envió ya no coincide con el precio vigente. Vuelva a consultar `/corridas/:id/asientos` y reintente con el nuevo precio.
422CF_SOLO_VENTA_WEBEl `clienteFrecuenteId` solo aplica a la venta web propia de la empresa, no a integradoras. Retire ese campo.
403VENTA_WEB_EMISION_DESACTIVADALa emisión por el sitio web está desactivada para esta empresa. El administrador debe activarla en Ajustes → Ventas.

Consultar un boleto

GET/boletos/:folio

Consulte un boleto por su folio y vuelva a obtener sus PDFs cuando los necesite.

Consulta el estado actual de un boleto por su folio y devuelve de nuevo sus PDFs (las URLs firmadas caducan; este endpoint le entrega unas nuevas). Un folio de otra empresa responde 404 — igual que uno inexistente.

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"

La respuesta trae el estado del boleto, el montoTotal, el canal por el que se vendió y el pdf con URLs firmadas re-generables:

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=..."
  }
}

Como integradora, utilice este endpoint para conciliar: es la fuente de verdad del estado de cada boleto que emitió. Los Webhooks le avisan en tiempo real cuando un boleto suyo se emite o se cancela; aun así, cierre siempre su conciliación contra este GET.

Clientes frecuentes

GET/clientes-frecuentes/:numeroTarjeta

Busque a un cliente frecuente por su número de tarjeta para llenar sus datos en la venta web. Disponible solo para conexiones de venta web.

Busque a un cliente frecuente por su número de tarjeta (16 dígitos) para autocompletar sus datos en el checkout. Solo para conexiones de venta web — una conexión de integradora recibe 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"

La respuesta trae el clienteFrecuenteId (que luego enviará por pasajero al emitir), el nombre en mayúsculas y el telefono en formato internacional. Esos datos son los que la empresa dio de alta: no los edite, el servidor los sustituye al emitir con cliente frecuente:

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

Este lookup tiene un límite estricto de consultas por minuto para proteger los datos. Una tarjeta inválida, inexistente o de otra empresa devuelve siempre el mismo 404 (no es posible deducir qué tarjetas existen).

Código HTTPCódigo de errorDescripción
404CLIENTE_FRECUENTE_NO_ENCONTRADONo hay una tarjeta con ese número (o no pertenece a su empresa). Mismo mensaje para toda tarjeta que no resuelve, por seguridad.
429CF_LOOKUP_RATE_LIMITDemasiadas consultas de tarjetas consecutivas. Espere el tiempo que indique el header Retry-After e intente de nuevo.

Cancelación (integradoras)

POST/boletos/:folio/cancelar

Cancele un boleto vendido por su agencia: queda el registro completo de la venta y la cancelación, y el boleto se excluye del corte que se le cobra.

Cancele un boleto que su agencia emitió. Solo para conexiones de integradora — una conexión de venta web recibe 403. La cancelación deja el registro completo de la venta y de la cancelación; no borra nada.

No hay reembolso automático por la API: usted resuelve el reembolso con su pasajero según su propio proceso. Del lado de Boleti, el boleto cancelado simplemente se excluye del corte contable del periodo que se le cobra. Envíe un Idempotency-Key (UUID v4) para que un reintento no falle si el boleto ya quedó cancelado.

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"

La respuesta confirma el folio, el nuevo estado (CANCELADO) y la marca de tiempo canceladoAt:

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

Para enterarse de una cancelación en tiempo real — incluso cuando la realiza la empresa en su taquilla — configure Webhooks: recibirá el evento boleto.cancelado. Para conciliar, la fuente de verdad sigue siendo la consulta: el corte que se le cobra ya trae el efecto de las cancelaciones del periodo, y puede consultar cualquier boleto en /boletos/:folio.

Errores que debe manejar:

Código HTTPCódigo de errorDescripción
403CANCELACION_SOLO_INTEGRADORALa cancelación por la API es solo para conexiones de integradora. Una conexión de venta web no puede cancelar boletos por esta vía.
404BOLETO_NO_ENCONTRADONo hay un boleto con ese folio (o no pertenece a su empresa). Mismo mensaje para todo folio que no resuelve.
409BOLETO_YA_CANCELADOEse boleto ya estaba cancelado. La operación es idempotente: no registra la cancelación de nuevo.

Webhooks

Reciba avisos en tiempo real cuando un boleto emitido con su conexión cambie de estado: Boleti envía una petición POST firmada a la URL que usted configure, sin necesidad de polling.

Un webhook es una petición POST con JSON que Boleti envía a su servidor cuando ocurre un evento de un boleto emitido con su conexión (una emisión o una cancelación). Cada conexión API admite un endpoint receptor. La entrega es al menos una vez (at-least-once): en casos excepcionales puede recibir un evento duplicado — deduplique por el campo id del evento. El orden de llegada NO está garantizado: procese cada evento por su contenido, no por su posición.

Configure su endpoint en app.boleti.mx, en Integraciones → Webhooks, sobre la conexión API correspondiente. La URL debe usar https://. Al crear el endpoint se le muestra su signing secret (inicia con whsec_) UNA sola vez: consérvelo en un lugar seguro; si lo pierde, regenérelo desde el mismo panel. El botón Probar entrega envía un evento test.ping firmado con su secret real para que valide su integración. Cada entrega llega con estos headers:

ParámetroTipoObligatorioDescripción
Content-Typeheader · stringSiempre `application/json`.
User-Agentheader · stringSiempre `Boleti-Webhooks/1.0`.
X-Boleti-Eventheader · stringTipo del evento entregado (por ejemplo `boleto.emitido`). Coincide con el campo `type` del cuerpo.
X-Boleti-Deliveryheader · UUIDIdentificador único de la entrega. Se mantiene estable entre los reintentos de una misma entrega — puede usarlo para rastrearla en sus registros.
X-Boleti-Signatureheader · stringFirma HMAC del cuerpo con el esquema `t=<epoch>,v1=<hex>`. Verifíquela ANTES de procesar el evento.

El header X-Boleti-Signature sigue el esquema t=<epoch en segundos>,v1=<hex>: v1 es el HMAC-SHA256 de la cadena t + "." + cuerpo crudo, calculado con su signing secret. Recompute esa firma sobre los bytes exactos que recibió (no re-serialice el JSON), compárela con v1 mediante una comparación de tiempo constante y rechace entregas cuyo t difiera más de 5 minutos de su reloj — cada reintento se firma de nuevo con un t fresco. A continuación, la estructura del header y una verificación de referencia en 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));
}

Su endpoint debe responder un código 2xx en menos de 10 segundos; cualquier otra respuesta, una redirección o un timeout cuentan como intento fallido. Boleti reintenta hasta 8 veces en total, con espera exponencial a partir de 1 minuto (la ventana completa dura alrededor de 2 horas). Si una entrega agota sus reintentos, se descarta — puede reenviarla manualmente desde el panel. Si 10 entregas consecutivas se agotan, el endpoint se desactiva automáticamente y usted recibe una notificación dentro de app.boleti.mx; reactívelo desde el mismo panel cuando su servidor se recupere.

Buenas prácticas: responda 2xx de inmediato y procese el evento de forma asíncrona; valide la firma ANTES de procesar; deduplique por el id del evento; tolere campos nuevos dentro de data (no use parsers estrictos que rechacen propiedades desconocidas); y concilie siempre contra GET /boletos/:folio — el webhook le avisa, la consulta es la fuente de verdad.

Eventos de webhook

El catálogo de eventos v1 (boleto.emitido y boleto.cancelado), el envelope común que comparten todos y el payload de ejemplo de cada uno.

Todo evento viaja con el mismo envelope: un id único del evento (evt_ seguido de 32 caracteres hexadecimales — la llave con la que usted deduplica), el type, el apiVersion (v1), el createdAt en ISO 8601 y el objeto data con el detalle. Una emisión de N pasajeros genera N eventos, uno por boleto:

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

boleto.emitido se envía cuando se emite un boleto con su conexión. data.boleto trae el snapshot público del boleto: folio, estado, estadoPago, canal, corridaId, fechaSalida, numeroAsiento, pasajeroNombre, origen y destino (cada uno con taquillaId, codigo, nombre y orden), montoTotal (texto con dos decimales, nunca número), moneda y 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 trae el mismo snapshot más dos campos: canceladoAt (cuándo se canceló) y origenCancelacionEMPRESA si canceló la empresa (por ejemplo, un cajero en taquilla) o API si la cancelación entró por su propia conexión. Este evento le llega AUNQUE la cancelación no haya pasado por la API: si la empresa cancela en su taquilla un boleto emitido por usted, usted se entera de inmediato:

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"
    }
  }
}

Compatibilidad: dentro de v1 pueden aparecer campos nuevos en data — su integración debe tolerarlos sin fallar. Además de los eventos de negocio existe test.ping, el evento sintético que envía el botón Probar entrega del panel, firmado con su secret real, para que valide su verificación de firma de extremo a extremo.