Recibir eventos por webhook
En lugar de preguntar cada pocos minutos si entró una reserva, una integración
puede registrar una URL y dejar que Axis Pro le avise: cuando una reserva se
crea, se edita, se confirma, hace check-in o check-out, se cancela o se paga, y
cuando una habitación queda sucia o lista, llega un POST firmado a esa URL.
El evento avisa; no trae la reserva. El cuerpo lleva identificadores, el
código y el estado — nada del huésped, ni montos, ni fechas. Para saber qué
pasó hay que pedir el recurso condata.url, usando la propia API key. Así
la entrega nunca muestra más de lo que la key puede leer, y un evento viejo
que llega tarde no puede engañar a nadie:data.urlsiempre devuelve el
estado actual.
Los eventos
Hay quince, en tres familias:
- Reservas:
booking.created,booking.updated,booking.deleted,
booking.restored,booking.guest_changed, y los cambios de estado
booking.status_changed,booking.confirmed,booking.checked_in,
booking.checked_outybooking.cancelled. - Dinero:
booking.payment_registered,booking.payment_revertedy
booking.refunded. - Habitaciones:
unit.readyyunit.dirty.
Qué significa cada uno, cuándo llega, qué trae y con qué otros eventos viaja
está en el Catálogo de eventos.
GET /webhooks/events devuelve la misma lista, para que una integración no
tenga que mantenerla a mano.
Suscribirse
POST /api/external/v1/webhooks
X-API-Key: axis_live_…
Content-Type: application/json
{
"url": "https://integracion.example.com/axis/webhooks",
"events": ["booking.created", "booking.cancelled"],
"property_ids": ["01a06051-c4e9-7011-a5f2-3d4c5b6a7980"],
"description": "CRM de reservas"
}{
"data": {
"id": "01a0a3c2-5b7e-7d11-9f3e-0c2b1a4d5e6f",
"url": "https://integracion.example.com/axis/webhooks",
"description": "CRM de reservas",
"events": ["booking.created", "booking.cancelled"],
"property_ids": ["01a06051-c4e9-7011-a5f2-3d4c5b6a7980"],
"status": "active",
"disabled_reason": null,
"consecutive_failures": 0,
"created_by_type": "api_key",
"created_by_id": "…",
"created_at": "2026-09-28T15:04:05+00:00",
"updated_at": "2026-09-28T15:04:05+00:00"
},
"secret": "whsec_3q2-7wAbCdEfGhIjKlMnOpQrStUvWxYz0123456789a",
"message": "Save this signing secret now. It will not be shown again."
}El secret aparece una sola vez, en esta respuesta. No hay forma de volver
a leerlo: si se pierde, se genera otro con
rotate-secret.
Tres reglas deciden si la suscripción se acepta:
-
Cada familia de eventos pide su propio alcance, además de
webhooks:write:booking.*exigebookings:read:all. El flujo cubre todas las reservas
de la propiedad, no sólo las que creó la key, así que pide el mismo
alcance que leerlas todas.unit.*exigehousekeeping:read, el mismo alcance que leer el estado de
las habitaciones.
Sin el alcance que corresponde, la respuesta es
403 WEBHOOK_EVENT_NOT_PERMITTED. La regla vale también para cualquier
cambio posterior sobre esa suscripción — editarla, rotar su secreto,
activarla, probarla o borrarla. -
property_idsvacío u omitido significa todas las propiedades del hotel.
Una key limitada a algunas propiedades tiene que nombrarlas, y sólo puede
nombrar las suyas; si no, es un422sobreproperty_ids. -
La URL tiene que ser
httpsy resolver a una dirección pública. Una URL a
localhost, a una red privada o a la dirección de metadatos de la nube se
rechaza con un422sobreurl. La regla se vuelve a revisar antes de cada
entrega, no sólo al crear.
La suscripción es de la cuenta del hotel, no de la key que la creó.
Revocar esa key no la apaga; para dejar de recibir eventos hay que
desactivarla o borrarla.
Lo que llega
Cada entrega es un POST a la URL, con Content-Type: application/json:
{
"id": "0b5f8c1e-1111-4222-8333-444455556666",
"type": "booking.status_changed",
"api_version": "v1",
"created_at": "2026-09-28T15:04:05Z",
"tenant": "…",
"data": {
"object": "booking",
"id": "01a06051-c523-7396-a1b7-273f29c9f7e3",
"property_id": "01a06051-c4e9-7011-a5f2-3d4c5b6a7980",
"code": "P8582-000001",
"status": "cancelled",
"previous_status": "confirmed",
"url": "https://api.axispro.travel/api/external/v1/bookings/01a06051-c523-7396-a1b7-273f29c9f7e3"
}
}| Campo | Qué es |
|---|---|
id | Identificador del evento. Es la llave para descartar duplicados. |
type | Uno de los eventos de arriba. |
created_at | Cuándo ocurrió, en UTC. |
tenant | La cuenta del hotel. Una integración que atiende a varios hoteles lo usa para elegir con cuál de sus keys pedir la reserva. |
data.previous_status | En los eventos de cambio de estado: status_changed, confirmed, checked_in, checked_out y cancelled. |
data.changes | Sólo en booking.updated: una lista con dates, unit, occupancy, price u other. Dice qué cambió, nunca los valores; nunca viene vacía. |
data.transaction_id | Sólo en los eventos de pago y reembolso. No trae el monto: paid_amount y balance_due se leen de la reserva. |
data.url | El recurso en esta API — la reserva, o el estado de la habitación. Pedirlo con GET y la key del hotel correspondiente. |
Dos casos en los que data.url responde 404 y es lo esperado:
booking.deleted (la reserva está borrada) y un booking.payment_reverted de
una reserva que se borró antes de revertir el pago.
Y dos headers:
Axis-Webhook-Id: 0b5f8c1e-1111-4222-8333-444455556666
Axis-Signature: t=1790625410,v1=37606fb9c994af939552c7ac77ff82749aca1bc03c000c1996ec97ae0e72cbcbAntes de mandar la API key a
data.url, hay que comprobar que su host es
el de Axis Pro — el mismo host al que la integración ya le habla. Es una
defensa barata: si alguna vez llegara un evento que no vino de Axis Pro, la
key no sale hacia otro lado.
Estado de las habitaciones
Los eventos unit.* están pensados para cerraduras, mensajería de check-in
anticipado y herramientas de limpieza: avisan cuando una habitación queda
sucia o lista, sin tener que consultar su estado cada pocos minutos.
{
"id": "5d3c1a90-2222-4333-8444-555566667777",
"type": "unit.ready",
"api_version": "v1",
"created_at": "2026-09-28T16:20:00Z",
"tenant": "…",
"data": {
"object": "unit",
"id": "01a06051-69f2-724a-bfe0-57b5b5dab318",
"property_id": "01a06051-c4e9-7011-a5f2-3d4c5b6a7980",
"inventory": { "id": "01a06051-7a10-7c1d-9e2f-3a4b5c6d7e8f", "label": "204" },
"state": "clean",
"ready": true,
"url": "https://api.axispro.travel/api/external/v1/units/01a06051-69f2-724a-bfe0-57b5b5dab318/housekeeping"
}
}Lista no es lo mismo que limpia. El estado de una habitación es dirty,
clean o inspected, y ready dice si ya se puede entregar:
- En un hotel que no exige inspección,
cleanya es lista. - En un hotel que sí exige inspección, una habitación limpia sigue sin estar
lista hasta que un supervisor la marca comoinspected.unit.readyllega en
ese momento, no al terminar la limpieza. inspectedsiempre es lista.
La habitación física. En una unidad de una sola habitación, inventory es
null y id basta. En un Room Type, id es el Room Type y inventory nombra
la habitación concreta (label es el número o nombre que usa el hotel). Para
saber qué habitación física tiene una reserva, GET /bookings/{booking} trae
unit.inventory con el mismo id y label.
Leer el estado actual: GET /units/{unit}/housekeeping, con el alcance
housekeeping:read:
{
"data": {
"unit_id": "01a06051-69f2-724a-bfe0-57b5b5dab318",
"housekeeping": null,
"inventories": [
{ "id": "01a06051-7a10-7c1d-9e2f-3a4b5c6d7e8f", "label": "204", "housekeeping": { "state": "clean", "ready": true } },
{ "id": "01a06051-7a10-7c1d-9e2f-3a4b5c6d7e90", "label": "205", "housekeeping": { "state": "dirty", "ready": false } }
]
}
}Una unidad de una sola habitación responde con housekeeping lleno e
inventories vacío. Este endpoint encuentra cualquier habitación de la
propiedad, aunque el hotel no la venda por esta API — a diferencia de
GET /units/{unit}, que sólo muestra lo que se puede vender. Una habitación
sin estado registrado se lee como clean.
Dos cosas a tener en cuenta:
- El check-out ya ensucia la habitación. No hace falta esperar a que alguien
la marque: el check-out produceunit.dirtypor sí solo. unit.readysólo avisa cuando la habitación entra en lista. Si deja de
estarlo sin ensuciarse — por ejemplo, el hotel empieza a exigir inspección —
no llega evento; el estado actual siempre está en el endpoint.
Verificar la firma
Cualquiera que conozca la URL puede mandarle un POST. La firma es lo que
prueba que la entrega vino de Axis Pro y que nadie la alteró en el camino.
Axis-Signature trae dos partes: t, el momento de la firma en segundos Unix,
y v1, un HMAC-SHA256 en hexadecimal. El mensaje firmado es el valor de t,
un punto y el cuerpo crudo de la petición, tal como llegó:
v1 = HMAC-SHA256(secret, t + "." + cuerpo_crudo)Verificarla correctamente exige tres cosas:
- Usar el cuerpo crudo, no el JSON ya parseado. Volver a serializar un
objeto cambia espacios y orden de las llaves, y la firma deja de coincidir. - Comparar en tiempo constante (
timingSafeEqual,hash_equals), nunca
con==. - Rechazar un
tde más de cinco minutos. Sin ese límite, alguien que
capture una entrega válida puede reenviarla cuando quiera.
Node.js
import crypto from 'node:crypto';
import express from 'express';
function axisSignatureIsValid(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((pair) => pair.split('=')));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw deja el cuerpo como Buffer, exactamente como llegó.
app.post('/axis/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Axis-Signature') ?? '';
if (!axisSignatureIsValid(req.body, header, process.env.AXIS_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// Encolar el trabajo y responder enseguida.
res.sendStatus(204);
});PHP
function axisSignatureIsValid(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
$timestamp = (int) ($parts['t'] ?? 0);
if ($timestamp === 0 || abs(time() - $timestamp) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_AXIS_SIGNATURE'] ?? '';
if (! axisSignatureIsValid($rawBody, $header, getenv('AXIS_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(204);Responder rápido y dejar que reintente
Cualquier 2xx en menos de 10 segundos cuenta como recibido. Todo lo demás
es un fallo: un 4xx o 5xx, un timeout, un error de conexión — y también una
redirección, porque Axis Pro no sigue un 3xx. Si la URL cambió, hay que
actualizar la suscripción.
Lo que conviene es verificar la firma, guardar o encolar el evento, responder
204 y hacer el trabajo después. Una integración que llama a su propia base de
datos o a otra API antes de responder termina pasando los 10 segundos en el peor
momento.
Una entrega fallida se reintenta con espera creciente: ocho intentos en
aproximadamente 29 horas (1 min, 5 min, 30 min, 2 h, 5 h, 10 h, 12 h). Una
caída de unas horas no pierde eventos.
Diez eventos seguidos que agotan todos sus reintentos desactivan la
suscripción. Queda con status: "disabled" y
disabled_reason: "consecutive_failures", y el hotel recibe un aviso. Cualquier
entrega exitosa en el medio pone la cuenta en cero. Para reanudar hay que
reactivarla explícitamente con POST /webhooks/{subscription}/enable, que
también pone la cuenta en cero.
Duplicados y orden
Las entregas son al menos una vez: un evento puede llegar dos veces — por
ejemplo, si la respuesta se perdió en la red después de procesarlo. Hay que
descartar duplicados por el id del evento (el mismo valor viene en
Axis-Webhook-Id).
Y no llegan en orden. Un reintento de booking.updated puede llegar
después de un booking.cancelled más nuevo. Por eso el evento no trae el estado
completo: ante cualquier evento de una reserva, lo correcto es pedir data.url
y quedarse con lo que diga — ese sí es el estado actual.
Probar la URL
POST /webhooks/{subscription}/test manda un evento webhook.test por el
camino normal — firmado, con los mismos reintentos. Sirve para comprobar de
punta a punta que la URL responde y que la verificación de la firma funciona,
antes de que llegue una reserva real. webhook.test no se puede elegir en
events: sólo lo manda esta acción.
Rotar el secreto
POST /webhooks/{subscription}/rotate-secret genera un secreto nuevo y lo
devuelve una sola vez, igual que al crear. El anterior deja de verificar en
ese mismo instante — no hay periodo de gracia. Conviene tener lista la
configuración con el secreto nuevo antes de rotarlo; las entregas que lleguen
entre la rotación y el cambio van a fallar la verificación y se reintentarán.
Qué suscripciones ve una key
Una key ve y administra sólo las suscripciones creadas por la API — por
cualquier key de la cuenta, así que una key nueva puede seguir administrando las
de la que reemplazó. Las que el hotel creó desde su propia pantalla no aparecen.
Una key limitada a algunas propiedades, además, sólo ve las suscripciones cuyas
propiedades caen todas dentro de las suyas; una suscripción de todo el hotel es
más ancha que ella y tampoco aparece.
Todo lo que una key no ve responde
404 WEBHOOK_SUBSCRIPTION_NOT_FOUND, igual que un id que no existe.
Relacionado
Updated 33 minutes ago