Integración
Motor de reservas propio
Para el hotel que opera en yaReserva pero vende desde el buscador de su propia web. Seis endpoints: qué hay libre, cuánto cuesta, reservar, consultar, cancelar y cobrar.
Para quién es
El hotel ya tiene su web, con su diseño y su buscador, y no quiere cambiarlo por el nuestro. Por dentro, el inventario, los precios, los impuestos y las políticas siguen siendo los de yaReserva. Esta API es el puente: tu motor pregunta y reserva, y la reserva aparece en el panel del hotel como cualquier otra.
No es un segundo motor de reservas: es el mismo que mueve el Web Embed, con otra pantalla enfrente. Por eso no hay dos verdades sobre el mismo inventario ni riesgo de sobrecupo. Lo único que cambia es el canal con el que se registra la venta, que es el que decide la comisión.
Si el hotel no tiene quien le programe esto, no hace falta: el Web Embed es un buscador listo que se pega en la web con una línea. Esta API es para quien quiere control del diseño y del flujo.
Regla: La intención de pago la crea siempre yaReserva
El motor del hotel jamás llama a Stripe para crear el cobro. Lo crea nuestro backend, el importe sale de la reserva ya sellada —no de lo que mande el navegador— y la cuenta donde nace el cargo la decide la configuración del hotel, no un parámetro. Una intención de pago creada en el frontend es un importe que elige el comprador y una cuenta que apunta él.
Regla: El secreto se ve una sola vez
Al crear la conexión o al rotarla, el panel enseña el secreto una vez y no lo vuelve a enseñar: se guarda cifrado y ni el hotel ni nosotros lo podemos leer después. Guárdalo en tu gestor de secretos en ese momento. Si se pierde, la salida es rotar, y el secreto anterior deja de valer en el acto.
Credenciales y dominios
El hotel crea la conexión en su panel, en Configuración → Motor de reservas, y elige la propiedad. Ahí recibe, una sola vez, el par de credenciales.
| Dato | Forma | Uso |
|---|---|---|
| keyId | yreng_ + 24 hex | Público. Identifica la conexión y viaja en cada petición. |
| secret | yreng_sk_ + 64 hex | Secreto. Firma las peticiones y nunca viaja en ellas. |
Además del par de llaves, la conexión lleva dos ajustes que el hotel controla desde el panel: los dominios autorizados y el tope por reserva.
Regla: Sin dominios autorizados, la llave es de servidor
Una conexión sin dominios declarados no admite ninguno: si la petición llega con el header Origin, se rechaza con 403 ORIGIN_NOT_ALLOWED aunque la firma sea perfecta. Ver un Origin significa que hay un navegador detrás, y un navegador con esa llave es una llave regalada, porque el secreto de firma está en ella. Si tu motor de verdad corre en el navegador, el hotel tiene que declarar sus dominios primero.
- Hasta diez dominios por llave, con esquema y host: «https://www.mihotel.mx». Se guardan normalizados, en minúsculas y sin ruta ni barra final.
- «http://localhost» y «http://localhost:3000» valen, para que el hotel pueda desarrollar su motor.
- La respuesta devuelve el Origin concreto, nunca «*»: con comodín cualquier página podría leerla, que es justo lo que la lista existe para impedir.
- El preflight OPTIONS se contesta sin llave —el navegador lo manda pelado— y se cachea solo diez minutos, para que un cambio en la lista de dominios se note el mismo día.
- El tope por reserva acota el daño de un secreto filtrado: con tope, quien lo robe no puede crear una reserva de medio millón y pedir su devolución. Cero significa sin tope, y el techo absoluto es un millón de pesos en centavos.
https://api.yareserva.com/api/v1/engine
X-YR-ENG-Key: <keyId>
X-YR-ENG-Timestamp: <segundos Unix, UTC>
X-YR-ENG-Signature: sha256=<hex(HMAC-SHA256(secret, timestamp + "." + cuerpo))>
X-Idempotency-Key: <solo al crear la reserva; opcional al cobrar>Cómo firmar
Es la misma mecánica que el resto de la API: HMAC-SHA256 sobre «timestamp + "." + cuerpo», con el secreto como llave, en hexadecimal y con el prefijo «sha256=». Hay dos detalles propios que conviene tener claros antes de pelearse con un 401.
- En un GET el cuerpo está vacío, así que se firma la cadena vacía: lo que entra en el HMAC es el timestamp, el punto, y nada más.
- La URL no entra en la firma. Consultar disponibilidad no cambia nada, y meterla obligaría a que los dos lados normalizaran la query igual, que es donde siempre acaban discrepando dos implementaciones.
- El cuerpo son los bytes exactos que mandas. Firma después de serializar el JSON y no lo reformatees.
- Tu reloj puede diferir del nuestro cinco minutos como mucho. Fuera de ahí la petición se rechaza aunque la firma sea correcta; sincroniza con NTP.
- Una llave que no existe, una firma que no cuadra, una hora fuera de margen y una conexión revocada contestan todas lo mismo: 401 INVALID_SIGNATURE. A quien prueba no se le dice qué parte falló.
- El cuerpo no puede pasar de 64 KB —caben ocho habitaciones con sus tokens de sobra— y hay un tope de 300 peticiones por minuto por IP.
KEY=yreng_3f9a1c0b7d2e4a6f8b0c1d2e
SECRET=yreng_sk_...
BODY='{"offers":["eyJ2IjoxLCJyIjoi..."]}'
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/engine/quote \
-H "Content-Type: application/json" \
-H "X-YR-ENG-Key: $KEY" \
-H "X-YR-ENG-Timestamp: $TS" \
-H "X-YR-ENG-Signature: sha256=$SIG" \
--data-binary "$BODY"TS=$(date +%s)
# El punto y nada detrás: en un GET se firma la cadena vacía.
SIG=$(printf '%s.' "$TS" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl "https://api.yareserva.com/api/v1/engine/availability?checkIn=2026-11-20&checkOut=2026-11-22&adults=2" \
-H "X-YR-ENG-Key: $KEY" \
-H "X-YR-ENG-Timestamp: $TS" \
-H "X-YR-ENG-Signature: sha256=$SIG"const crypto = require('crypto');
function firmar(secreto, payload) {
// En un GET, payload es undefined y el cuerpo va vacío.
const body = payload === undefined ? '' : JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto.createHmac('sha256', secreto).update(`${ts}.${body}`).digest('hex');
return {
body,
headers: {
'X-YR-ENG-Key': keyId,
'X-YR-ENG-Timestamp': ts,
'X-YR-ENG-Signature': `sha256=${sig}`,
},
};
}
// Manda `body` tal cual, sin volver a serializar el objeto.import hashlib, hmac, json, time
def firmar(secreto, payload=None):
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode()
ts = str(int(time.time()))
sig = hmac.new(secreto.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return body, {
"X-YR-ENG-Key": key_id,
"X-YR-ENG-Timestamp": ts,
"X-YR-ENG-Signature": "sha256=" + sig,
}El flujo va con ofertas firmadas
Esto es lo que más se malinterpreta al empezar, así que va antes que los endpoints. No hay identificadores de cotización que tú guardes y devuelvas. Lo que devuelve la disponibilidad, por cada tarifa de cada habitación, es una oferta: un token opaco y firmado por nosotros. Ese token es lo único que mandas de vuelta para cotizar y para reservar.
{ "offers": ["eyJ2IjoxLCJyIjoi...", "eyJ2IjoxLCJyIjoi..."] }
// Una sola habitación también se puede mandar como:
{ "offer": "eyJ2IjoxLCJyIjoi..." }Regla: El precio NO viaja dentro del token
La firma protege la habitación, el plan, las fechas y los huéspedes: eso nadie lo puede editar. El precio se queda fuera a propósito y se vuelve a calcular con nuestro motor al cotizar y otra vez al reservar. Si viajara dentro, quien manipulara el token podría comprar una noche de temporada alta al precio de hace un mes, y tendríamos dos números para la misma noche. Por eso tu motor nunca tiene que mandar un importe, y por eso no puede confiar en un total que guardó hace rato.
- Una oferta vale treinta minutos desde que se emite. Tiempo de sobra para llenar un formulario y poco para que el inventario se haya movido. Pasado ese rato llega 409 OFFER_EXPIRED y hay que volver a la disponibilidad.
- Varias ofertas en un mismo arreglo son varias habitaciones de una misma reserva. Caben hasta ocho; más que eso es un grupo y se cotiza aparte.
- Todas las habitaciones de una reserva tienen que compartir fechas. Mezclarlas contesta 422 MIXED_DATES.
- Trata el token como opaco: no lo parsees, no lo acortes, no lo guardes en una URL. Solo se pasa tal cual.
- El huésped busca fechas y tu motor pide la disponibilidad. Cada tarifa viene con su oferta y con su precio ya calculado.
- El huésped elige, y tu motor manda esas ofertas a la cotización. Ahí llega el total con impuestos, cuánto se cobra ahora, cuánto en el hotel y los tramos de la política de cancelación.
- El huésped llena sus datos y acepta condiciones y privacidad, y tu motor crea la reserva con las mismas ofertas y una llave de idempotencia. Se recalcula el precio, se compara con el tope de la llave y se sella el inventario.
- Tu motor pide la intención de pago de esa reserva y recibe un client secret, la cuenta del hotel y la clave publicable. El navegador termina el pago con Stripe.js.
Dato: Aquí se reserva primero y se cobra después
Al revés que el Web Embed, que cobra y luego reserva. Es porque la pantalla de pago la pone el hotel y nosotros no sabemos cuándo la va a enseñar: la reserva nace con saldo pendiente y el cobro se abre cuando tu motor lo pida.
Disponibilidad
/api/v1/engine/availabilityQué hay libre en un rango de fechas y a qué precio, con la oferta firmada de cada tarifa.
Parámetros de consulta
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| checkIn | AAAA-MM-DD | Sí | Día de entrada. |
| checkOut | AAAA-MM-DD | Sí | Día de salida. Si el rango no se puede cotizar, llega 422 con field checkOut. |
| adults | integer | No | Adultos. Dos si no mandas nada, y también si mandas un valor imposible: cotizar para dos es más útil que un error por una URL mal armada. |
| children | integer | No | Menores. Cero si no mandas nada. |
| childAges | string | No | Las edades separadas por comas: «8,12». Algunos hoteles cobran por edad y otros no admiten menores de cierta edad. |
| propertyId | UUID | No | Solo hace falta si el hotel tiene más de una propiedad. Sin él se usa la única que tenga. |
Petición
GET /api/v1/engine/availability?checkIn=2026-11-20&checkOut=2026-11-22&adults=2&children=1&childAges=8 HTTP/1.1
Host: api.yareserva.com
X-YR-ENG-Key: yreng_3f9a1c0b7d2e4a6f8b0c1d2e
X-YR-ENG-Timestamp: 1789459391
X-YR-ENG-Signature: sha256=9c1f0a7d4b2e6c8a1f3d5b7e9c0a2d4f6b8e0c2a4d6f8b0c2e4a6d8f0b2c4e6aRespuesta
{
"data": {
"checkIn": "2026-11-20",
"checkOut": "2026-11-22",
"nights": 2,
"adults": 2,
"children": 1,
"currency": "MXN",
"pricesIncludeTaxes": true,
"rooms": [
{
"roomTypeId": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6071",
"name": "Habitación Doble",
"description": "Cama king, vista al jardín.",
"images": ["https://cdn.yareserva.com/…/doble-1.jpg"],
"amenities": ["wifi", "air_conditioning"],
"beds": "1 king",
"maxGuests": 3,
"perPerson": false,
"rates": [
{
"offer": "eyJ2IjoxLCJyIjoi...",
"ratePlanId": "2b3c4d5e-6f70-4819-a2b3-c4d5e6f70819",
"ratePlan": "Tarifa Flexible",
"description": "Cancela gratis hasta 48 h antes.",
"refundable": true,
"cancelText": "Cancelación gratuita hasta el 18 de noviembre.",
"freeCancelUntil": "2026-11-18T18:00:00Z",
"paymentTiming": "pay_now",
"payNowCents": 440300,
"payAtHotelCents": 0,
"included": [{ "name": "Wifi", "type": "wifi" }],
"totalCents": 440300,
"nightlyFromCents": 185000,
"taxesCents": 70300,
"currency": "MXN",
"nights": 2,
"unitsLeft": 4
}
]
}
]
}
}Que no haya nada libre no es un error: llega 200 con «rooms» vacío. Enseña «no hay disponibilidad» y ofrece otras fechas.
- «pricesIncludeTaxes» decide si tu pantalla dice «impuestos incluidos» o «más impuestos». Es la decisión del hotel, no la tuya.
- «payNowCents» y «payAtHotelCents» suman siempre «totalCents». Hay planes que se cobran enteros al reservar y otros que se pagan al llegar.
- «unitsLeft» es cuántas habitaciones de ese tipo quedan. Sirve para el «solo quedan dos» de tu pantalla, no para reservar más de las que hay.
- «freeCancelUntil» llega vacío cuando la tarifa no tiene plazo gratuito.
Cotización
/api/v1/engine/quoteRecalcula lo que el huésped eligió: total con impuestos, cuánto ahora y cuánto en el hotel, y los tramos de la política de cancelación de cada habitación.
Cuerpo de la petición
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| offers | string[] | Sí | Las ofertas elegidas, una por habitación, hasta ocho. Para una sola también vale el campo «offer» con el token suelto. |
| offer | string | No | Atajo para una sola habitación. Si mandas «offers», este se ignora. |
| propertyId | UUID | No | Igual que en disponibilidad: solo si el hotel tiene varias. |
Petición
{
"offers": ["eyJ2IjoxLCJyIjoi...", "eyJ2IjoxLCJyIjoi..."]
}Respuesta
{
"data": {
"propertyId": "0f1e2d3c-4b5a-4968-8776-655443332211",
"checkIn": "2026-11-20",
"checkOut": "2026-11-22",
"nights": 2,
"adults": 2,
"children": 1,
"currency": "MXN",
"totalCents": 880600,
"taxesCents": 140600,
"payNowCents": 880600,
"payAtHotelCents": 0,
"requiresCard": false,
"rooms": [
{
"offer": "eyJ2IjoxLCJyIjoi...",
"roomTypeId": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6071",
"roomName": "Habitación Doble",
"ratePlanId": "2b3c4d5e-6f70-4819-a2b3-c4d5e6f70819",
"ratePlan": "Tarifa Flexible",
"totalCents": 440300,
"taxesCents": 70300,
"nightlyFromCents": 185000,
"payNowCents": 440300,
"payAtHotelCents": 0,
"currency": "MXN",
"nights": 2,
"paymentTiming": "pay_now",
"requiresGuarantee": false,
"cancellationRules": [
{ "hoursBefore": 48, "penaltyType": "none", "penaltyValue": 0, "appliesTo": "total" },
{ "hoursBefore": 0, "penaltyType": "nights", "penaltyValue": 1, "appliesTo": "total" }
]
}
]
}
}Cuidado: Enseña el total que llega, no el que sumes
Los impuestos de hospedaje cambian por estado y no se aplican igual en toda estancia. Recalcular del lado del motor produce diferencias que el huésped descubre al llegar al hotel. El total es «totalCents» y lo que se cobra ahora es «payNowCents».
Los tramos de cancelación van crudos, y no una frase hecha, porque tu motor dibuja su propia pantalla: con la frase no podrías enseñar «hasta el jueves gratis, después una noche» en tu idioma ni en tu formato. «requiresCard» avisa de que no se cobra nada ahora pero alguna política pide tarjeta en garantía.
Crear la reserva
/api/v1/engine/bookingsSella el inventario y crea la reserva con saldo pendiente. Exige X-Idempotency-Key.
Cuerpo de la petición
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| offers | string[] | Sí | Las mismas ofertas que cotizaste. También vale «offer» para una sola. |
| guest.firstName | string | Sí | Nombre. Vacío contesta 422 con field guest.firstName. |
| guest.lastName | string | Sí | Apellido. |
| guest.email | string | Sí | Ahí llega la confirmación de la reserva. |
| guest.phone | string | No | En formato internacional, con el signo de más y el código de país. |
| guest.country | string | No | Nacionalidad del titular. Sirve para el reporte del hotel. |
| guest.birthdate | AAAA-MM-DD | No | Fecha de nacimiento del titular, cuando el hotel la pide. |
| acceptedTerms | boolean | Sí | Que el huésped aceptó las condiciones. Se guarda en el historial de la reserva junto a la IP: es donde se sostiene si alguien lo reclama. |
| acceptedPrivacy | boolean | Sí | Que aceptó el aviso de privacidad. Se guarda igual. |
| guestIp | string | No | La IP del huésped cuando tu motor corre en el servidor del hotel: ahí la que vemos es la tuya, no la de quien reserva. Sin este campo usamos la que vemos. |
| userAgent | string | No | Lo mismo con el navegador del huésped. |
| propertyId | UUID | No | Solo si el hotel tiene varias propiedades. |
Petición
POST /api/v1/engine/bookings
X-Idempotency-Key: 8c2e1f04-5a6b-4c7d-8e9f-0a1b2c3d4e5f
{
"offers": ["eyJ2IjoxLCJyIjoi..."],
"guest": {
"firstName": "Ana",
"lastName": "Ruiz",
"email": "ana@correo.mx",
"phone": "+525512345678",
"country": "MX",
"birthdate": "1990-04-12"
},
"acceptedTerms": true,
"acceptedPrivacy": true,
"guestIp": "189.203.11.42",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)"
}Respuesta
{
"data": {
"id": "7b0c5d2e-1f3a-4c6b-9d8e-2a1b3c4d5e6f",
"code": "YR-8K3M2Q",
"status": "pending",
"checkIn": "2026-11-20",
"checkOut": "2026-11-22",
"adults": 2,
"children": 1,
"rooms": 1,
"totalCents": 440300,
"paidCents": 0,
"dueCents": 440300,
"currency": "MXN",
"guest": {
"firstName": "Ana",
"lastName": "Ruiz",
"email": "ana@correo.mx",
"phone": "+525512345678"
},
"cancelledAt": null,
"penaltyCents": 0,
"retry": false
}
}Regla: La llave de idempotencia es obligatoria y se comprueba dentro de un lock
Sin el header, la respuesta es 400 IDEMPOTENCY_KEY_REQUIRED: no se inventa una por ti, porque entonces un reintento tras un corte de red crearía una segunda reserva y eso solo se descubre cuando el hotel ve dos habitaciones bloqueadas. La comprobación de la llave y la venta ocurren dentro de un lock por conexión y llave, así que dos POST simultáneos con la misma llave no pueden pasar los dos por la comprobación en vacío: se serializan y solo uno vende.
- La llave admite letras, dígitos y . _ : - y cabe en 100 caracteres. Un UUID v4 sirve. Una llave con otros caracteres contesta 422 con field idempotencyKey.
- Un reintento con la misma llave devuelve 200 —no 201— con la misma reserva y «retry»: true. La respuesta 201 significa que esta petición vendió; la 200, que solo te está enseñando lo que ya existía.
- La llave queda anotada junto a la reserva que creó. Reusarla con otras ofertas no crea nada distinto: devuelve la primera reserva. Si los datos cambian, cambia la llave.
- El precio se vuelve a calcular antes de vender y se compara con el tope de la conexión. Si lo pasa, llega 422 BOOKING_OVER_LIMIT y no se crea nada.
Consultar una reserva
/api/v1/engine/bookings/:codeEl estado y el saldo de una reserva por su folio. Es lo que pinta tu página de gracias y tu pantalla de «mi reserva».
Parámetros de ruta
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| code | string | Sí | El folio que devolvió la creación, como YR-8K3M2Q. |
Respuesta
{
"data": {
"id": "7b0c5d2e-1f3a-4c6b-9d8e-2a1b3c4d5e6f",
"code": "YR-8K3M2Q",
"status": "confirmed",
"checkIn": "2026-11-20",
"checkOut": "2026-11-22",
"adults": 2,
"children": 1,
"rooms": 1,
"totalCents": 440300,
"paidCents": 440300,
"dueCents": 0,
"currency": "MXN",
"guest": {
"firstName": "Ana",
"lastName": "Ruiz",
"email": "ana@correo.mx",
"phone": "+525512345678"
},
"cancelledAt": null,
"penaltyCents": 0,
"retry": false
}
}Regla: Una conexión solo ve lo que ella creó
Consultar, cancelar o cobrar una reserva que entró por otra conexión —o por el mostrador, o por una OTA— contesta 404, exactamente igual que un folio que no existe. No se distinguen a propósito: si se distinguieran, quien recorriera folios sabría cuáles acertó, y los folios son cortos.
La reserva no llega entera: no van el coste por noche, las notas internas ni la comisión. Son datos del hotel, no de la venta. «dueCents» es lo que falta por cobrar y nunca es negativo.
Cancelar
/api/v1/engine/bookings/:code/cancelCancela aplicando la política que se congeló al confirmar la reserva, y dice cuánto se retiene y cuánto sobra devolver.
Cuerpo de la petición
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| reason | string | No | Por qué se cancela. Se ve en el panel del hotel. Vacío se admite —cancelar sin decir por qué es normal en la web de un hotel— y se guarda «Cancelada por el huésped desde el motor del hotel». Más de 300 caracteres se recortan. |
Petición
{ "reason": "Cambio de planes del huésped" }Respuesta
{
"data": {
"booking": {
"id": "7b0c5d2e-1f3a-4c6b-9d8e-2a1b3c4d5e6f",
"code": "YR-8K3M2Q",
"status": "cancelled",
"totalCents": 440300,
"paidCents": 440300,
"dueCents": 0,
"currency": "MXN",
"cancelledAt": "2026-11-16T09:12:00Z",
"penaltyCents": 0,
"retry": false
},
"penaltyCents": 0,
"refundDueCents": 440300,
"currency": "MXN",
"refundedByHotel": true,
"refundedByUsNote": "El cargo nació en la cuenta Stripe del hotel: la devolución la emite el hotel."
}
}Regla: Esto NO reembolsa: dice cuánto
El dinero está en la cuenta de Stripe del hotel, porque el cargo nació ahí. La devolución la ejecuta el hotel. Lo que devuelve este endpoint es «refundDueCents», que es cuánto sobra devolver después de la penalidad. No le prometas al huésped que el dinero ya salió: dile lo que le corresponde y que el hotel lo emite.
La política no se recalcula con la de hoy. El huésped compró bajo unas condiciones, y aplicarle otras después —aunque el hotelero haya editado el plan mientras tanto— sería cobrarle una penalidad que nunca aceptó.
Cobrar
/api/v1/engine/bookings/:code/payment-intentAbre el cobro de una reserva con saldo pendiente y devuelve lo que tu página necesita para montar el formulario de tarjeta.
Cuerpo de la petición
| Nombre | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| amountCents | integer | No | Cuánto cobrar. En cero, o sin mandar nada, se cobra el saldo pendiente entero. Nunca puede pasar de él: más contesta 422 con field amountCents. |
Petición
POST /api/v1/engine/bookings/YR-8K3M2Q/payment-intent
X-Idempotency-Key: 8c2e1f04-5a6b-4c7d-8e9f-0a1b2c3d4e5f
{ "amountCents": 440300 }Respuesta
{
"data": {
"paymentIntentId": "pi_3Qk…",
"clientSecret": "pi_3Qk…_secret_9c1f…",
"stripeAccountId": "acct_1QkA2B3C4D5E6F7G",
"publishableKey": "pk_live_51H…",
"amountCents": 440300,
"currency": "MXN"
}
}- El cargo es directo en la cuenta Connect del hotel, como cualquier venta directa suya: el merchant of record es el hotel. Nuestra parte viaja como comisión de la plataforma.
- Sin «stripeAccountId» el client secret no resuelve, y el error que ve el huésped es «No such payment_intent», que desde fuera parece que la reserva no existe. Inicia Stripe.js con la cuenta y con la clave publicable que vienen en la respuesta.
- La clave publicable la da el servidor y no la configura el hotelero: es nuestra clave de plataforma de Connect y cambia entre pruebas y producción.
- Si mandas X-Idempotency-Key, dos clics en «pagar» dan el mismo intent en vez de dos cargos que habría que devolver. Si no la mandas, se genera una estable por reserva e importe, que hace lo mismo.
// Tu servidor pide la intención (firmada) y le pasa esto a la página.
const { clientSecret, publishableKey, stripeAccountId } = await pedirIntencionASuBackend(code);
// La cuenta del hotel: el cargo nace y se queda ahí.
const stripe = Stripe(publishableKey, { stripeAccount: stripeAccountId });
const elements = stripe.elements({ clientSecret });
elements.create('payment').mount('#pago');
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: 'https://www.mihotel.mx/reserva/gracias?code=' + code },
});
// No marques la reserva como pagada desde aquí: vuelve a consultarla.Cuidado: El estado lo cambia el cobro, no tu página
Cuando el pago entra de verdad, la reserva pasa a confirmada sola. Tu página de gracias vuelve a consultar la reserva por su folio y enseña lo que llegue. Si el huésped cierra la pestaña a media autenticación, la reserva se queda con saldo pendiente y el cuarto se libera cuando venza su plazo de pago.
Errores del motor
El código HTTP dice de qué tipo es el problema y «error.code» dice cuál. En los VALIDATION_ERROR, «details.field» dice exactamente qué campo revisar.
Errores
| HTTP | Código | Cuándo | Qué hacer |
|---|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | Un POST a /bookings sin el header X-Idempotency-Key. | Manda la llave. No se inventa por ti. |
| 401 | INVALID_SIGNATURE | Llave desconocida, firma que no cuadra, hora fuera del margen de cinco minutos o conexión revocada. | Revisa el reloj primero. Después, que firmes los bytes exactos que mandas, y que en los GET firmes la cadena vacía. |
| 403 | ORIGIN_NOT_ALLOWED | La petición llegó con Origin y ese dominio no está en la lista de la llave, o la llave no tiene dominios declarados. | No reintentes. O llamas desde el servidor, o el hotel declara el dominio en su panel. |
| 404 | NOT_FOUND | No hay reserva con ese folio, o la hay pero no la creó esta conexión. | Revisa el folio. Las dos cosas contestan igual a propósito. |
| 409 | OFFER_EXPIRED | La oferta pasó de treinta minutos, o esa tarifa ya no está disponible. | Vuelve a pedir la disponibilidad y enseña lo que quedó. |
| 409 | PRICE_CHANGED | El precio cambió desde que se cotizó. | Vuelve a cotizar y enseña el precio nuevo antes de cobrar. |
| 409 | BOOKING_ALREADY_CANCELLED | Esa reserva ya está cancelada. | Léela y enseña cómo quedó. Cancelarla otra vez no cambia nada. |
| 409 | NOTHING_TO_CHARGE | Esa reserva no tiene saldo pendiente. | Manda al huésped a la página de gracias. |
| 409 | NO_PROPERTY | El hotel todavía no tiene ninguna propiedad dada de alta. | No reintentes. Se arregla en el panel del hotel. |
| 422 | VALIDATION_ERROR | Un dato que no sirve. «details.field» dice cuál: checkIn, checkOut, propertyId, offers, body, guest.firstName, guest.lastName, guest.email, idempotencyKey o amountCents. | Corrige ese campo y vuelve a mandar. |
| 422 | BOOKING_OVER_LIMIT | La reserva pasa del tope por reserva que el hotel puso a esta llave. | No se creó nada. El hotel sube el tope desde su panel si toca. |
| 422 | TOO_MANY_ROOMS | Más de ocho habitaciones en una sola reserva. | Un grupo se cotiza aparte, con el hotel. |
| 422 | MIXED_DATES | Las habitaciones del carrito no comparten fechas. | Sepáralas en dos reservas. |
| 422 | CHILDREN_NOT_ADMITTED | El hotel es solo para adultos. | Dilo antes de pedir los datos del huésped. |
| 422 | CHILD_BELOW_MIN_AGE | El hotel no admite menores de esa edad. | Enseña la edad mínima en tu buscador. |
| 413 | PAYLOAD_TOO_LARGE | El cuerpo pasa de 64 KB. | Manda menos ofertas: caben ocho de sobra. |
| 429 | RATE_LIMIT_EXCEEDED | Más de 300 peticiones por minuto desde la misma IP. | Espera lo que diga Retry-After y reintenta. |
| 503 | PAYMENTS_NOT_CONFIGURED | El cobro en línea no está configurado para este hotel. | Consultar, cotizar y reservar siguen funcionando. Lo único que no se puede es cobrar; avisa al hotel. |
¿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.