API reference
Groups
Guest-formed crews, the activities they vote on, and the one rollup the operator reads.
A group is a crew of stays travelling together. One guest creates it, the others join with a four-character code, and the group votes on activities until the organizer books one. Every write here comes from the Concierge and carries the guest's own session. The one read, GET /v1/groups/summary, is a counts-only rollup for Hotel Operator.
A stay holds at most one active group at a time. A group stays live until it is dissolved or every active member's stay has departed, and the caller's own stay must not have departed either (the departure day itself still counts). See groups.ts.
POST /v1/groups
Create a group and make the caller its organizer.
Auth: x-gi-session. The token must belong to the stay_id in the body.
Request
POST /v1/groups
POST /v1/groups
content-type: application/json
x-gi-session: <session token>
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The stay creating the group. Must match the session token. |
label | string | required | Crew name. Trimmed, 1 to 40 characters after trimming. |
The stay must be recognized and have analytics_consent = 1, or the call returns consent_required.
Response
JSON
{
"group": {
"id": "1f0a7c4e-6d0b-4f42-9c31-6a1a4d1b93f2",
"property_slug": "solara-cove",
"label": "Aretha's crew",
"join_code": "ORB-K7QM",
"created_by_stay_id": "stay-655",
"created_at": "2026-09-03T18:00:00.000Z",
"dissolved_at": null
}
}
The join code is always ORB- plus four characters drawn from ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no letters or digits that read as each other).
Errors
| Status | Error | When |
|---|---|---|
| 400 | stay_id and label required | stay_id missing, or label is not a string. |
| 401 | unauthorized | Session header missing, or not this stay's token. |
| 409 | consent_required | The stay is not recognized, or analytics_consent is not 1. |
| 400 | invalid_label | Trimmed label is empty or over 40 characters. |
| 409 | already_in_group | The stay already holds an active group. |
| 400 | join_code_exhausted | Twenty code generations all collided. Not reachable in practice. |
POST /v1/groups/join
Join an existing group by its code.
Auth: x-gi-session.
Request
POST /v1/groups/join
POST /v1/groups/join
content-type: application/json
x-gi-session: <session token>
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The joining stay. Must match the session token. |
join_code | string | required | The code from the organizer's group, exact match. |
Response
The same group object as the create route, for the group that was joined.
JSON
{
"group": {
"id": "1f0a7c4e-6d0b-4f42-9c31-6a1a4d1b93f2",
"property_slug": "solara-cove",
"label": "Aretha's crew",
"join_code": "ORB-K7QM",
"created_by_stay_id": "stay-655",
"created_at": "2026-09-03T18:00:00.000Z",
"dissolved_at": null
}
}
A stay that left and rejoins reactivates its old membership row, and its old votes count again.
Errors
| Status | Error | When |
|---|---|---|
| 400 | stay_id and join_code required | Either field missing. |
| 401 | unauthorized | Session header missing, or not this stay's token. |
| 409 | consent_required | The stay is not recognized, or analytics_consent is not 1. |
| 404 | unknown_join_code | No group carries that code. |
| 409 | group_dissolved | The group exists but is dissolved or fully departed. |
| 409 | already_in_group | The stay already holds an active group. |
POST /v1/groups/:id/leave
Leave a group.
Auth: x-gi-session.
Request
| Path param | Type | Meaning |
|---|---|---|
id | string | Group id. |
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The leaving stay. Must match the session token. |
Response
JSON
{ "ok": true }
When the organizer leaves, the earliest-joined remaining member is promoted to organizer. When the last member leaves, the group is dissolved.
Errors
| Status | Error | When |
|---|---|---|
| 400 | stay_id required | Field missing. |
| 401 | unauthorized | Session header missing, or not this stay's token. |
| 404 | unknown_membership | The stay holds no unleft membership in that group. |
POST /v1/groups/:id/activities
Propose an activity for the group to vote on. The proposer is recorded as going.
Auth: x-gi-session.
Request
| Path param | Type | Meaning |
|---|---|---|
id | string | Group id. The caller must be an active member. |
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The proposing stay. Must match the session token. |
title | string | required | What the crew would do. Trimmed, 1 to 80 characters. |
activity_type | string | required | One of dining, spa, amenity, other. Decides which queue a booking lands in. |
proposed_time | string | optional | Free text, up to 40 characters. Defaults to an empty string. |
JSON
{
"stay_id": "stay-655",
"title": "Dinner together",
"activity_type": "dining",
"proposed_time": "7:00 tonight"
}
Response
JSON
{
"activity": {
"id": "6b0f2d13-9a4c-4d0a-b8a9-2f1e4c77dd10",
"title": "Dinner together",
"activity_type": "dining",
"proposed_time": "7:00 tonight",
"status": "open",
"proposed_by_first_name": "Aretha",
"going_count": 1,
"maybe_count": 0,
"out_count": 0,
"my_state": "going",
"request_status": null
}
}
Counts include only stays that are still unleft members of the group. my_state is the caller's own vote. request_status is the status of the request created by booking, and stays null until the activity is booked.
Errors
| Status | Error | When |
|---|---|---|
| 400 | stay_id, title, and activity_type required | stay_id missing, or title or activity_type is not a string. |
| 401 | unauthorized | Session header missing, or not this stay's token. |
| 404 | unknown_membership | The caller is not an active member, or the caller's stay has departed, or the group is no longer live. |
| 400 | invalid_title | Trimmed title is empty or over 80 characters. |
| 400 | invalid_type | activity_type is not one of the four allowed values. |
| 400 | invalid_time | proposed_time is over 40 characters. |
POST /v1/group-activities/:id/book
Turn a voted activity into a real request in the operator queue, sized to the going count.
Auth: x-gi-session. Organizer only.
Request
| Path param | Type | Meaning |
|---|---|---|
id | string | Group activity id. |
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The organizer's stay. Must match the session token. |
Response
The updated activity plus the request it created. The request's details carry the activity title as note, the proposed time as preferred_time, and the going count as party_size.
JSON
{
"activity": {
"id": "6b0f2d13-9a4c-4d0a-b8a9-2f1e4c77dd10",
"title": "Dinner together",
"activity_type": "dining",
"proposed_time": "7:00 tonight",
"status": "booked",
"proposed_by_first_name": "Aretha",
"going_count": 2,
"maybe_count": 0,
"out_count": 0,
"my_state": "going",
"request_status": "open"
},
"request": {
"id": "0d6b8c21-7f3a-4a6e-9c55-1b2f0a9de334",
"stay_id": "stay-655",
"type": "dining",
"details": "{\"note\":\"Dinner together\",\"preferred_time\":\"7:00 tonight\",\"party_size\":2}",
"queue_id": "dining",
"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:00.000Z",
"claimed_at": null,
"closed_at": null
}
}
details is a JSON string, not an object. See types.ts.
Errors
| Status | Error | When |
|---|---|---|
| 400 | stay_id required | Field missing. |
| 401 | unauthorized | Session header missing, or not this stay's token. |
| 404 | unknown_activity | No activity with that id. |
| 404 | unknown_membership | The caller is not an active member of the activity's group. Checked before the activity's status, so a non-member can never learn a foreign activity's state. |
| 409 | invalid_state | The activity is already booked or cancelled. |
| 403 | organizer_only | The caller is a member but not the organizer. |
| 409 | nobody_going | No active member is marked going. |
POST /v1/group-activities/:id/attendance
Set the caller's vote on an activity.
Auth: x-gi-session.
Request
| Path param | Type | Meaning |
|---|---|---|
id | string | Group activity id. |
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_id | string | required | The voting stay. Must match the session token. |
state | string | required | One of going, maybe, out. |
Response
The same activity view as the propose route, with the counts and my_state recomputed.
JSON
{
"activity": {
"id": "6b0f2d13-9a4c-4d0a-b8a9-2f1e4c77dd10",
"title": "Dinner together",
"activity_type": "dining",
"proposed_time": "7:00 tonight",
"status": "open",
"proposed_by_first_name": "Aretha",
"going_count": 2,
"maybe_count": 1,
"out_count": 0,
"my_state": "maybe",
"request_status": null
}
}
Votes upsert: sending a second state for the same stay replaces the first.
Errors
| Status | Error | When |
|---|---|---|
| 400 | stay_id and state required | Either field missing. |
| 401 | unauthorized | Session header missing, or not this stay's token. |
| 404 | unknown_activity | No activity with that id. |
| 404 | unknown_membership | The caller is not an active member of the activity's group. |
| 409 | invalid_state | The activity is not open, or state is not one of the three allowed values. |
GET /v1/groups/summary
Count the live groups on property.
Auth: unauthenticated in this build.
Request
GET /v1/groups/summary
GET /v1/groups/summary
No path params, no query params, no body.
Response
JSON
{ "groups": 2, "largest": 3, "members_total": 5 }
groups is the number of live groups, largest the biggest active member count, members_total the sum across all live groups. All three are zero when nothing is live.
Errors
None. The route always returns 200.
Notes
- Every write route reads
stay_idfrom the body and checks it against thex-gi-sessiontoken. A token for a different stay returns 401, so one guest cannot act as another. - Group payloads are projections. They carry first names, roles, and counts, and never a room number, booking code, tier, or session token. See
core/src/__tests__/groups.test.tsfor the assertions that pin this. - Attendance counts and the booked party size ignore members who left. A rejoin brings the old vote back.
POST /v1/groups/:id/leaveis the only group route whose success body is{ "ok": true }rather than an object.