Bookings & Webhooks
Channex delivers OTA bookings as revisions. There are two delivery paths that share the same apply→ack core, and we run both: a webhook (low-latency push) and a scheduled poll of the unacked revision feed (the backstop for any missed webhook). Each ingested revision is turned into a real PMS booking.
Two delivery paths
Webhook (push)
POST /api/channex/webhook?channelId=chnl_X
This path is public (permitted in SecurityConfig) — Channex calls it without our JWT — and always
returns 200 OK (Channex retries on non-2xx, so failures are logged/persisted, not surfaced). The
webhook is registered automatically during onboarding when channex.webhook.base-url
is set; the channelId is embedded in the callback URL so the handler resolves the right credentials.
The webhook carries only a revision_id — it is a notification, not the booking itself.
Feed poll (backstop)
ChannexFeedPoller polls the account-wide unacked feed on a schedule:
GET /booking_revisions/feed (every 15 min by default — channex.feed.poll-interval-ms)
The feed is a 30-minute window, not a durable queue: an unacked revision is re-served for ~30 minutes and then drops out permanently. Polling every 15 minutes gives two cycles inside that window, so a missed webhook is still caught with margin. The poller drains until empty (the feed is paginated, oldest first) and acks-and-skips revisions for properties not in our mapping table so a stray test property can't wedge the account-wide feed.
Ingestion flow
Both paths converge on the same core (ChannexBookingService):
1. Take the revision_id (webhook) or each feed entry.
2. Pull the authoritative revision → GET /booking_revisions/{id}
3. Persist it raw (idempotent on channex_booking_id) as RECEIVED.
4. Acknowledge the REVISION → POST /booking_revisions/{id}/ack → ACKED
5. Map it into the PMS domain (below) — best-effort; the raw row survives for re-apply.
Acking is per revision, not per booking. The raw channex_booking row is always written and acked
first, so a mapping failure never blocks the queue and can be re-applied from the stored payload.
Mapping a booking into the PMS
ChannexOtaBookingService converts a revision into a real booking in the shared bookings table. The
Channex booking id is used as the PMS bookings.id (the same pattern as the Livbnb integration),
which makes ingestion idempotent and cancellation a simple lookup.
| Revision status | Action |
|---|---|
new | Create the booking, allocate a free unit per room, decrement inventory per night |
cancelled | Mark the booking CANCELLED and release its inventory |
modified | Logged for manual reconciliation — not auto-applied (date/room/price changes need UX the PMS doesn't have yet) |
Key behaviours:
- Money is reverse-derived. An OTA sends one gross (tax-inclusive) amount, so GST is reverse-derived
per room-night using the same date-aware India GST slab as cart bookings (
HotelGst: net > ₹7499 → 18%; otherwise 5% for nights after 2025-09-21, else 12% legacy), and the 2.04% platform fee is applied to the net stay.payment_collect = ota⇒ paid in full; otherwise outstanding at the property. - Guest reconciliation by email/phone (
GuestDao), creating a guest when none matches. - Unit allocation = active units of the property minus those booked on overlapping nights.
- Never drops a booking. If no unit is free or inventory is short, the booking is still written
(flagged overbooked in
remarks) rather than rejected — a guest with an OTA confirmation exists regardless. source= the OTA name (e.g.Booking.com),is_ota_booking = true, and the OTA reservation code is kept inremarks.- Dedupe via
existsByIdon the Channex booking id — the feed and webhook can both deliver the same revision.
Auto-mapping is on by default and can be disabled with channex.booking.auto-apply=false (the raw
revision is still stored and acked).
Recovery after an outage
The feed only retains unacked revisions for ~30 minutes, so a poller outage longer than that loses them
from the feed. Recovery is a manual, time-scoped pull of the durable booking list
(GET /bookings?filter[inserted_at][gte]=<outage_start>), backfilled through the same apply logic and
deduped by Channex booking id — not a periodic cron (which would re-pull the same bookings forever).
Inspect ingested bookings
GET /api/v1/admin/channex/bookings?limit=50
Returns recent channex_booking rows (capped at 200), newest first, with status, event, payload,
and acknowledgedAt.
Testing inbound
There is no API to inject test bookings. In the Channex dashboard: Applications → add "Booking CRS" → create a booking manually; it arrives via the feed within a minute and flows through to a PMS booking. Then cancel it and confirm the cancellation releases inventory.