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>
FieldTypeRequiredMeaning
stay_idstringrequiredThe stay creating the group. Must match the session token.
labelstringrequiredCrew 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

StatusErrorWhen
400stay_id and label requiredstay_id missing, or label is not a string.
401unauthorizedSession header missing, or not this stay's token.
409consent_requiredThe stay is not recognized, or analytics_consent is not 1.
400invalid_labelTrimmed label is empty or over 40 characters.
409already_in_groupThe stay already holds an active group.
400join_code_exhaustedTwenty 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>
FieldTypeRequiredMeaning
stay_idstringrequiredThe joining stay. Must match the session token.
join_codestringrequiredThe 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

StatusErrorWhen
400stay_id and join_code requiredEither field missing.
401unauthorizedSession header missing, or not this stay's token.
409consent_requiredThe stay is not recognized, or analytics_consent is not 1.
404unknown_join_codeNo group carries that code.
409group_dissolvedThe group exists but is dissolved or fully departed.
409already_in_groupThe stay already holds an active group.

POST /v1/groups/:id/leave

Leave a group.

Auth: x-gi-session.

Request

Path paramTypeMeaning
idstringGroup id.
FieldTypeRequiredMeaning
stay_idstringrequiredThe 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

StatusErrorWhen
400stay_id requiredField missing.
401unauthorizedSession header missing, or not this stay's token.
404unknown_membershipThe 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 paramTypeMeaning
idstringGroup id. The caller must be an active member.
FieldTypeRequiredMeaning
stay_idstringrequiredThe proposing stay. Must match the session token.
titlestringrequiredWhat the crew would do. Trimmed, 1 to 80 characters.
activity_typestringrequiredOne of dining, spa, amenity, other. Decides which queue a booking lands in.
proposed_timestringoptionalFree 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

StatusErrorWhen
400stay_id, title, and activity_type requiredstay_id missing, or title or activity_type is not a string.
401unauthorizedSession header missing, or not this stay's token.
404unknown_membershipThe caller is not an active member, or the caller's stay has departed, or the group is no longer live.
400invalid_titleTrimmed title is empty or over 80 characters.
400invalid_typeactivity_type is not one of the four allowed values.
400invalid_timeproposed_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 paramTypeMeaning
idstringGroup activity id.
FieldTypeRequiredMeaning
stay_idstringrequiredThe 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

StatusErrorWhen
400stay_id requiredField missing.
401unauthorizedSession header missing, or not this stay's token.
404unknown_activityNo activity with that id.
404unknown_membershipThe 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.
409invalid_stateThe activity is already booked or cancelled.
403organizer_onlyThe caller is a member but not the organizer.
409nobody_goingNo 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 paramTypeMeaning
idstringGroup activity id.
FieldTypeRequiredMeaning
stay_idstringrequiredThe voting stay. Must match the session token.
statestringrequiredOne 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

StatusErrorWhen
400stay_id and state requiredEither field missing.
401unauthorizedSession header missing, or not this stay's token.
404unknown_activityNo activity with that id.
404unknown_membershipThe caller is not an active member of the activity's group.
409invalid_stateThe 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_id from the body and checks it against the x-gi-session token. 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.ts for 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/leave is the only group route whose success body is { "ok": true } rather than an object.