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.

FieldMeaning
topic, label, play_id, intent_noteStraight 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.
countNumber of entries in stays.
stays[].notedtrue 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[].kindasked (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).
sourcesPer-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

StatusErrorWhen
--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

StatusErrorWhen
--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.

FieldTypeRequiredMeaning
play_idstringrequiredId of a play in offer_plays, for example cabana_day_pass. Supplies the label, headline, body, default price, and default capacity.
staff_idstringrequiredThe staff member sending the offer. Must exist in the staff table.
capacitynumberoptionalHow many accepts the offer allows. Defaults to the play's default_capacity.
expires_in_minutesnumberoptionalWindow length from now. Defaults to 240.
price_usdnumberoptionalOverrides the play price. Must be greater than zero.
audienceobjectoptionalWho receives it. Empty means every eligible stay. See the audience table below.
intent_notestringoptionalThe "why you are seeing this" line on the guest card. 1 to 80 characters after trimming.
intent_note_stay_idsstring[]optionalWhich 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):

FieldTypeMeaning
tiersstring[]Any of standard, gold, platinum.
min_party_sizenumberParty size at or above this number.
departing"today"Stays departing on the property's calendar day.
group_min_sizenumberInteger 2 or greater. Targets the organizers of active groups at or above that size.
stay_idsstring[]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

StatusErrorWhen
400play_id and staff_id requiredEither field is missing.
400invalid_priceprice_usd is present and not greater than zero.
400invalid_group_min_sizegroup_min_size is not an integer of 2 or more.
400invalid_audiencestay_ids is combined with another filter, or holds fewer than 1 or more than 50 unique ids.
400invalid_intent_noteThe note is empty or over 80 characters after trimming, or intent_note_stay_ids names a stay outside the audience.
404unknown_playNo play with that play_id.
404unknown_staffNo staff member with that staff_id.
409empty_audienceThe 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

ParamTypeRequiredMeaning
id (path)stringrequiredThe pending offer.
FieldTypeRequiredMeaning
staff_idstringrequiredThe 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

StatusErrorWhen
400staff_id requiredThe body has no staff_id.
404unknown_offerNo offer with that id.
409invalid_stateThe offer is not pending_approval.

POST /v1/offers/:id/reject

Reject a gated offer. Nothing is sent.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe pending offer.
FieldTypeRequiredMeaning
staff_idstringrequiredThe 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

StatusErrorWhen
400staff_id requiredThe body has no staff_id.
404unknown_offerNo offer with that id.
409invalid_stateThe offer is not pending_approval.

POST /v1/offers/:id/close

Close an open offer by hand.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
id (path)stringrequiredThe 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

StatusErrorWhen
404unknown_offerNo offer with that id.
409invalid_stateThe 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

ParamTypeRequiredMeaning
id (path)stringrequiredThe 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

StatusErrorWhen
401unauthorizedNo x-gi-session header, or the token belongs to a different stay than the instance.
404unknown_instanceNo instance with that id.
409sold_outThe offer is no longer open and accepted instances have reached capacity. Someone else took the last slot.
409offer_expiredThe 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

StatusErrorWhen
--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 paramTypeMeaning
tierscomma-separated stringAny of standard, gold, platinum. An unknown tier is rejected.
min_party_sizenumberParty size at or above this number. Must parse as a finite number.
departingstringOnly today is accepted. An empty value is ignored.
group_min_sizenumberInteger 2 or greater.
stay_idscomma-separated stringNamed 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

StatusErrorWhen
400invalid tiertiers holds a value outside the three tiers.
400invalid min_party_sizemin_party_size does not parse as a finite number.
400invalid departingdeparting is present, non-empty, and not today.
400invalid group_min_sizegroup_min_size is not an integer of 2 or more.
400invalid_audiencestay_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>
FieldTypeRequiredMeaning
stay_refstringrequiredThe guest's porter_ref.
delivery_idstringrequiredQR Find's own id for this delivery. Trimmed, truncated to 128 characters. The idempotency key.
titlestringrequiredHeadline. Trimmed, truncated to 80 characters.
bodystringoptionalSupporting line. Trimmed, truncated to 200 characters.
emojistringoptionalUp to 8 characters.
momentstringoptionalWhen in the stay this fits, for example prearrival. Up to 40 characters.
linkstringrequiredWhere 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

StatusErrorWhen
401unauthorizedToken missing, wrong, or none configured on the server. Checked first.
404unknown_stay_refstay_ref missing, not a string, or matching no stay.
400delivery_id_requireddelivery_id missing, not a string, or empty after trimming. Nothing is written.
400title_requiredtitle empty after trimming, or not a string.
400invalid_linklink 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 paramTypeMeaning
idstringThe 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

StatusErrorWhen
404unknown_upsellNo suggestion with that id. Checked before the session.
401unauthorizedSession 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 Find chip: 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.