Skip to main content

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 statusAction
newCreate the booking, allocate a free unit per room, decrement inventory per night
cancelledMark the booking CANCELLED and release its inventory
modifiedLogged 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 in remarks.
  • Dedupe via existsById on 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.