API reference
Identity and stays
How a booking code becomes a recognized stay with a session token, and the routes that read that stay back.
This family turns a booking code into a recognized stay, records the guest's analytics consent, and serves the two read views built on top of that stay. The Concierge (guest web) calls all of it except GET /v1/stays/:stay_id/context, which Hotel Operator (staff web) calls to open a guest's record. GET /v1/confirmation/:code is the confirmation email front door and runs before any session exists.
POST /v1/recognize
Exchange a booking code for a stay record and a guest session token.
Auth: unauthenticated in this build. This route issues the session that the rest of the guest routes require.
Request
No path or query params.
| Field | Type | Required | Meaning |
|---|---|---|---|
booking_code | string | required | The property's booking code, matched exactly against stays.booking_code. |
POST /v1/recognize
POST /v1/recognize
content-type: application/json
{ "booking_code": "SOL-655" }
Response
JSON
{
"stay": {
"stay_id": "stay-655",
"guest_first_name": "Aretha",
"room": "655",
"tier": "gold",
"party_size": 9,
"analytics_consent": null
},
"session_token": "0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7"
}
The stay object is the trimmed public view built by publicStay in server.ts. The full Stay row (booking code, dates, porter_ref, session_token) never leaves this route.
Errors
| Status | Error | When |
|---|---|---|
| 400 | booking_code required | The body has no booking_code. |
| 404 | unknown_booking_code | No stay matches the code, and the reservation source (when one is wired) returned nothing. |
POST /v1/stays/:stay_id/consent
Record the guest's analytics consent decision for this stay.
Auth: x-gi-session, matched against this stay_id.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
stay_id (path) | string | required | The stay the session token belongs to. |
| Field | Type | Required | Meaning |
|---|---|---|---|
analytics_consent | number | required | Exactly 0 (declined) or 1 (granted). Any other value is rejected. |
POST /v1/stays/stay-655/consent
POST /v1/stays/stay-655/consent
content-type: application/json
x-gi-session: 0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7
{ "analytics_consent": 1 }
Response
JSON
{ "ok": true }
Errors
| Status | Error | When |
|---|---|---|
| 400 | analytics_consent must be 0 or 1 | The field is missing or is any other value. |
| 401 | unauthorized | No x-gi-session header, or the token does not match this stay_id. |
GET /v1/stays/:stay_id/context
The operator-facing record for one stay: requests, recommendations, actions, service-recovery events, and group membership.
Auth: unauthenticated in this build. The handler compensates by stripping the guest's session_token, booking_code, and porter_ref from the stay before returning it (see stayContext in requests.ts).
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
stay_id (path) | string | required | The stay to read. |
No body, no query params.
Response
JSON
{
"stay": {
"stay_id": "stay-655",
"property_slug": "solara-cove",
"guest_first_name": "Aretha",
"room": "655",
"tier": "gold",
"party_size": 9,
"arrival_date": "2026-08-28",
"departure_date": "2026-09-01",
"analytics_consent": 1,
"recognized_at": "2026-08-30T18:00:00Z",
"origin": "live",
"source_system": "fixture",
"external_ref": null
},
"requests": [
{
"id": "7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44",
"stay_id": "stay-655",
"type": "dining",
"details": "{\"party_size\":9,\"preferred_time\":\"19:30\"}",
"queue_id": "dining",
"status": "done",
"owner_staff_id": "dana",
"recommendation_id": "a1b2c3d4-0000-4000-8000-000000000001",
"result_kind": "booking",
"result_ref": "SC-4821",
"result_value_usd": 309.96,
"created_at": "2026-08-30T18:00:00Z",
"claimed_at": "2026-08-30T18:02:00Z",
"closed_at": "2026-08-30T18:05:00Z"
}
],
"recommendations": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"stay_id": "stay-655",
"rule_id": "group_dining",
"headline": "The chef's table seats large parties",
"body": "Want us to hold it for your group tonight?",
"request_type": "dining",
"status": "acted",
"created_at": "2026-08-30T18:00:00Z"
}
],
"actions": [
{
"id": "0f9a7c22-5b3e-4a2e-9c0f-3d5b7e1a9c20",
"stay_id": "stay-655",
"kind": "booking",
"source": "7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44",
"value_usd": 309.96,
"acted_at": "2026-08-30T18:05:00Z",
"origin": "live"
}
],
"recovery_events": [
{
"event_type": "friction_detected",
"occurred_at": "2026-08-30T18:00:00Z",
"params": { "pattern": "queue_dwell" }
}
],
"group_label": null,
"group_size": null
}
requests is ordered oldest first. recovery_events carries only events whose source is make_it_right, with params already parsed from JSON. details on a request stays a JSON string, exactly as stored.
See core/src/__tests__/server.test.ts ("stay context response omits guest bearer token and booking code") and core/src/__tests__/requests.test.ts ("queue counts and stay context federate requests and recovery events") for the shape this route is tested against.
Errors
| Status | Error | When |
|---|---|---|
| - | - | This route declares no error responses. An unknown stay_id is not handled: the handler reads a missing row and fails inside stayContext (see requests.ts). |
GET /v1/stays/:stay_id/home
Everything the Concierge home screen renders for one recognized stay, in a single call.
Auth: x-gi-session, matched against this stay_id.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
stay_id (path) | string | required | The stay to render. |
No body, no query params.
Response
JSON
{
"stay": {
"stay_id": "stay-655",
"guest_first_name": "Aretha",
"room": "655",
"tier": "gold",
"party_size": 9,
"analytics_consent": 1
},
"recommendations": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"stay_id": "stay-655",
"rule_id": "group_dining",
"headline": "The chef's table seats large parties",
"body": "Want us to hold it for your group tonight?",
"request_type": "dining",
"status": "shown",
"created_at": "2026-08-30T18:00:00Z"
}
],
"requests": [],
"offers": [
{
"instance_id": "6d2b0f61-9c2a-4f0e-9f4a-1c2b3d4e5f60",
"headline": "A cabana is open today",
"body": "Shade, towels, and a dedicated server by the main pool. Yours for the day.",
"label": "Cabana day pass",
"price_usd": 150,
"expires_at": "2026-09-03T22:00:00.000Z",
"status": "viewed",
"result_ref": null,
"intent_note": "You asked about the spa."
}
],
"group": null,
"last_day": false,
"concierge_url": "https://qrfind.example/chat?stay_ref=1b9d0c2e-77aa-4f31-9a6f-0c1d2e3f4a5b",
"timelens_url": "https://timelens.example/solara?property=solara-cove&ref=1b9d0c2e-77aa-4f31-9a6f-0c1d2e3f4a5b&src=stay-app",
"concierge_suggestions": [],
"chips": [
"Book a table tonight",
"Any offers?",
"We are traveling with friends",
"What was this place before?"
],
"usher": "Hungry? Ask me for a table tonight."
}
Field notes:
recommendationscomes fromcomputeRecommendationsinrecommend.tsand is empty unlessanalytics_consentis1.requestsis the stay's requests, newest first (the handler reverses the oldest-first orderstayContextreturns).offersis the guest view of this stay's offer instances. Reading this route stamps anysentinstance asviewedand emitsoffer_viewedonce. SeeoffersForStayinoffers.ts.groupisnullwhen the stay has no active group. When it has one, the object carriesgroup_id,label,join_code,role,members, andactivities(seegroupViewForStayingroups.tsfor the activity shape).last_dayis true when the stay's departure date equals the property's calendar day inAmerica/Nassau(seeproperty-time.ts).concierge_urlandtimelens_urlarenullunless the stay consented, the server was started with the matching URL, and the stay has aporter_ref. They are consent-gated so an unconsented guest's taps never carry a ref that resolves to their stay.concierge_suggestionsare up to five QR Find upsells recorded for this stay, unopened first (seeconciergeSuggestionsForStayinporter-upsells.ts).chipsandushercome fromjourneyForStayinconcierge.ts. See the Concierge page for the arc they follow.
Errors
| Status | Error | When |
|---|---|---|
| 401 | unauthorized | No x-gi-session header, or the token does not match this stay_id. |
GET /v1/confirmation/:code
The confirmation email front door: read a stay by its booking code without being recognized.
Auth: unauthenticated in this build.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
code (path) | string | required | Booking code. Trimmed and upper-cased before lookup, so sol-412 resolves. |
No body, no query params.
Response
JSON
{
"property_name": "Solara Cove Resort",
"booking_code": "SOL-412",
"guest_first_name": "John",
"arrival_date": "2026-09-01",
"departure_date": "2026-09-05",
"room": "412",
"party_size": 2,
"tier": "standard"
}
Dates are cut to ten characters. Vendor-sourced stays store yyyy-MM-dd HH:mm:ss, and this route returns only the date part. See core/src/__tests__/server.test.ts ("GET /v1/confirmation/:code").
Errors
| Status | Error | When |
|---|---|---|
| 404 | unknown_booking_code | No stay carries that booking code. |
Notes
How the session token is issued. POST /v1/recognize writes a fresh session_token onto the stay row on every call and returns it. The token is the whole credential: requireSession in recognize.ts looks up a stay by stay_id and session_token together, so a token only ever works for its own stay. Recognizing again rotates the token and invalidates the previous one. recognized_at is first-touch only and is never overwritten. Both facts are pinned in core/src/__tests__/recognize.test.ts.
Recognition can create a stay. When the booking code is not in the local database and the server was started with a reservation source, recognize asks the vendor sandbox for the reservation and upserts it as a live-origin stay (see stay-sync.ts). A source that fails is treated as a miss: the core emits source_unreachable and answers unknown_booking_code.
Consent drives the pipeline, not service. Consent changes what the platform remembers and forwards, not whether a guest can ask for something. Recommendations, offer eligibility, Openings evidence, and the porter_ref-bearing links on /home all require analytics_consent = 1. Recording consent as 1 (on either POST /v1/recognize or POST /v1/stays/:stay_id/consent) also sends the stay to QR Find so it can hold questions against that ref.
The email front door leaves no trace. GET /v1/confirmation/:code writes nothing and emits nothing. Reading the confirmation email must not count as being recognized, so recognized_at stays null and no event lands.