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.
| Field | Type | Meaning |
|---|---|---|
eligible | number | Stays with analytics_consent = 1. Consent is the gate, nothing else. |
reached | number | Stays that got a recommendation, or an offer that rendered on their screen (viewed) or that they accepted. |
recognized | number | Reached stays that also carry a recognized_at. |
acted | number | Reached stays with at least one action row. Counted once per stay however many times they acted. |
acted_stay_rate | number | acted / eligible, or 0 when nothing is eligible. |
modeled_influenced_value_usd | number | Sum 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
| Field | Type | Meaning |
|---|---|---|
recovery.incidents | number | friction_detected events from source make_it_right, all time. |
recovery.gestures_sent | number | recovery_sent events from the same source. |
recovery.gestures_claimed | number | recovery_claimed events from the same source. |
recovery.recovery_spend_usd | number | Sum of cost_usd across recovery_sent event params. |
rebook_holds | number | Requests of type rebook_hold with status done. |
group_bookings.count | number | Completed requests created by a group activity, plus accepted instances of offers carrying a group_min_size audience. |
group_bookings.revenue_usd | number | Value behind those two counts. |
method_note | string | The honesty line, straight from core/config/solara-cove.json. |
reach_note | string | Fixed string explaining that reach includes rendered operator offers. |
measured_lift | string | Always 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.
liveFunnelRowsinreport.tsbacks each of them, so the CSV cannot drift from the JSON. eligibleis 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
actedonce. A second booking adds value, not another acted stay. measured_liftis 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.tswith the seeded season in place. A database with no season seeded returns zeroed seed and combined blocks.