API reference

Report

The funnel, the offer split, and the stay-level CSV behind them.

Two reads back the Report tab in Hotel Operator. GET /v1/report returns the funnel and the offer numbers. GET /v1/report/export.csv returns the same funnel one row per stay, so a finance team can check the arithmetic rather than take it on trust.

The payload separates two populations. Seed rows are a modeled season stored in the funnel table. Live rows are computed from real stays in this build. They are reported apart and combined, never blended silently.

GET /v1/report

The full payload.

Auth: unauthenticated in this build.

Request

GET /v1/report

GET /v1/report

No path params, no query params, no body.

Response

Adapted from core/src/__tests__/report.test.ts: the seeded season plus one live guest who accepted a broadcast offer and a demand-led one.

JSON

{
  "combined": {
    "eligible": 28804,
    "reached": 17281,
    "recognized": 13825,
    "acted": 3457,
    "acted_stay_rate": 0.12001805304818775,
    "modeled_influenced_value_usd": 1071565
  },
  "seed": {
    "eligible": 28800,
    "reached": 17280,
    "recognized": 13824,
    "acted": 3456,
    "acted_stay_rate": 0.12,
    "modeled_influenced_value_usd": 1071360
  },
  "live": {
    "eligible": 4,
    "reached": 1,
    "recognized": 1,
    "acted": 1,
    "acted_stay_rate": 0.25,
    "modeled_influenced_value_usd": 205
  },
  "value_per_acted_stay_usd": 309.9696268440845,
  "recovery": { "incidents": 0, "gestures_sent": 0, "gestures_claimed": 0, "recovery_spend_usd": 0 },
  "offers": {
    "rows": [
      {
        "label": "Same-day spa slot",
        "sent": 1,
        "viewed": 0,
        "accepted": 1,
        "conversion": 1,
        "revenue_usd": 180,
        "status": "open",
        "demand_led": true
      },
      {
        "label": "Late checkout",
        "sent": 2,
        "viewed": 0,
        "accepted": 1,
        "conversion": 0.5,
        "revenue_usd": 25,
        "status": "open",
        "demand_led": false
      }
    ],
    "totals": {
      "sent": 3,
      "accepted": 2,
      "revenue_usd": 205,
      "split": {
        "broadcast": { "sent": 2, "accepted": 1, "revenue_usd": 25, "conversion": 0.5 },
        "demand_led": { "sent": 1, "accepted": 1, "revenue_usd": 180, "conversion": 1 }
      }
    }
  },
  "rebook_holds": 0,
  "group_bookings": { "count": 0, "revenue_usd": 0 },
  "method_note": "Simulated example only. Finance must confirm the incremental revenue method, measured lift and confidence.",
  "reach_note": "Reach includes operator offers rendered on a guest screen.",
  "measured_lift": "finance test pending"
}

The funnel blocks

combined, seed, and live share one shape.

FieldTypeMeaning
eligiblenumberStays with analytics_consent = 1. Consent is the gate, nothing else.
reachednumberStays that got a recommendation, or an offer that rendered on their screen (viewed) or that they accepted.
recognizednumberReached stays that also carry a recognized_at.
actednumberReached stays with at least one action row. Counted once per stay however many times they acted.
acted_stay_ratenumberacted / eligible, or 0 when nothing is eligible.
modeled_influenced_value_usdnumberSum of action values on acted stays.

value_per_acted_stay_usd is the combined modeled value divided by combined acted stays, and 0 when nothing acted.

The offers block

One row per offer, newest offer first, plus totals. conversion is accepted / sent, or 0 when nothing was sent. status is the effective status at read time, so an offer past its expires_at reads expired even though its stored row still says open. demand_led is true when the offer named its recipients with a stay_ids audience, which is how the split separates a targeted send from a broadcast.

The rest

FieldTypeMeaning
recovery.incidentsnumberfriction_detected events from source make_it_right, all time.
recovery.gestures_sentnumberrecovery_sent events from the same source.
recovery.gestures_claimednumberrecovery_claimed events from the same source.
recovery.recovery_spend_usdnumberSum of cost_usd across recovery_sent event params.
rebook_holdsnumberRequests of type rebook_hold with status done.
group_bookings.countnumberCompleted requests created by a group activity, plus accepted instances of offers carrying a group_min_size audience.
group_bookings.revenue_usdnumberValue behind those two counts.
method_notestringThe honesty line, straight from core/config/solara-cove.json.
reach_notestringFixed string explaining that reach includes rendered operator offers.
measured_liftstringAlways the literal "finance test pending". This build measures no lift.

Errors

None. The route always returns 200.

GET /v1/report/export.csv

The funnel, one row per stay, as CSV.

Auth: unauthenticated in this build.

Request

GET /v1/report/export.csv

GET /v1/report/export.csv

No path params, no query params, no body. The response carries content-type: text/csv.

Response

A fixed header line, then the seed rows in stay-id order, then the live rows in stay-id order. With the shipped season that is 28,800 seed rows before the first live one, so the sample below shows the first two and then the live block from the same scenario as the JSON above.

Code

stay_id,origin,eligible,reached,recognized,acted,acted_value_usd
seed-000001,seed,1,1,1,1,310
seed-000002,seed,1,1,1,1,310
stay-198,live,0,0,0,0,0
stay-233,live,1,0,0,0,0
stay-412,live,1,0,0,0,0
stay-509,live,0,0,0,0,0
stay-655,live,1,1,1,1,205
stay-777,live,0,0,0,0,0
stay-871,live,1,0,0,0,0

Seven columns, no quoting and no escaping. Every stay appears exactly once, and summing the columns reproduces the combined block from GET /v1/report.

Errors

None. The route always returns 200.

Notes

  • Both routes read the same computation. liveFunnelRows in report.ts backs each of them, so the CSV cannot drift from the JSON.
  • eligible is consent and nothing else. A stay that declined, or was never asked, stays out of the denominator whatever it does afterwards.
  • A stay counts as acted once. A second booking adds value, not another acted stay.
  • measured_lift is a placeholder on purpose. Nothing in this build runs a holdout, and the payload says so rather than implying otherwise.
  • The numbers above come from core/src/__tests__/report.test.ts with the seeded season in place. A database with no season seeded returns zeroed seed and combined blocks.