API de check-in y ejemplos
Las rutas están disponibles únicamente con CHECKIN_ENABLED=true. Comparten el origen de la API de firmas y usan el prefijo /v1/checkin/sites/{site}. openapi.yaml en la raíz del repositorio es el contrato HTTP canónico; docs/API_CONTRACT.md explica el contrato funcional. Esta guía muestra cómo consumirlo. Con SWAGGER_ENABLED=true, abre /swagger en el origen de tu API y busca el grupo Check-in; el JSON generado está en /swagger/v1/swagger.json.
Acceso y aislamiento
| Operación | Credencial |
|---|---|
| Contexto y registro de recepción | Pública, establecimiento autorizado |
| Contexto y registro de reserva | Token privado de la invitación, en el cuerpo JSON |
| Provisión de reservas y consulta/reintento de entregas | Cabecera X-Checkin-Admin-Token |
| Entrega del servicio a BC | OAuth de Entra configurado exclusivamente en backend |
BC_BEARER_TOKEN y X-Device-Token pertenecen a las firmas; no habilitan estas operaciones administrativas. El token administrativo de check-in debe tener al menos 32 caracteres y se compara en tiempo constante. Es común a los sitios de esta instalación: no representa un permiso distinto por recepcionista o establecimiento. Protégelo y no lo incluyas en la web.
site debe figurar en CHECKIN_SITE_SLUGS. Admite letras ASCII minúsculas, dígitos y guiones; empieza por letra/dígito y tiene hasta 80 caracteres. Se distingue entre mayúsculas y minúsculas. Consultas, referencias e idempotencia están acotadas al sitio; el token de una reserva de otro sitio no sirve. No existe alta pública de establecimientos.
Se usa JSON con nombres camelCase. Las fechas civiles son YYYY-MM-DD; los instantes incluyen zona horaria y se devuelven en UTC. Cada cuerpo está limitado a 16 KiB. Las respuestas, incluidos errores, deshabilitan caché. El límite es 60 peticiones por minuto e IP, compartido entre las rutas de check-in de cada proceso API; no es un limitador distribuido.
Rutas
Todas las rutas siguientes son relativas a /v1/checkin/sites/{site}:
| Método y ruta | Entrada | Éxito |
|---|---|---|
GET /reception |
Sin cuerpo | 200 {site,referenceDate} |
POST /reservation-context |
{token} |
200 {reservationReference,capacity,registered,remaining,expiresAt,referenceDate} |
POST /guests |
{mode,token?,idempotencyKey,guest} |
200 {receiptId,remaining,capacity} |
POST /reservations |
Admin + {reservationReference,capacity,expiresAt} |
201 {reservationId,token,reservationReference,capacity,expiresAt} |
GET /deliveries |
Admin | 200, array de hasta 100 metadatos |
GET /deliveries/{receiptId} |
Admin | 200, metadatos de un recibo |
POST /deliveries/{receiptId}/retry |
Admin; sin cuerpo | 204, reintento/revisión programado |
Los metadatos de entrega y sus estados se explican en Operación. No existe listado público de huéspedes ni descarga pública del contenido de un recibo.
Provisionar una reserva
La integración BC llama a POST /reservations después de crear su reserva de negocio:
POST /v1/checkin/sites/las-torres/reservations
Content-Type: application/json
X-Checkin-Admin-Token: <secreto-administrativo>
{
"reservationReference": "BC-STABLE-BOOKING-KEY",
"capacity": 2,
"expiresAt": "2027-01-31T12:00:00Z"
}
La referencia tiene 1–100 caracteres y debe ser única y estable dentro del establecimiento. Evita números reutilizables entre años: usa una identidad estable de BC. capacity es un entero de 1 a 1000 y expiresAt debe estar en el futuro, como máximo dos años después de la solicitud. La fecha del ejemplo debe ajustarse al momento de uso.
El 201 devuelve el ID de reserva, los tres campos anteriores y un token URL-safe de 43 caracteres. Se generan 32 bytes aleatorios y solo se conserva su hash SHA-256. La respuesta es la única oportunidad de obtener ese token. Guarda respuesta/token en un lugar protegido del lado BC antes de enviar la invitación.
Una segunda llamada con la misma referencia devuelve 409 reservation_exists; no devuelve el token anterior. Esta provisión no usa la idempotencia de /guests. Si se pierde su respuesta, no cambies la referencia para volver a probar: crearías otro cupo. La recuperación de token, revocación, rotación, actualización de plazas y cancelación no tienen endpoint implementado. Deben resolverse antes de automatizar ese ciclo en producción.
Consultar el contexto
Recepción usa GET /reception y recibe el slug y la fecha de negocio del establecimiento. Para reserva envía el token por POST:
{"token":"<token-opaco-devuelto-al-provisionar>"}
Respuesta ilustrativa de /reservation-context:
{
"reservationReference": "BC-STABLE-BOOKING-KEY",
"capacity": 2,
"registered": 0,
"remaining": 2,
"expiresAt": "2027-01-31T12:00:00Z",
"referenceDate": "2026-10-23"
}
referenceDate se calcula con CHECKIN_TIME_ZONE y cambia con el día del establecimiento. No es una fecha que deba copiarse fija a un cliente. El contexto no devuelve datos de otros huéspedes ni reserva una plaza mientras se rellena el formulario.
Registrar un huésped
Ejemplo completo y ficticio de POST /guests:
{
"mode": "reservation",
"token": "<token-opaco-devuelto-al-provisionar>",
"idempotencyKey": "ae7cb5d4-8efb-47ee-a6b2-e342969bcb15",
"guest": {
"givenName": "Ana",
"surname1": "García",
"surname2": "",
"birthDate": "1990-05-17",
"holderRelationship": "",
"sex": "female",
"nationality": "ES",
"documentType": "dni",
"documentCountry": "ES",
"documentNumber": "12345678Z",
"documentSupportNumber": "",
"documentIssueDate": null,
"documentExpiryDate": null,
"address": "Calle Mar 1",
"city": "Mazarrón",
"postalCode": "30860",
"province": "Murcia",
"country": "ES",
"phone": "+34 612 345 678",
"email": "[email protected]"
}
}
Para recepción usa mode: "reception" y omite token o envíalo como null. Un token no vacío en recepción devuelve 400 invalid_guest. Cada nuevo huésped necesita un UUID no nulo propio. El UUID anterior es ilustrativo: no lo reutilices como constante de integración.
Respuesta 200 de una reserva de dos plazas tras el primer registro:
{"receiptId":"b2fe0703-9e92-4fe8-baf4-e6542b6c2311","remaining":1,"capacity":2}
En recepción remaining y capacity son null. El recibo identifica el envío guardado en el servicio; la creación del huésped y de su entrada de outbox se confirma en la misma transacción. No espera la importación a BC para responder.
Datos del huésped
Los textos se recortan y normalizan a Unicode NFC. Los límites se aplican al contenido normalizado; no se admiten caracteres de control en los campos de texto validados.
| Campo | Obligatorio | Formato y límite |
|---|---|---|
givenName, surname1 |
Sí | 1–100 caracteres cada uno |
surname2 |
No | Hasta 100 caracteres |
birthDate |
Sí | Fecha civil válida, no futura |
holderRelationship |
Menor de 14 | Hasta 100 caracteres; no vacío cuando es obligatorio |
sex |
Sí | male, female, unspecified |
nationality |
Sí | País/nacionalidad del huésped, ISO 3166-1 alpha-2 |
documentType |
Sí | dni, passport, nie |
documentCountry |
Sí | País emisor, ISO alpha-2 |
documentNumber |
Sí | 3–40 letras ASCII/dígitos; se eliminan espacios/guiones y se convierte a mayúsculas |
documentSupportNumber |
No | Hasta 40 caracteres |
documentIssueDate |
No | Fecha ISO o null; desde nacimiento hasta referenceDate |
documentExpiryDate |
No | Fecha ISO o null; no anterior a la expedición si se informa |
address |
Sí | 1–250 caracteres |
city, province |
Sí | 1–100 caracteres cada uno, admiten direcciones internacionales |
postalCode |
Sí | 1–20 caracteres, admite letras y espacios |
country |
Sí | País de residencia, ISO alpha-2 |
phone |
Sí | Hasta 40 caracteres; 7–15 dígitos ASCII, + inicial opcional y puntuación habitual |
email |
Sí | Hasta 254 caracteres; dirección válida sin nombre de presentación |
Los tres países pueden ser diferentes. Las fechas opcionales vacías se representan con null, no con "". La API no recibe edad calculada ni el prefijo telefónico como campos separados.
El umbral de menor de 14 es birthDate > referenceDate.AddYears(-14), incluido el ajuste de años bisiestos. El servidor obtiene su fecha de negocio en cada envío. La relación se exige tanto con reserva como en recepción. No se ha inventado un catálogo legal de parentescos ni una excepción para menores sin documento.
DNI con emisor ES y NIE validan su letra de control. NIE exige emisor ES; DNI con otro país representa un documento nacional extranjero con validación alfanumérica conservadora. Números con forma de DNI/NIE español tampoco pueden eludir la letra cambiando el tipo. No se verifica autenticidad ni se implementan controles específicos para todos los pasaportes extranjeros. Un documento caducado no se rechaza solo por estar caducado.
Idempotencia, cupo y concurrencia
La clave es idempotencyKey dentro del JSON, no la cabecera usada por PAdES. Guarda juntos sitio, modo, token, datos normalizados y UUID para reintentar el mismo envío.
- Misma clave y mismo contenido normalizado: devuelve el recibo original, incluso cuando la reserva ya esté llena o haya caducado. Los contadores del recibo son los del éxito original; consulta contexto si necesitas disponibilidad actual.
- Misma clave y contenido distinto:
409 idempotency_conflict. No generes otra clave automáticamente para superar el conflicto. - Mismo documento en una reserva con una clave distinta:
409 duplicate_document. La identidad es país emisor + número canónico, independiente del tipo elegido. - Recepción no deduplica por documento ni tiene cupo de reserva. Dos claves distintas representan dos registros distintos; concilia resultados inciertos antes de crear otra operación.
PostgreSQL bloquea la fila de reserva con FOR UPDATE durante comprobación e inserción. Las restricciones únicas de sitio/clave y sitio/reserva/documento refuerzan esa protección. La relación compuesta incluye el sitio y evita enlazar una persona a una reserva de otro establecimiento. El navegador no garantiza estas propiedades por sí solo.
Errores y recuperación
Los errores de aplicación de check-in usan {code,message?}. No exigen traceId en el cuerpo como los errores antiguos de firmas; la cabecera compartida X-Trace-Id sirve para diagnóstico. No muestres mensajes arbitrarios de servidor al huésped ni registres cuerpos con datos personales o tokens.
| HTTP | Código / caso | Acción |
|---|---|---|
| 400 | invalid_guest |
Revisar campos, modo, UUID y, en provisión, referencia/cupo/caducidad |
| 400 | JSON o fechas/UUID mal formados | Error de enlace del framework; el cuerpo puede no seguir el sobre de check-in |
| 401 | checkin_unavailable |
Falta o no coincide el secreto administrativo; comprobar también longitud configurada |
| 404 | unknown_site |
Revisar slug exacto y allowlist |
| 404 | invalid_token |
Revisar invitación original y establecimiento; no cambiar a recepción automáticamente |
| 404 | delivery_not_found |
No hay ese recibo en el sitio consultado |
| 409 | reservation_full |
El cupo se completó, posiblemente durante otro envío |
| 409 | duplicate_document |
Ya existe ese documento en la reserva; conciliar |
| 409 | idempotency_conflict |
La clave pertenece a otro contenido; conservarla e investigar |
| 409 | reservation_exists |
La referencia ya fue provisionada; no hay devolución del token anterior |
| 409 | delivery_not_retryable |
Recibo desconocido, en procesamiento o ya importado |
| 410 | reservation_expired |
Invitación caducada; contactar con recepción |
| 413 | Cuerpo mayor de 16 KiB | Reducir al contrato; respuesta del framework, no necesariamente JSON de check-in |
| 429 | rate_limited |
Esperar antes de repetir; conservar el mismo intento si era un registro |
| 503 | checkin_unavailable |
Fallo de persistencia; comprobar servicio y reintentar con la misma clave |
El proxy puede producir otros errores o HTML. Una respuesta perdida o no válida es un resultado incierto, no autorización para crear otro registro. Las rutas {receiptId:guid} también pueden devolver un 404 de enrutamiento ante un identificador mal formado.
Prueba reproducible con PowerShell
Después de arrancar la API local, ejecuta en una terminal de operador. Usa únicamente un entorno de prueba y valores ficticios. El ejemplo crea datos; no publica ni envía correos.
$api = 'http://127.0.0.1:8080'
$site = 'las-torres'
# Define CHECKIN_ADMIN_TOKEN en esta terminal con el mismo secreto de la API.
$headers = @{ 'X-Checkin-Admin-Token' = $env:CHECKIN_ADMIN_TOKEN }
$request = @{
reservationReference = 'LOCAL-' + [guid]::NewGuid().ToString('N')
capacity = 2
expiresAt = [DateTimeOffset]::UtcNow.AddDays(2).ToString('O')
} | ConvertTo-Json
$reservation = Invoke-RestMethod -Method Post -Uri "$api/v1/checkin/sites/$site/reservations" `
-Headers $headers -ContentType 'application/json' -Body $request
$contextBody = @{ token = $reservation.token } | ConvertTo-Json
$context = Invoke-RestMethod -Method Post -Uri "$api/v1/checkin/sites/$site/reservation-context" `
-ContentType 'application/json' -Body $contextBody
$payload = @{
mode = 'reservation'
token = $reservation.token
idempotencyKey = [guid]::NewGuid().ToString()
guest = @{
givenName = 'Ana'; surname1 = 'Garcia'; surname2 = ''
birthDate = '1990-05-17'; sex = 'female'; nationality = 'ES'
documentType = 'dni'; documentCountry = 'ES'; documentNumber = '12345678Z'
address = 'Calle Mar 1'; city = 'Mazarron'; postalCode = '30860'; province = 'Murcia'
country = 'ES'; phone = '+34 612 345 678'; email = '[email protected]'
}
} | ConvertTo-Json -Depth 5
$receipt = Invoke-RestMethod -Method Post -Uri "$api/v1/checkin/sites/$site/guests" `
-ContentType 'application/json' -Body $payload
# Repetir exactamente el mismo texto: mismo recibo y sin consumir otra plaza.
$replay = Invoke-RestMethod -Method Post -Uri "$api/v1/checkin/sites/$site/guests" `
-ContentType 'application/json' -Body $payload
$receipt.receiptId -eq $replay.receiptId
Conserva $payload sin modificar si una llamada falla con resultado incierto. El ejemplo usa una referencia nueva solo para otra reserva de laboratorio, nunca para recuperar una provisión cuyo resultado se haya perdido. Para abrir esa misma reserva en la web local compón http://127.0.0.1:5174/?site=las-torres#token= seguido de $reservation.token, sin guardar el enlace en registros compartidos.