API reference
Openings and Offers
The demand signals Hotel Operator reads, the offers it sends against them, the approval gate, and the guest accept.
Openings are the demand signals the core assembles from guest questions, group votes, and TimeLens exploration. Offers are what a staff member sends against one. Hotel Operator (staff web) reads GET /v1/openings, composes and sends offers, and works the approval gate. The Concierge (guest web) only ever calls POST /v1/offers/instances/:id/accept, and reads its offer cards from GET /v1/stays/:stay_id/home.
GET /v1/openings
List the current demand signals, grouped by topic.
Auth: unauthenticated in this build.
Request
No path params, no query params, no body.
Response
JSON
{
"openings": [
{
"topic": "dining",
"label": "Dining",
"play_id": "cabana_day_pass",
"intent_note": "You asked about dining.",
"count": 3,
"stays": [
{ "stay_id": "sa", "first_name": "John", "noted": true },
{ "stay_id": "sb", "first_name": "Dolly", "noted": true },
{ "stay_id": "sc", "first_name": "Ringo", "noted": true }
],
"evidence": [
{
"kind": "group_vote",
"first_name": "John",
"detail": "3 going, not booked",
"at": "2026-09-03T17:15:00Z"
}
]
},
{
"topic": "cabana",
"label": "Cabanas and pool",
"play_id": "cabana_day_pass",
"intent_note": "You asked about the pool.",
"count": 1,
"stays": [{ "stay_id": "a", "first_name": "Keith", "noted": false }],
"evidence": [
{
"kind": "explored",
"first_name": "Keith",
"detail": "explored the pool in TimeLens",
"at": "2026-09-03T18:00:00Z"
}
]
}
],
"sources": { "porter": "unavailable", "timelens": "unavailable" }
}
Openings are sorted by count, largest first. A topic with no evidence is omitted entirely.
| Field | Meaning |
|---|---|
topic, label, play_id, intent_note | Straight from the openings.topics block in the property config. play_id names the offer play to send against this topic, and intent_note is the line the guest sees on the offer card. |
count | Number of entries in stays. |
stays[].noted | true when the stay asked or voted, false when the only evidence is exploration. The distinction feeds intent_note_stay_ids on POST /v1/offers: an asked stay gets the note, an explored-only stay stays silent. |
evidence[].kind | asked (a question held by QR Find), group_vote (an open group activity past its unbooked window with enough people going), or explored (a TimeLens feature with at least two pieces of evidence). |
sources | Per-source health, each ok or unavailable. Both report unavailable when the server was started without those sources wired. |
Shapes above are copied from core/src/__tests__/openings.test.ts, including the "GET /v1/openings" case.
Errors
| Status | Error | When |
|---|---|---|
| - | - | This route declares no error responses. A source that fails is reported as unavailable in sources rather than failing the request. |
GET /v1/offers
List every offer with live counters, newest first.
Auth: unauthenticated in this build.
Request
No path params, no query params, no body.
Response
JSON
{
"offers": [
{
"id": "3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13",
"play_id": "late_checkout",
"label": "Late checkout",
"headline": "Sleep in tomorrow",
"body": "Keep your room until 2pm on your last day.",
"price_usd": 25,
"capacity": 20,
"status": "open",
"audience_tiers": null,
"audience_min_party_size": null,
"audience_departing": null,
"audience_group_min_size": null,
"audience_stay_ids": null,
"expires_at": "2026-09-03T22:00:00.000Z",
"created_by": "dana",
"created_at": "2026-09-03T18:00:00Z",
"closed_at": null,
"intent_note": null,
"intent_note_stay_ids": null,
"sent": 2,
"viewed": 1,
"accepted": 1,
"revenue_usd": 25,
"remaining": 19
}
]
}
status is the effective status at the time of the call: a pending_approval or closed offer keeps its stored status, an open offer past expires_at reports expired. The audience columns store what the offer was created with, as JSON strings where the value is a list.
Errors
| Status | Error | When |
|---|---|---|
| - | - | This route declares no error responses. |
POST /v1/offers
Create an offer from a configured play and send it to an audience.
Auth: unauthenticated in this build.
Request
No path or query params.
| Field | Type | Required | Meaning |
|---|---|---|---|
play_id | string | required | Id of a play in offer_plays, for example cabana_day_pass. Supplies the label, headline, body, default price, and default capacity. |
staff_id | string | required | The staff member sending the offer. Must exist in the staff table. |
capacity | number | optional | How many accepts the offer allows. Defaults to the play's default_capacity. |
expires_in_minutes | number | optional | Window length from now. Defaults to 240. |
price_usd | number | optional | Overrides the play price. Must be greater than zero. |
audience | object | optional | Who receives it. Empty means every eligible stay. See the audience table below. |
intent_note | string | optional | The "why you are seeing this" line on the guest card. 1 to 80 characters after trimming. |
intent_note_stay_ids | string[] | optional | Which audience stays get the note. Every id must be in the computed audience. When omitted, every audience stay gets it. |
Audience fields (see OfferAudience in offers.ts):
| Field | Type | Meaning |
|---|---|---|
tiers | string[] | Any of standard, gold, platinum. |
min_party_size | number | Party size at or above this number. |
departing | "today" | Stays departing on the property's calendar day. |
group_min_size | number | Integer 2 or greater. Targets the organizers of active groups at or above that size. |
stay_ids | string[] | Named recipients, 1 to 50 after deduplication. Exclusive: it cannot be combined with any other audience field. |
Every audience is intersected with the standing eligibility rule: origin = 'live', analytics_consent = 1, and recognized_at set.
POST /v1/offers
POST /v1/offers
content-type: application/json
{
"play_id": "cabana_day_pass",
"staff_id": "dana",
"audience": { "stay_ids": ["stay-655", "stay-412"] },
"intent_note": "You asked about the spa.",
"intent_note_stay_ids": ["stay-655"]
}
Response
JSON
{
"offer": {
"id": "3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13",
"play_id": "cabana_day_pass",
"label": "Cabana day pass",
"headline": "A cabana is open today",
"body": "Shade, towels, and a dedicated server by the main pool. Yours for the day.",
"price_usd": 150,
"capacity": 8,
"status": "open",
"audience_tiers": null,
"audience_min_party_size": null,
"audience_departing": null,
"audience_group_min_size": null,
"audience_stay_ids": "[\"stay-655\",\"stay-412\"]",
"expires_at": "2026-09-03T22:00:00.000Z",
"created_by": "dana",
"created_at": "2026-09-03T18:00:00Z",
"closed_at": null,
"intent_note": "You asked about the spa.",
"intent_note_stay_ids": "[\"stay-655\"]"
},
"sent_count": 2
}
When the price is above the configured GM approval threshold, the offer is stored pending_approval, no instances are created, and sent_count is 0. See core/src/__tests__/offers-api.test.ts and core/src/__tests__/approval-gate.test.ts.
Errors
| Status | Error | When |
|---|---|---|
| 400 | play_id and staff_id required | Either field is missing. |
| 400 | invalid_price | price_usd is present and not greater than zero. |
| 400 | invalid_group_min_size | group_min_size is not an integer of 2 or more. |
| 400 | invalid_audience | stay_ids is combined with another filter, or holds fewer than 1 or more than 50 unique ids. |
| 400 | invalid_intent_note | The note is empty or over 80 characters after trimming, or intent_note_stay_ids names a stay outside the audience. |
| 404 | unknown_play | No play with that play_id. |
| 404 | unknown_staff | No staff member with that staff_id. |
| 409 | empty_audience | The audience resolves to zero eligible stays. |
POST /v1/offers/:id/approve
Approve a gated offer and materialize the send.
Auth: unauthenticated in this build.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The pending offer. |
| Field | Type | Required | Meaning |
|---|---|---|---|
staff_id | string | required | The approver. Recorded on the offer_approved event. |
POST /v1/offers/3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13/approve
POST /v1/offers/3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13/approve
content-type: application/json
{ "staff_id": "jake" }
Response
The same envelope POST /v1/offers returns: the offer with status now open and a fresh expires_at, plus the number of instances created.
JSON
{
"offer": {
"id": "3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13",
"play_id": "spa_same_day",
"label": "Same-day spa slot",
"headline": "A spa slot opened this afternoon",
"body": "A same-day opening at the spa. Tap to take it.",
"price_usd": 180,
"capacity": 4,
"status": "open",
"audience_tiers": null,
"audience_min_party_size": null,
"audience_departing": null,
"audience_group_min_size": null,
"audience_stay_ids": "[\"stay-655\"]",
"expires_at": "2026-09-03T22:30:00.000Z",
"created_by": "dana",
"created_at": "2026-09-03T18:00:00Z",
"closed_at": null,
"intent_note": null,
"intent_note_stay_ids": null
},
"sent_count": 1
}
Errors
| Status | Error | When |
|---|---|---|
| 400 | staff_id required | The body has no staff_id. |
| 404 | unknown_offer | No offer with that id. |
| 409 | invalid_state | The offer is not pending_approval. |
POST /v1/offers/:id/reject
Reject a gated offer. Nothing is sent.
Auth: unauthenticated in this build.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The pending offer. |
| Field | Type | Required | Meaning |
|---|---|---|---|
staff_id | string | required | The rejecter. Recorded on the offer_rejected event. |
POST /v1/offers/3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13/reject
POST /v1/offers/3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13/reject
content-type: application/json
{ "staff_id": "jake" }
Response
JSON
{
"offer": {
"id": "3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13",
"play_id": "spa_same_day",
"label": "Same-day spa slot",
"headline": "A spa slot opened this afternoon",
"body": "A same-day opening at the spa. Tap to take it.",
"price_usd": 180,
"capacity": 4,
"status": "closed",
"audience_tiers": null,
"audience_min_party_size": null,
"audience_departing": null,
"audience_group_min_size": null,
"audience_stay_ids": "[\"stay-655\"]",
"expires_at": "2026-09-03T22:00:00.000Z",
"created_by": "dana",
"created_at": "2026-09-03T18:00:00Z",
"closed_at": "2026-09-03T18:30:00Z",
"intent_note": null,
"intent_note_stay_ids": null
}
}
Errors
| Status | Error | When |
|---|---|---|
| 400 | staff_id required | The body has no staff_id. |
| 404 | unknown_offer | No offer with that id. |
| 409 | invalid_state | The offer is not pending_approval. |
POST /v1/offers/:id/close
Close an open offer by hand.
Auth: unauthenticated in this build.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The open offer to close. |
No body fields.
POST /v1/offers/3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13/close
POST /v1/offers/3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13/close
Response
JSON
{
"offer": {
"id": "3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13",
"play_id": "spa_same_day",
"label": "Same-day spa slot",
"headline": "A spa slot opened this afternoon",
"body": "A same-day opening at the spa. Tap to take it.",
"price_usd": 180,
"capacity": 4,
"status": "closed",
"audience_tiers": null,
"audience_min_party_size": null,
"audience_departing": null,
"audience_group_min_size": null,
"audience_stay_ids": null,
"expires_at": "2026-09-03T22:00:00.000Z",
"created_by": "dana",
"created_at": "2026-09-03T18:00:00Z",
"closed_at": "2026-09-03T18:30:00Z",
"intent_note": null,
"intent_note_stay_ids": null
}
}
Closing also flips every sent or viewed instance to expired, so the cards disappear from the guests' screens.
Errors
| Status | Error | When |
|---|---|---|
| 404 | unknown_offer | No offer with that id. |
| 409 | invalid_state | The offer is not open. A second close fails here. |
POST /v1/offers/instances/:id/accept
The guest takes an offer. Books it and returns the confirmation.
Auth: x-gi-session, matched against the stay that owns the instance.
Request
| Param | Type | Required | Meaning |
|---|---|---|---|
id (path) | string | required | The offer instance id, as it appears in offers[].instance_id on GET /v1/stays/:stay_id/home. |
No body fields.
POST /v1/offers/instances/6d2b0f61-9c2a-4f0e-9f4a-1c2b3d4e5f60/accept
POST /v1/offers/instances/6d2b0f61-9c2a-4f0e-9f4a-1c2b3d4e5f60/accept
x-gi-session: 0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7
Response
JSON
{
"instance": {
"id": "6d2b0f61-9c2a-4f0e-9f4a-1c2b3d4e5f60",
"offer_id": "3f7c8b21-6d9e-4a1c-8b77-2e5f9a0c4d13",
"stay_id": "stay-655",
"status": "accepted",
"sent_at": "2026-09-03T18:00:00Z",
"viewed_at": "2026-09-03T18:02:00Z",
"accepted_at": "2026-09-03T18:04:00Z",
"booking_id": "b7d1c9f3-2a44-4c1b-9e30-5f6a7b8c9d01",
"result_ref": "SC-9134",
"value_usd": 150,
"intent_note": "You asked about the spa."
},
"booking": {
"confirmation_code": "SC-9134",
"value_usd": 150
}
}
Accepting twice is idempotent: the second call returns 200 with the same confirmation code.
Errors
| Status | Error | When |
|---|---|---|
| 401 | unauthorized | No x-gi-session header, or the token belongs to a different stay than the instance. |
| 404 | unknown_instance | No instance with that id. |
| 409 | sold_out | The offer is no longer open and accepted instances have reached capacity. Someone else took the last slot. |
| 409 | offer_expired | The offer is no longer open for any other reason: the window passed, or a staff member closed it. |
GET /v1/offers/plays
List the configured offer plays for the composer.
Auth: unauthenticated in this build.
Request
No path params, no query params, no body.
Response
JSON
{
"plays": [
{
"id": "cabana_day_pass",
"label": "Cabana day pass",
"price_usd": 150,
"default_capacity": 8,
"headline": "A cabana is open today",
"body": "Shade, towels, and a dedicated server by the main pool. Yours for the day."
},
{
"id": "spa_same_day",
"label": "Same-day spa slot",
"price_usd": 180,
"default_capacity": 4,
"headline": "A spa slot opened this afternoon",
"body": "A same-day opening at the spa. Tap to take it."
},
{
"id": "late_checkout",
"label": "Late checkout",
"price_usd": 25,
"default_capacity": 20,
"headline": "Sleep in tomorrow",
"body": "Keep your room until 2pm on your last day."
}
]
}
This is the offer_plays block of the property config, returned as is.
Errors
| Status | Error | When |
|---|---|---|
| - | - | This route declares no error responses. |
GET /v1/offers/preview
Size an audience before sending anything.
Auth: unauthenticated in this build.
Request
No path params, no body. Audience filters arrive as query params, all optional.
| Query param | Type | Meaning |
|---|---|---|
tiers | comma-separated string | Any of standard, gold, platinum. An unknown tier is rejected. |
min_party_size | number | Party size at or above this number. Must parse as a finite number. |
departing | string | Only today is accepted. An empty value is ignored. |
group_min_size | number | Integer 2 or greater. |
stay_ids | comma-separated string | Named recipients. Cannot be combined with any other filter. |
GET /v1/offers/preview
GET /v1/offers/preview?tiers=gold,platinum&min_party_size=2
Response
JSON
{
"count": 3,
"sample": ["Ringo", "Aretha", "Dolly"]
}
count is the full audience size. sample is the first names of at most five of them, in stay_id order. See core/src/__tests__/offers-api.test.ts for the exact case above.
Errors
| Status | Error | When |
|---|---|---|
| 400 | invalid tier | tiers holds a value outside the three tiers. |
| 400 | invalid min_party_size | min_party_size does not parse as a finite number. |
| 400 | invalid departing | departing is present, non-empty, and not today. |
| 400 | invalid group_min_size | group_min_size is not an integer of 2 or more. |
| 400 | invalid_audience | stay_ids is combined with another filter, or resolves to fewer than 1 or more than 50 unique ids. |
POST /v1/federation/porter/upsells
QR Find delivers a suggestion for the guest to see in the Concierge. It is called by QR Find server to server with x-gi-service-token, the same token the escalations route on Requests and queues checks, and the check runs before any parsing or database work. When no token is configured on the server every call returns 401. The route path says porter because that is the identifier in the code and the database.
Auth: x-gi-service-token.
Request
POST /v1/federation/porter/upsells
POST /v1/federation/porter/upsells
content-type: application/json
x-gi-service-token: <service token>
| Field | Type | Required | Meaning |
|---|---|---|---|
stay_ref | string | required | The guest's porter_ref. |
delivery_id | string | required | QR Find's own id for this delivery. Trimmed, truncated to 128 characters. The idempotency key. |
title | string | required | Headline. Trimmed, truncated to 80 characters. |
body | string | optional | Supporting line. Trimmed, truncated to 200 characters. |
emoji | string | optional | Up to 8 characters. |
moment | string | optional | When in the stay this fits, for example prearrival. Up to 40 characters. |
link | string | required | Where the tap goes. Must start with https://. Truncated to 300 characters. |
JSON
{
"stay_ref": "7f5c1d90a2b34e6f",
"delivery_id": "del-1",
"title": "Sunset dinner cruise",
"body": "Two hours on the water before dinner.",
"emoji": "๐
",
"moment": "prearrival",
"link": "https://porter-taupe.vercel.app/solara-cove?upsell=u1&delivery=del-1"
}
Response
JSON
{ "upsell_id": "b41d9f2a-3c6e-4a71-9f08-5d2c7e1a6b33" }
Repeating a delivery_id updates the existing row's title, body, emoji, moment, and link, returns the same upsell_id, and emits nothing further. One delivery is one row and one porter_upsell_sent event however many times it is posted. A long delivery_id is truncated to 128 characters first, and the upsert matches on the truncated form.
The suggestion then appears in GET /v1/stays/:stay_id/home under concierge_suggestions, unopened first, capped at five, projected down to id, title, body, emoji, link, and opened.
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 | delivery_id_required | delivery_id missing, not a string, or empty after trimming. Nothing is written. |
| 400 | title_required | title empty after trimming, or not a string. |
| 400 | invalid_link | link missing, empty, or not starting with https://. A plain http:// link is rejected. |
POST /v1/porter-upsells/:id/open
The guest tapped the suggestion. Record it.
Auth: x-gi-session. The session must belong to the stay that owns the suggestion.
Request
POST /v1/porter-upsells/b41d9f2a-3c6e-4a71-9f08-5d2c7e1a6b33/open
POST /v1/porter-upsells/b41d9f2a-3c6e-4a71-9f08-5d2c7e1a6b33/open
x-gi-session: <session token>
| Path param | Type | Meaning |
|---|---|---|
id | string | The upsell_id returned by the upsells route. |
No body.
Response
JSON
{ "ok": true }
The open timestamp is stamped once. A second call returns 200 and changes nothing, and the porter_upsell_opened event fires exactly once.
Errors
| Status | Error | When |
|---|---|---|
| 404 | unknown_upsell | No suggestion with that id. Checked before the session. |
| 401 | unauthorized | Session header missing, or belonging to a different stay than the suggestion. |
Notes
- The upsells route and the open route sit on different sides of the trust boundary. QR Find holds the service token, the guest holds a session token, and neither works in the other's place.
- Activity lines sourced from QR Find carry the
QR Findchip: the suggestion-sent line and the suggestion-opened line.
Who is eligible. Every audience is filtered down to stays that are origin = 'live', have analytics_consent = 1, and have been recognized. Consent is the gate on the whole feature: an unconsented stay never enters an audience, never contributes evidence to an opening, and never has its ref sent to QR Find or TimeLens.
The approval gate. An offer priced above gm.approval_threshold_usd in the property config is stored pending_approval and sends nothing. Approval recomputes eligibility at approval time, so the audience reflects who is actually on property when the send happens, and it restarts the expiry clock: the guest gets the original window length, measured from approval. Rejection closes the offer with no instances. Both verbs require the offer to still be pending_approval.
Openings never nag. A stay already holding a live sent or viewed instance of a topic's play is dropped from that topic entirely. A pending offer counts as covering its named stays, and a pending offer with a broadcast audience suppresses its whole topic until the GM resolves it, since the eventual recipients are unknown until approval.
Capacity closes an offer. The accept that fills capacity also expires every remaining sent or viewed instance and closes the offer, inside the same transaction. Stragglers then get sold_out rather than offer_expired, which is the honest difference between losing the last slot and missing the window.
Viewing happens on the home route. Nothing here marks an offer viewed. GET /v1/stays/:stay_id/home stamps a sent instance as viewed and emits offer_viewed once, the first time the guest's screen loads it.
Sources fail soft. GET /v1/openings calls out to QR Find for questions and TimeLens for exploration features. Either source can be absent or down, and the route still answers with whatever evidence it has, reporting the failed source as unavailable. Both are vendor sandboxes in this build. Results are cached for five minutes.