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.

FieldTypeRequiredMeaning
booking_codestringrequiredThe 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

StatusErrorWhen
400booking_code requiredThe body has no booking_code.
404unknown_booking_codeNo 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

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe stay the session token belongs to.
FieldTypeRequiredMeaning
analytics_consentnumberrequiredExactly 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

StatusErrorWhen
400analytics_consent must be 0 or 1The field is missing or is any other value.
401unauthorizedNo 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

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe 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

StatusErrorWhen
--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

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe 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:

  • recommendations comes from computeRecommendations in recommend.ts and is empty unless analytics_consent is 1.
  • requests is the stay's requests, newest first (the handler reverses the oldest-first order stayContext returns).
  • offers is the guest view of this stay's offer instances. Reading this route stamps any sent instance as viewed and emits offer_viewed once. See offersForStay in offers.ts.
  • group is null when the stay has no active group. When it has one, the object carries group_id, label, join_code, role, members, and activities (see groupViewForStay in groups.ts for the activity shape).
  • last_day is true when the stay's departure date equals the property's calendar day in America/Nassau (see property-time.ts).
  • concierge_url and timelens_url are null unless the stay consented, the server was started with the matching URL, and the stay has a porter_ref. They are consent-gated so an unconsented guest's taps never carry a ref that resolves to their stay.
  • concierge_suggestions are up to five QR Find upsells recorded for this stay, unopened first (see conciergeSuggestionsForStay in porter-upsells.ts).
  • chips and usher come from journeyForStay in concierge.ts. See the Concierge page for the arc they follow.

Errors

StatusErrorWhen
401unauthorizedNo 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

ParamTypeRequiredMeaning
code (path)stringrequiredBooking 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

StatusErrorWhen
404unknown_booking_codeNo 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.