Skip to Content
Bronit HotelPMS integration (Exely)

PMS integration (Exely)

Connects a hotel to its property-management system so Bronit stops being a second place to type room numbers. Exely is the only provider today; HotelIntegration.provider exists so the next one is a new value, not a second subsystem.

The one thing to understand first

An Exely key is a property. No request carries a hotel id — Exely scopes every response to whichever property issued the key. Point Bronit Hotel Tashkent at Fergana’s key and you get Fergana’s rooms and Fergana’s guests, with HTTP 200 and no warning.

Everything below follows from that:

  • the integration hangs off Hotel, never Company — a two-hotel company connects twice, with two keys;
  • resolveHotelExelyKey() has no environment fallback, because falling back to a shared key silently answers the wrong hotel’s question;
  • the connect flow forces a test before save, and shows room numbers rather than a success tick, because those numbers are the only way an admin can tell it is their own property. Exely exposes no hotel-name endpoint to check against.

Setup

# 32 random bytes. Losing it means re-entering every stored key. node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# .env INTEGRATION_ENC_KEY=<the value above> CRON_SECRET=<already set, guards /api/cron/*>

Owner → Settings → Hotel Integrations → Integrations → Connect → paste key → Test key → name the room types → Import rooms.

How the key is stored

HotelIntegration.apiKeyCipher, AES-256-GCM via src/lib/secretBox.ts:

  • authenticated, so a tampered ciphertext fails to decrypt rather than yielding garbage we would then send to Exely as a key;
  • random IV per encryption, so the same key stored twice looks different;
  • select: false, so a stray .find() in a route cannot serialise it;
  • never returned by any route — the UI gets ••••3a2b.

This protects a leaked database dump. It does not protect against an attacker who already runs the server process, since that process must be able to decrypt. Rotating INTEGRATION_ENC_KEY invalidates every stored key (each hotel re-enters theirs) and is the response to a server compromise.

What Exely can and cannot tell us

Verified against the live Swagger, not assumed:

Room numbers✅ RoomDto.name — “105”, what staff say
Room count, and which rooms share a type✅ via roomTypeId
Room type names❌ no endpoint exists — the admin names each group once
Hotel name❌ no property/identity endpoint at all
Floor⚠️ floorId is opaque; floor is derived from the number (101 → 1)

floorFromRoomNumber returns null rather than guessing for anything that is not 3–4 digits, and the import preview lists those rooms so an admin can catch them instead of silently getting floor 1.

Room import

Match by number, add missing, never delete. A room in Bronit but not in Exely is reported and left alone: the likeliest cause is a key pointed at the wrong property, and the cost of being wrong is a room whose printed QR code stops resolving. An admin’s own room-type wording is never overwritten — the import only fills a blank one.

Imported rooms are ordinary Room records, so room QR codes, the guest menu and TV bindings all work off them with no special-casing.

Reservation sync

GET /api/cron/exely-sync — hourly, guarded by CRON_SECRET.

/v1/bookings returns booking numbers only, so every booking costs a second request; a full sweep of a busy property is ~1 + N calls. The hourly run is therefore incremental — filtered by modifiedFrom, so it reads only bookings that changed since the last run, usually a handful and often none. The cursor is rewound 15 minutes each time, because Exely’s modification clock is not ours and re-reading a few minutes is cheaper than losing a booking.

Once a day (?full=1, or automatically) it runs a full sweep instead, to reconcile anything an incremental pass could miss. One hotel’s failure never stops the others, and a failed run does not advance the cursor.

Guest data

RoomOccupancy holds no personal data by design: room number, dates, booking number, guest count. Nothing else.

Exely will hand over names, dates of birth, citizenship and passport records. When a screen genuinely needs a guest’s name — a TV greeting — the caller fetches it live and room-scoped at display time via findExelyGuestsByRoom and discards it. A database leak is then not a guest-data leak, and our retention duty stays “delete the booking window”.

Two standing rules, from the vendor research and worth repeating:

  1. Never log it, never write it to disk. exelyFetch deliberately keeps Exely’s error body out of thrown messages, because a failed guest call can carry a fragment of a profile into whatever catches it.
  2. Room-scope every lookup. A TV bound to room 105 may learn about room 105 and nothing else. Guest lookups key off the device’s own binding, never a room number supplied by the caller — otherwise any device on the network can enumerate the hotel’s guests one number at a time.

Files

FileRole
src/lib/exely.tsPure HTTP client. No database.
src/lib/exelyIntegration.tsKey resolution, connection test, room import, sync.
src/lib/secretBox.tsAES-256-GCM for stored vendor credentials.
src/models/HotelIntegration.tsOne connection per hotel per provider.
src/models/RoomOccupancy.tsOccupancy mirror. No personal data.
src/app/api/hotels/[id]/integrations/exely/**Status, save, test, import.
src/app/api/cron/exely-sync/route.tsHourly incremental + daily full sync.
src/services/core/admin/settings/hotels/components/IntegrationsSection.tsxOwner UI — the Integrations tab.