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, neverCompany— 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:
- Never log it, never write it to disk.
exelyFetchdeliberately 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. - 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
| File | Role |
|---|---|
src/lib/exely.ts | Pure HTTP client. No database. |
src/lib/exelyIntegration.ts | Key resolution, connection test, room import, sync. |
src/lib/secretBox.ts | AES-256-GCM for stored vendor credentials. |
src/models/HotelIntegration.ts | One connection per hotel per provider. |
src/models/RoomOccupancy.ts | Occupancy mirror. No personal data. |
src/app/api/hotels/[id]/integrations/exely/** | Status, save, test, import. |
src/app/api/cron/exely-sync/route.ts | Hourly incremental + daily full sync. |
src/services/core/admin/settings/hotels/components/IntegrationsSection.tsx | Owner UI — the Integrations tab. |