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.
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The stay the request belongs to. Must match the session token. |
type | string | required | One of dining, spa, housekeeping, amenity, other, luggage_hold, rebook_hold, concierge. The type picks the queue through routing in the property config. |
details | object | optional | Flat 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_id | string | optional | The 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
| Status | Error | When |
|---|---|---|
| 400 | stay_id and type required | Either field is missing from the body. |
| 400 | invalid_details | A value in details is not a string, number, boolean, or null. |
| 401 | unauthorized | No 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
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The request to claim. |
| Field | Type | Required | Meaning |
|---|---|---|---|
staff_id | string | required | The 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
| Status | Error | When |
|---|---|---|
| 400 | staff_id required | The body has no staff_id. |
| 404 | unknown_request | No request with that id. |
| 404 | unknown_staff | No staff member with that staff_id. |
| 409 | invalid_state | The 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
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The request to complete. |
| Field | Type | Required | Meaning |
|---|---|---|---|
staff_id | string | required | The staff member completing the request. |
venue | string | optional | Booking venue. Defaults to main. |
slot | string | optional | Booking 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
| Status | Error | When |
|---|---|---|
| 400 | staff_id required | The body has no staff_id. |
| 404 | unknown_request | No request with that id. |
| 409 | invalid_state | The 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
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The request to decline. |
| Field | Type | Required | Meaning |
|---|---|---|---|
staff_id | string | required | The staff member declining the request. |
reason | string | required in practice | Free 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
| Status | Error | When |
|---|---|---|
| 400 | staff_id required | The body has no staff_id. |
| 400 | reason_required | reason is missing or empty. |
| 404 | unknown_request | No request with that id. |
| 409 | invalid_state | The 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
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The request being handed off. |
| Field | Type | Required | Meaning |
|---|---|---|---|
from_staff_id | string | required | The current owner. Must match the request's owner_staff_id. |
to_staff_id | string | required | Who 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
| Status | Error | When |
|---|---|---|
| 400 | from_staff_id and to_staff_id required | Either field is missing from the body. |
| 400 | already assigned to that person | to_staff_id equals the current owner, which after the ownership check means it equals from_staff_id. |
| 404 | request not found | No request with that id. |
| 404 | staff not found | No staff member with that to_staff_id. |
| 409 | request is not claimed | The request is open, done, or declined. Only a claimed request can be handed off. |
| 409 | request is owned by someone else | from_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
| Status | Error | When |
|---|---|---|
| - | - | 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
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The 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
| Status | Error | When |
|---|---|---|
| - | - | 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
| Status | Error | When |
|---|---|---|
| - | - | 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.
- The expected value is the
federationTokenthe 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. - The comparison is exact. A missing header and a wrong header both return 401, with the body
{"error": "unauthorized"}. - The check runs first, so a bad token never reveals whether a
stay_refexists. Seecore/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>
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_ref | string | required | The guest's porter_ref. A non-string value is treated as absent. |
summary | string | required | What 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_url | string | optional | Deep 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
| Status | Error | When |
|---|---|---|
| 401 | unauthorized | Token missing, wrong, or none configured on the server. Checked first. |
| 404 | unknown_stay_ref | stay_ref missing, not a string, or matching no stay. |
| 400 | summary_required | summary 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.