Integración
POS de restaurante
Para el punto de venta que cierra una cuenta «a la habitación»: el cargo entra al folio de la estancia y el huésped lo paga al salir.
Qué resuelve
El POS sigue cobrando todo lo demás como siempre: efectivo, tarjeta, transferencia. Solo cambia una cosa. Cuando el mesero cierra una cuenta «a la habitación», el POS nos manda el cargo, entra al folio de la estancia del huésped y se paga al hacer check-out.
Sirve para Soft Restaurant, Parrot, Loyverse o cualquier otro punto de venta que pueda hacer una llamada HTTP firmada al cerrar la cuenta.
- Son tres llamadas: buscar al huésped de una habitación, cargar, y anular si el mesero se equivocó.
- Los importes viajan en centavos y en la moneda de la reserva.
- El folio del ticket es la llave de idempotencia. Reintentar con el mismo folio no cobra dos veces.
Credenciales
El hotel crea la conexión en su panel, en Configuración → POS del restaurante, y recibe una sola vez el par de credenciales.
| Dato | Ejemplo | Uso |
|---|---|---|
| keyId | yrpos_3f9a1c0b7d2e4a6f8b0c1d2e | Público. Identifica la conexión. |
| secret | yrpos_sk_9c1f… (64 hex) | Secreto. Firma las peticiones y nunca viaja en ellas. |
Cada conexión pertenece a una propiedad: solo ve y carga a las estancias de esa propiedad. El hotel puede limitar el importe máximo por cargo, apagar los cargos, rotar el secreto —el anterior deja de valer en el acto— o revocar la conexión entera.
Regla: El secreto se ve una sola vez
El panel lo enseña al crearlo y al rotarlo, y no lo vuelve a enseñar. Guárdalo en ese momento donde guardes tus secretos; si se pierde, hay que rotar.
https://api.yareserva.com/api/v1/webhooks/posCómo firmar
X-YR-POS-Key: <keyId>
X-YR-POS-Timestamp: <segundos Unix, UTC>
X-YR-POS-Signature: sha256=<hex(HMAC-SHA256(secret, timestamp + "." + cuerpo))>- El cuerpo son los bytes exactos que envías. Firma después de serializar el JSON y no lo reformatees.
- En los GET el cuerpo es vacío: se firma «timestamp + "."», o sea la cadena termina en el punto.
- La hora del POS puede diferir de la nuestra como mucho cinco minutos. Fuera de ese margen la petición se rechaza; sincroniza el reloj con NTP.
- Cualquier fallo —llave desconocida o revocada, firma incorrecta, hora fuera de margen— responde 401 INVALID_SIGNATURE y no se procesa nada.
- El cuerpo no puede pasar de 64 KB, y hay un tope de 300 peticiones por minuto por IP.
KEY=yrpos_3f9a1c0b7d2e4a6f8b0c1d2e
SECRET=yrpos_sk_...
BODY='{"externalId":"TK-000123","roomNumber":"204","amountCents":45000,"description":"Cena mesa 7"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST https://api.yareserva.com/api/v1/webhooks/pos/charges \
-H "Content-Type: application/json" \
-H "X-YR-POS-Key: $KEY" \
-H "X-YR-POS-Timestamp: $TS" \
-H "X-YR-POS-Signature: sha256=$SIG" \
--data-binary "$BODY"const crypto = require('crypto');
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
// headers: X-YR-POS-Key, X-YR-POS-Timestamp: ts, X-YR-POS-Signature: `sha256=${sig}`import hashlib, hmac, json, time
body = json.dumps(payload, separators=(",", ":")).encode()
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()Buscar al huésped
/rooms/{roomNumber}/guestsQuién está hospedado en una habitación, para que el mesero lo confirme con el huésped antes de cargar.
Parámetros de ruta
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| roomNumber | string | Sí | El número de habitación tal como lo teclea el mesero. |
Respuesta
{
"data": {
"items": [
{
"stayId": "5d0c3f1e-2b7a-4c1d-9e8f-0a1b2c3d4e5f",
"roomNumber": "204",
"guest": "J. Pérez",
"reservationCode": "•••••••2345",
"checkOut": "2026-09-17"
}
]
}
}Errores
| HTTP | Código | Cuándo | Qué hacer |
|---|---|---|---|
| 404 | ROOM_NOT_IN_HOUSE | Nadie está hospedado en esa habitación. | Enséñalo en la pantalla del mesero y que cobre por otro medio. |
Dato: Devuelve lo mínimo, a propósito
Solo la inicial del nombre y el primer apellido, y el código de reserva con los últimos cuatro caracteres. Alcanza para que el mesero confirme en voz alta sin que la pantalla del restaurante enseñe los datos de un huésped a quien pase por ahí.
Cargar a la habitación
/chargesMete el consumo al folio de la estancia. En el folio aparece como «POS <conexión>: <descripción>».
Cuerpo de la petición
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| externalId | string | Sí | 1–100 caracteres: letras, dígitos y . _ : -. Es el folio del ticket y tiene que ser único por conexión. |
| roomNumber | string | Sí | Número de habitación. Se puede omitir si mandas stayId o reservationCode. |
| stayId | UUID | No | El de la búsqueda. Úsalo si hay más de una estancia en la habitación. |
| reservationCode | string | No | Código completo de la reserva, si el huésped lo da. |
| amountCents | integer | Sí | Mayor que cero y dentro del tope de la conexión, si el hotel puso uno. |
| currency | string | No | Si viene, tiene que ser la moneda de la reserva. |
| description | string | Sí | 1–120 caracteres. Es lo que el huésped lee en su folio. |
| lines | array | No | Si vienen, la suma de qty × unitCents tiene que dar exactamente amountCents. Los descuentos van con unitCents negativo y la propina como una línea más. |
Petición
{
"externalId": "TK-000123",
"roomNumber": "204",
"stayId": "5d0c3f1e-2b7a-4c1d-9e8f-0a1b2c3d4e5f",
"amountCents": 45000,
"currency": "MXN",
"description": "Cena mesa 7",
"lines": [
{ "name": "Filete", "qty": 2, "unitCents": 21000 },
{ "name": "Propina", "qty": 1, "unitCents": 3000 }
]
}Respuesta
{
"data": {
"chargeId": "b7e4c1d0-3a2f-4e5b-8c7d-9e0f1a2b3c4d",
"externalId": "TK-000123",
"status": "posted",
"duplicate": false,
"amountCents": 45000,
"currency": "MXN",
"roomNumber": "204",
"guest": "J. Pérez",
"reservationCode": "•••••••2345",
"receivedAt": "2026-09-14T21:03:11Z",
"voidedAt": null
}
}Errores
| HTTP | Código | Cuándo | Qué hacer |
|---|---|---|---|
| 401 | INVALID_SIGNATURE | Firma, hora o llave inválidas. | Revisa el reloj antes que nada. |
| 404 | ROOM_NOT_IN_HOUSE | Nadie hospedado con esos datos. | Confirma la habitación con el huésped y vuelve a buscar. |
| 409 | CHARGES_NOT_ALLOWED | La estancia ya no admite cargos —salió o no ha llegado— o el hotel apagó los cargos de esta conexión. | No reintentes. Que el restaurante cobre por otro medio. |
| 409 | MULTIPLE_STAYS_IN_ROOM | Hay dos estancias en la misma habitación. | Reintenta mandando stayId, el de la búsqueda. |
| 409 | EXTERNAL_ID_REUSED | Ese folio ya se usó con otro importe o con otra habitación. | Usa un folio nuevo. El mismo folio tiene que ser el mismo cargo. |
| 413 | PAYLOAD_TOO_LARGE | Cuerpo de más de 64 KB. | Manda menos líneas de detalle. |
| 422 | VALIDATION_ERROR | Falta un campo o las líneas no suman el total. | «details.field» dice cuál. |
Anular un cargo
/charges/{externalId}/voidGenera una nota de crédito en el folio. El cargo original no se borra: se queda con su nota al lado.
Cuerpo de la petición
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| reason | string | Sí | Por qué se anula. Se ve en el folio y en el panel del hotel. |
Petición
{ "reason": "Se cargó a la mesa equivocada" }Respuesta
{
"data": {
"chargeId": "b7e4c1d0-3a2f-4e5b-8c7d-9e0f1a2b3c4d",
"externalId": "TK-000123",
"status": "voided",
"amountCents": 45000,
"currency": "MXN",
"voidedAt": "2026-09-14T21:41:02Z"
}
}Errores
| HTTP | Código | Cuándo | Qué hacer |
|---|---|---|---|
| 404 | CHARGE_NOT_FOUND | No hay cargo con ese folio en esta conexión. | Comprueba el folio: solo ves los cargos de tu propia conexión. |
| 409 | CHARGE_ALREADY_VOIDED | Ya estaba anulado, por el POS o por recepción. | Trátalo como éxito: el cargo ya no está en el folio. |
| 409 | CHARGES_NOT_ALLOWED | La cuenta del huésped ya se cerró. | El ajuste lo hace recepción desde el panel. |
| 422 | VALIDATION_ERROR | Falta «reason». | Manda el motivo. |
Idempotencia y reintentos
- Reintenta con el mismo externalId ante cualquier error de red, timeout o 5xx. Si el cargo ya había entrado, recibes 200 con «duplicate»: true y el mismo chargeId: no se carga dos veces.
- El registro del cargo y el movimiento del folio se escriben en la misma transacción. Dos reintentos a la vez no pueden duplicar el cargo.
- Genera un timestamp y una firma nuevos en cada reintento: la firma vieja caduca a los cinco minutos.
- No reintentes un 4xx sin corregir la petición. La excepción es el 429, que se reintenta respetando Retry-After.
- Anular es seguro de repetir: la segunda vez responde 409 CHARGE_ALREADY_VOIDED, que para ti es lo mismo que haber salido bien.
Cuidado: El folio del ticket no se reusa
Si tu POS reinicia la numeración cada turno o cada día, junta el folio con la fecha o con la caja antes de mandarlo. Dos consumos distintos con el mismo externalId hacen que el segundo se tome por un reintento del primero y nunca llegue al folio del huésped.
¿Vas a integrar un hotel?
Las credenciales salen del panel del hotel, y las de sandbox se crean el mismo día. Si el hotel todavía no tiene cuenta, empieza por registrarlo.