API reference

Requests and queues

Creating a guest request, routing it to a queue, the claim, complete, decline, and hand off verbs Hotel Operator uses to work it, and the escalation QR Find hands back as a request.

A guest asks for something, the core routes it to a queue by type, and a staff member claims, completes, or declines it. The Concierge (guest web) creates requests. Hotel Operator (staff web) reads the queues and the staff list and drives the four lifecycle verbs, claim, complete, decline, and the hand off that passes a claimed request to someone else. A completed request writes a simulated booking and, where a vendor sink is wired, posts the charge to the folio.

POST /v1/requests

Create a request on behalf of a recognized stay.

Auth: x-gi-session, matched against the stay_id in the body.

Request

No path or query params.

FieldTypeRequiredMeaning
stay_idstringrequiredThe stay the request belongs to. Must match the session token.
typestringrequiredOne of dining, spa, housekeeping, amenity, other, luggage_hold, rebook_hold, concierge. The type picks the queue through routing in the property config.
detailsobjectoptionalFlat map of scalars (string, number, boolean, or null). Stored as a JSON string. Common keys are party_size, preferred_time, and note. A non-scalar value is rejected.
recommendation_idstringoptionalThe recommendation this request acted on. Completing the request flips that recommendation to acted.

POST /v1/requests

POST /v1/requests
content-type: application/json
x-gi-session: 0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7

{
  "stay_id": "stay-655",
  "type": "dining",
  "details": { "party_size": 9, "preferred_time": "19:30" },
  "recommendation_id": "a1b2c3d4-0000-4000-8000-000000000001"
}

Response

JSON

{
  "request": {
    "id": "7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44",
    "stay_id": "stay-655",
    "type": "dining",
    "details": "{\"party_size\":9,\"preferred_time\":\"19:30\"}",
    "queue_id": "dining",
    "status": "open",
    "owner_staff_id": null,
    "recommendation_id": "a1b2c3d4-0000-4000-8000-000000000001",
    "result_kind": null,
    "result_ref": null,
    "result_value_usd": null,
    "created_at": "2026-08-30T18:00:00Z",
    "claimed_at": null,
    "closed_at": null
  }
}

Errors

StatusErrorWhen
400stay_id and type requiredEither field is missing from the body.
400invalid_detailsA value in details is not a string, number, boolean, or null.
401unauthorizedNo x-gi-session header, or the token does not match stay_id.

A type outside the list above is not validated explicitly. It resolves to no queue and fails on insert, which the error mapper reports as a 400 carrying the database message (see createRequest in requests.ts).

POST /v1/requests/:id/claim

Assign an open request to a staff member.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe request to claim.
FieldTypeRequiredMeaning
staff_idstringrequiredThe staff member taking the request. Must exist in the staff table.

POST /v1/requests/7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44/claim

POST /v1/requests/7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44/claim
content-type: application/json

{ "staff_id": "dana" }

Response

JSON

{
  "request": {
    "id": "7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44",
    "stay_id": "stay-655",
    "type": "dining",
    "details": "{\"party_size\":9,\"preferred_time\":\"19:30\"}",
    "queue_id": "dining",
    "status": "claimed",
    "owner_staff_id": "dana",
    "recommendation_id": "a1b2c3d4-0000-4000-8000-000000000001",
    "result_kind": null,
    "result_ref": null,
    "result_value_usd": null,
    "created_at": "2026-08-30T18:00:00Z",
    "claimed_at": "2026-08-30T18:02:00Z",
    "closed_at": null
  }
}

Errors

StatusErrorWhen
400staff_id requiredThe body has no staff_id.
404unknown_requestNo request with that id.
404unknown_staffNo staff member with that staff_id.
409invalid_stateThe request is not open. A second claim fails here.

POST /v1/requests/:id/complete

Close a request as done, booking it through the simulated adapter.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe request to complete.
FieldTypeRequiredMeaning
staff_idstringrequiredThe staff member completing the request.
venuestringoptionalBooking venue. Defaults to main.
slotstringoptionalBooking time. Defaults to 19:00.

Party size comes from details.party_size when it is a number, and from the stay's party_size otherwise.

POST /v1/requests/7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44/complete

POST /v1/requests/7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44/complete
content-type: application/json

{ "staff_id": "dana", "venue": "chefs_table", "slot": "19:30" }

Response

JSON

{
  "request": {
    "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"
  }
}

The 309.96 figure is nine seats at the configured dining_per_seat_usd, and is the value the tests assert (see core/src/__tests__/requests.test.ts).

Errors

StatusErrorWhen
400staff_id requiredThe body has no staff_id.
404unknown_requestNo request with that id.
409invalid_stateThe request is already done or declined.

POST /v1/requests/:id/decline

Close a request as declined, with a reason.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe request to decline.
FieldTypeRequiredMeaning
staff_idstringrequiredThe staff member declining the request.
reasonstringrequired in practiceFree text. The route defaults a missing value to the empty string, which the module then rejects as reason_required.

POST /v1/requests/9b3d0a17-2c44-4e6b-9b02-77c1e5d8a3f0/decline

POST /v1/requests/9b3d0a17-2c44-4e6b-9b02-77c1e5d8a3f0/decline
content-type: application/json

{ "staff_id": "marco", "reason": "not available tonight" }

Response

JSON

{
  "request": {
    "id": "9b3d0a17-2c44-4e6b-9b02-77c1e5d8a3f0",
    "stay_id": "stay-655",
    "type": "amenity",
    "details": "{}",
    "queue_id": "guest_services",
    "status": "declined",
    "owner_staff_id": "marco",
    "recommendation_id": null,
    "result_kind": null,
    "result_ref": null,
    "result_value_usd": null,
    "created_at": "2026-08-30T18:05:00Z",
    "claimed_at": null,
    "closed_at": "2026-08-30T18:06:00Z"
  }
}

Errors

StatusErrorWhen
400staff_id requiredThe body has no staff_id.
400reason_requiredreason is missing or empty.
404unknown_requestNo request with that id.
409invalid_stateThe request is already done or declined.

POST /v1/requests/:id/assign

Hand a claimed request to another staff member.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe request being handed off.
FieldTypeRequiredMeaning
from_staff_idstringrequiredThe current owner. Must match the request's owner_staff_id.
to_staff_idstringrequiredWho takes it. Must exist in the staff table, and must not be the current owner.

POST /v1/requests/7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44/assign

POST /v1/requests/7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44/assign
content-type: application/json

{ "from_staff_id": "dana", "to_staff_id": "marco" }

Response

JSON

{
  "request": {
    "id": "7c1f5a2e-1f2c-4c8e-9c31-8a0d2b1f7c44",
    "stay_id": "stay-655",
    "type": "dining",
    "details": "{\"party_size\":9,\"preferred_time\":\"19:30\"}",
    "queue_id": "dining",
    "status": "claimed",
    "owner_staff_id": "marco",
    "recommendation_id": "a1b2c3d4-0000-4000-8000-000000000001",
    "result_kind": null,
    "result_ref": null,
    "result_value_usd": null,
    "created_at": "2026-08-30T18:00:00Z",
    "claimed_at": "2026-08-30T18:02:00Z",
    "closed_at": null,
    "assigned_at": "2026-08-30T18:04:00Z",
    "assigned_by_staff_id": "dana"
  }
}

A hand off is not a state change. status stays claimed and claimed_at keeps the original claim instant. Three things move: owner_staff_id becomes to_staff_id, assigned_at becomes the request instant, and assigned_by_staff_id records who let it go. Those last two columns sit on every request row and stay null until a hand off happens.

The call emits request_assigned with request_id, from_staff_id, to_staff_id, and queue_id. The queue card picks the new owner up as owner_first_name and the previous one as assigned_by_first_name, so the card shows who is on it now and who passed it along.

There is no chain limit. Marco can hand the same request on again, and each hand off overwrites assigned_at and assigned_by_staff_id with the latest one. Only the current pair is kept on the row, and the full trail lives in the request_assigned events.

Errors

StatusErrorWhen
400from_staff_id and to_staff_id requiredEither field is missing from the body.
400already assigned to that personto_staff_id equals the current owner, which after the ownership check means it equals from_staff_id.
404request not foundNo request with that id.
404staff not foundNo staff member with that to_staff_id.
409request is not claimedThe request is open, done, or declined. Only a claimed request can be handed off.
409request is owned by someone elsefrom_staff_id is not the request's current owner_staff_id. This is what stops two operators from handing the same card in opposite directions.

This route uses prose error messages rather than the snake-case codes the rest of the family returns. errorStatus in server.ts carries explicit cases for all four so they map to 404 and 409 rather than falling through to 400.

GET /v1/queues

List the property's queues with their open counts.

Auth: unauthenticated in this build.

Request

No path params, no query params, no body.

Response

JSON

{
  "queues": [
    { "queue_id": "dining", "label": "Dining", "sla_minutes": 15, "open_count": 1 },
    { "queue_id": "guest_services", "label": "Guest Services", "sla_minutes": 20, "open_count": 0 },
    { "queue_id": "housekeeping", "label": "Housekeeping", "sla_minutes": 30, "open_count": 0 },
    { "queue_id": "bell", "label": "Bell Desk", "sla_minutes": 20, "open_count": 0 }
  ]
}

open_count counts requests in that queue whose status is open.

Errors

StatusErrorWhen
--This route declares no error responses.

GET /v1/queues/:id/requests

List every request in one queue as a staff card, newest first.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe queue id, for example dining. An unknown queue returns an empty list.

No body, no query params.

Response

JSON

{
  "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": "open",
      "owner_staff_id": null,
      "recommendation_id": "a1b2c3d4-0000-4000-8000-000000000001",
      "result_kind": null,
      "result_ref": null,
      "result_value_usd": null,
      "created_at": "2026-08-30T18:00:00Z",
      "claimed_at": null,
      "closed_at": null,
      "guest_first_name": "Aretha",
      "room": "655",
      "tier": "gold",
      "party_size": 9,
      "escalated": 0,
      "owner_first_name": null,
      "posting_ref": null,
      "group_label": null,
      "group_size": null
    }
  ]
}

A card is the request row plus the guest columns, the owner's first name, the folio posting_ref when a charge posted, and the guest's active group label and size when they are in one.

escalated is 1 when the request is still open and has been waiting longer than the queue's sla_minutes, and 0 otherwise. Claiming a request clears it.

Errors

StatusErrorWhen
--This route declares no error responses.

GET /v1/staff

List the property's staff.

Auth: unauthenticated in this build.

Request

No path params, no query params, no body.

Response

JSON

{
  "staff": [
    { "staff_id": "dana", "first_name": "Dana", "role": "Dining lead", "queue_id": "dining" },
    { "staff_id": "marco", "first_name": "Marco", "role": "Guest Experience", "queue_id": "guest_services" },
    { "staff_id": "rosa", "first_name": "Rosa", "role": "Housekeeping lead", "queue_id": "housekeeping" },
    { "staff_id": "theo", "first_name": "Theo", "role": "Bell captain", "queue_id": "bell" },
    { "staff_id": "jake", "first_name": "Jake", "role": "General Manager", "queue_id": "guest_services" }
  ]
}

The rows are the staff table, seeded from the property config.

Errors

StatusErrorWhen
--This route declares no error responses.

POST /v1/federation/porter/escalations

QR Find hands a conversation it cannot finish to the human concierge desk. It is called by QR Find server to server, never by a browser, and lands here as a concierge request in the Guest Services queue. The route path says porter because that is the identifier in the code and the database.

The service token, shared with the upsells route on Openings and offers

The QR Find inbound routes are the only routes in the core that use x-gi-service-token. The check is a plain string comparison, run before any parsing or database work:

TS

const token = (req.headers as any)['x-gi-service-token'];
if (!federationToken || token !== federationToken) return reply.code(401).send({ error: 'unauthorized' });

Three consequences worth knowing before integrating.

  1. The expected value is the federationToken the server was built with. When that is null, meaning the environment variable behind it is unset, every call to these routes returns 401 no matter what the caller sends. There is no open mode.
  2. The comparison is exact. A missing header and a wrong header both return 401, with the body {"error": "unauthorized"}.
  3. The check runs first, so a bad token never reveals whether a stay_ref exists. See core/src/__tests__/federation.test.ts.

The same token travels in the other direction. When a stay is recognized or consents, the core posts that stay to QR Find with the identical x-gi-service-token header, fire and forget, three-second timeout, failures logged and dropped. See porter-feed.ts.

Guests are addressed by porter_ref, an opaque reference stamped on the stay at recognition and never re-stamped. QR Find never sees a stay id, a room, or a booking code. A stay's ref-bearing links are consent-gated: an unconsented guest's stay is never fed to QR Find.

x-gi-service-token is not on the service's CORS allow-list, which matches the fact that these two routes are called server to server and never from a browser.

Auth: x-gi-service-token.

Request

POST /v1/federation/porter/escalations

POST /v1/federation/porter/escalations
content-type: application/json
x-gi-service-token: <service token>
FieldTypeRequiredMeaning
stay_refstringrequiredThe guest's porter_ref. A non-string value is treated as absent.
summarystringrequiredWhat the guest needs, in their words or QR Find's. Trimmed, then truncated to 200 characters. A non-string value is treated as an empty summary.
conversation_urlstringoptionalDeep link back into the QR Find thread. Truncated to 300 characters. Omitted from the request details when absent or empty.

JSON

{
  "stay_ref": "7f5c1d90a2b34e6f",
  "summary": "My kids left our snorkel gear at the marina, can someone grab it?",
  "conversation_url": "https://porter.example/staff/inbox?c=1"
}

Response

A concierge request on the matched stay, routed to the Guest Services queue, with the provenance in its details.

JSON

{
  "request": {
    "id": "0d6b8c21-7f3a-4a6e-9c55-1b2f0a9de334",
    "stay_id": "stay-412",
    "type": "concierge",
    "details": "{\"note\":\"My kids left our snorkel gear at the marina, can someone grab it?\",\"source\":\"porter\",\"conversation_url\":\"https://porter.example/staff/inbox?c=1\"}",
    "queue_id": "guest_services",
    "status": "open",
    "owner_staff_id": null,
    "recommendation_id": null,
    "result_kind": null,
    "result_ref": null,
    "result_value_usd": null,
    "created_at": "2026-09-03T18:00:00Z",
    "claimed_at": null,
    "closed_at": null
  }
}

The call also emits a porter_escalation event on the stay, which is what renders John asked the concierge for help with the QR Find chip in the activity stream.

Errors

StatusErrorWhen
401unauthorizedToken missing, wrong, or none configured on the server. Checked first.
404unknown_stay_refstay_ref missing, not a string, or matching no stay.
400summary_requiredsummary is empty after trimming, or not a string.

Notes

  • An escalation is service, not revenue. Completing the request it creates books a zero-value result and writes no action row, so it never moves the report's acted or value numbers.
  • The escalation route truncates rather than rejects on long input. A 500-character summary becomes a 200-character note, and the request is still created. Non-string fields fall through to the same validation errors a missing field would produce, so malformed input never returns a 500.

Routing is config, not code. routing in the property config maps each request type to a queue id. In the Solara Cove config, dining goes to Dining, housekeeping to Housekeeping, luggage_hold to the Bell Desk, and spa, amenity, other, rebook_hold, and concierge all go to Guest Services.

The lifecycle. A request is created open. claim requires open and moves it to claimed. complete accepts open or claimed, so a request can be completed without being claimed first, and the completing staff member becomes the owner. decline also accepts open or claimed. Both closing verbs are terminal: a second call gets 409 invalid_state. assign sits outside that machine. It moves ownership of a claimed request without changing its status, so it can run any number of times before a closing verb runs once.

What completion writes. Completion always books through the simulated adapter and stamps result_kind, result_ref, and result_value_usd on the request. It writes an action row too, with two exceptions: luggage_hold and concierge record no action (holding a bag is service, not a trackable action), and rebook_hold records a rebooking action rather than a booking. When the request carried a recommendation_id, that recommendation flips to acted.

Vendor sinks are best effort. When a folio sink is wired and the stay carries an external_ref and the value is above zero, completion posts the charge and stores the posting_ref on the booking. A dining completion also opens a check on the POS sink when one is wired. Both paths swallow their failures into events (folio_post_failed, folio_skipped, pos_check_failed) rather than failing the request. These sinks talk to vendor sandboxes in this build.

Authentication is asymmetric on purpose. Creating a request needs the guest's session token. The four staff verbs and the three read routes are unauthenticated in this build, since Hotel Operator runs behind its own front door in the demo. Anything in front of a real property has to put an operator identity on these seven routes.