Partner API · Pricing v2 · preview on staging
Operator price sheets, trips and a per-guest quote
Pricing v2 is the operator's own price engine: a price sheet per trip, frozen when it is written, quoted per guest. On the partner API it is read + quote — every door below is a GET except the quote, and the quote writes nothing.
What it is, in ten lines
- Every operator is on the
legacyengine until 12SA staff flips that one operator tov2. Nothing flips by itself. - An operator on v2 sells trips: a departure plus the price sheet it is sold at.
- A sheet holds cabin prices, occupancy adjustments, age rules, rate plans, agency plans and fees — as rows, each with its own
time_unit. - Three layers share that one body: the boat standard sheet, periods (seasons), and the trip sheet.
- A new trip is pre-filled from the narrowest period covering its departure, else the boat sheet, else it starts empty.
- From then on the trip's prices are frozen: editing a period, a plan or a cabin moves no existing trip.
- The quote runs per guest, in a fixed order, percentages compounding on the running result, never below 0.
- Every amount is a fixed-2 string in the operator's currency and
SUM(lines) === total, literally. - A refusal is
{ error, field, message }andfieldis the input path.warningsis always present. - Until an operator is flipped, its sheets and trips read like anyone else's, but its quote and price lines answer
engine_not_v2. Bookings are never blocked by any of this.
The three layers
Same table, same body; kind is the only difference. What a read answers is exactly what the operator's editor saves.
| Layer | kind | One per | What it is |
|---|---|---|---|
| Boat standard sheet | boat | boat | The operator's default price list. Ignored while incomplete — never half-copied. |
| Period | period | free, many per boat | A sheet with date_from / date_to. Overlap is a warning; the narrowest period wins. |
| Trip sheet | trip | trip | The frozen prices a departure is sold at. This is what a quote reads. |
A period is resolved once, at write time — when the trip is pre-filled or when the operator turns follow on — and never at quote time. A trip that follows a period carries follow_sheet_id; GET /v1/pricing/trips/{trip_id} answers the effective sheet, so you never have to resolve that yourself.
Prices freeze at write
- Editing a period, the boat sheet, an age category, a rate plan's percent or a fee type's name moves no existing trip. A sheet keeps its own copy of every percent.
- Changing a trip's dates re-prices nothing — not even into another season. The answer carries
trip_dates_changed_price_frozen. - A cabin renamed in Fleet changes the label only;
base_occupancyandcabin_capacityare frozen onto the sheet row. - The only two ways a typed price moves are deliberate second calls by the operator: the follow toggle (a binding, reversible) and re-apply (per trip, ticked by name, no undo). Neither is on the partner API.
- A booking's money lives in its own price lines, written once at creation. Bookings are never touched by any sheet edit.
The quote is per guest
For each guest in a cabin, in this order. Steps 5 and 6 are percentages of the running result; fees are appended after the guests are summed and are never discounted.
| # | Line kind | What it is |
|---|---|---|
| 1 | cabin | The cabin's per-guest figure × its own time-unit multiplier (days, nights or 1). |
| 2 | single / extra_guest | The occupancy adjustment. An extra guest beyond base occupancy replaces steps 1–2 (hotel extra-bed reading). |
| 3 | non_diver | A fixed amount × its own multiplier. Never a percent. |
| 4 | age_rule | Only when the guest's berth position ≥ discount_from_guest. |
| 5 | rate_plan | A percent of the running result. |
| 6 | agency | The smart-agency percent of the running result. |
| — | fee | amount_per_guest_per_day × trip days × heads. Days, never nights. |
A charter runs charter_base → charter_extra_pax → rate_plan → agency → fee over the whole hull instead.
Worked example A — the numbers
Trip: 5 days / 4 nights, mode per_day. Sheet: cabin 3,000,000 per day with base occupancy 2; single supplement 50%; non-diver 700,000 per day; park fee 200,000 per guest per day; rate plan Early Bird −10%. Party: one adult, non-diver, alone in the cabin.
| step | line | amount | running |
|---|---|---|---|
| 1 | cabin 3,000,000 × 5 days | 15,000,000 | 15,000,000 |
| 2 | single +50% of 15,000,000 | 7,500,000 | 22,500,000 |
| 3 | non_diver 700,000 × 5 days — not scaled by the 50% | −3,500,000 | 19,000,000 |
| 5 | rate_plan −10% of 19,000,000 — it reduces the single too | −1,900,000 | 17,100,000 |
| — | fee 200,000 × 5 days × 1 guest, never discounted | 1,000,000 | 18,100,000 |
Guest total 17,100,000, quote total 18,100,000 — the API answers"17100000.00" and "18100000.00". A sheet-level time unit would have answered 20,620,000: every stored figure carries its own unit for exactly this reason.
The engine flip is staff-only
Exactly one door and one CLI write engine = 'v2', both 12SA staff, both taken by hand after a dry-run that proves every future departure has a fully priced trip. There is no flip on the partner API, no flip on the operator platform, and no boot job or seed that ever sets it. Read engine off GET /v1/pricing/operators and build behind it. A legacy operator still reads — sheets, periods and trips are authored before the flip, and every read door answers them — but its quote and its bookings' price lines answer engine_not_v2.
Walk-through — three calls
Hosts follow the Demo / Live pill in the nav (now demo-openapi.12seasalliance.com). The preview lives on staging: the buttons above switch you to Live, which on the staging portal is the staging gate — use an sk_test_ key there. The demo gate does not carry these doors at all (a plain 404 problem, not a pricing refusal), and production holds no v2 operator yet: reads answer, the quote answers engine_not_v2 — that is the inert state, not an outage.
engine, mode and currency. Anything on legacy is not priced by this model yet.curl https://demo-openapi.12seasalliance.com/v1/pricing/operators \ -H "x-api-key: sk_test_..."
{
"ok": true,
"operators": [
{ "operator_id": "OPR-3", "operator_name": "Komodo Cruises",
"engine": "v2", "mode": "per_day", "currency": "IDR", "currency_source": "operator",
"diver_pricing": true, "smart_agency_rate": false,
"charter_per_night_default": true, "min_charter_days_default": 0,
"updated_at": "2026-09-20T08:12:44.000Z" }
],
"next_cursor": null,
"warnings": []
}sheet in the answer is the one a quote will use — the followed period's when the trip follows one, the trip's own otherwise. Money is a fixed-2 string; cabin_name is a live label and never decides a price.curl https://demo-openapi.12seasalliance.com/v1/pricing/trips?boat_id=BOT-1&from=2026-07-01&to=2026-08-31 \ -H "x-api-key: sk_test_..."
curl https://demo-openapi.12seasalliance.com/v1/pricing/trips/TRP-9 \ -H "x-api-key: sk_test_..."
{
"ok": true,
"trip": { "trip_id": "TRP-9", "operator_id": "OPR-3", "boat_id": "BOT-1", "type": "open",
"departure_date": "2026-07-14", "return_date": "2026-07-18", "days": 5, "nights": 4, ... },
"sheet": { "sheet_id": "PSH-4", "kind": "trip", "time_unit": "per_day", "is_complete": true,
"cabins": [ { "cabin_id": "CAB-1", "cabin_name": "Deluxe", "duration_days": 0,
"public_amount": "3000000.00", "public_time_unit": "per_day",
"agent_amount": "2000000.00", "base_occupancy": 2, "cabin_capacity": 3 } ],
"boatwide": [ ... ], "charter": [ ... ], "age_rules": [ ... ],
"fees": [ ... ], "rate_plans": [ ... ], "agency_plans": [ ... ] },
"follow_sheet_id": null,
"warnings": []
}18100000.00 above.curl https://demo-openapi.12seasalliance.com/v1/pricing/quote \
-H "x-api-key: sk_test_..." \
-H "content-type: application/json" \
-d '{
"items": [
{
"ref": "A",
"trip_id": "TRP-9",
"cabin_id": "CAB-1",
"rate_plan_id": "RTP-1",
"party": [
{
"guest_ref": "g1",
"age_category_id": "adult",
"diver": false,
"age": 41
}
]
}
]
}'{
"ok": true,
"lane": "partner",
"results": [
{
"ref": "A",
"trip_id": "TRP-9",
"cabin_id": "CAB-1",
"type": "open",
"currency": "IDR",
"total": "18100000.00",
"lines": [
{ "guest_ref": "g1", "kind": "cabin", "side": "sell", "label": "Deluxe",
"amount": "15000000.00", "currency": "IDR", "sort_order": 10,
"meta": { "figure": 3000000, "unit": "amount", "time_unit": "per_day",
"multiplier": 5, "cabin_id": "CAB-1" } },
{ "guest_ref": "g1", "kind": "single", "side": "sell", "amount": "7500000.00", "currency": "IDR", ... },
{ "guest_ref": "g1", "kind": "non_diver", "side": "sell", "amount": "-3500000.00", "currency": "IDR", ... },
{ "guest_ref": "g1", "kind": "rate_plan", "side": "sell", "amount": "-1900000.00", "currency": "IDR", ... },
{ "kind": "fee", "side": "sell", "amount": "1000000.00", "currency": "IDR", ... }
],
"guests": [ { "guest_ref": "g1", "position": 1, "takes_berth": true, "total": "17100000.00" } ],
"warnings": [],
"resolved": { "cabin_id": "CAB-1", "base_occupancy": 2, "cabin_capacity": 3,
"days": 5, "nights": 4, "rate_plan_id": "RTP-1", "agency_plan_id": null,
"berths": 1, "trip_id": "TRP-9", "type": "open", "lane": "partner",
"currency": "IDR", "agency_id": null, "agent_price": false,
"capacity": 20, "sheet_id": "PSH-4", "follows_sheet_id": null }
}
]
}HTTP 422
{ "error": "engine_not_v2", "field": "items[0].trip_id",
"message": "This operator is still on the legacy price engine." }Every door on this face
Scopes: pricing:read for the reads, pricing:quote for the quote. A key with no scopes listed has all of them.
| Door | Scope | Answers |
|---|---|---|
GET /v1/pricing/operators | pricing:read | Every operator you can see, with engine, mode and currency. Cursor-paged. |
GET /v1/pricing/operators/{operator_id}/settings | pricing:read | One operator's pricing settings (contract §1). No row = the defaults, engine legacy. |
GET /v1/pricing/boats/{boat_id}/sheet | pricing:read | The boat standard sheet; sheet: null + boat_sheet_incomplete when there is none. |
GET /v1/pricing/periods?boat_id=&from=&to= | pricing:read | Seasons on a boat, with the overlaps[] each one has. |
GET /v1/pricing/periods/{sheet_id} | pricing:read | One period's full sheet body. |
GET /v1/pricing/trips?boat_id=&from=&to= | pricing:read | Trips (departures) on a boat or operator. Cursor-paged by departure date. |
GET /v1/pricing/trips/{trip_id} | pricing:read | The trip and its EFFECTIVE sheet (the followed period's when following). |
GET /v1/pricing/rate-plans?operator_id= | pricing:read | Rate plans: name, percent, scope, active, agents_allowed. |
GET /v1/pricing/agency-plans?operator_id= | pricing:read | Agency plans: name, percent. |
GET /v1/pricing/fee-types?operator_id= | pricing:read | Fee types. A fee type is a name; the amount lives on the sheet. |
GET /v1/pricing/age-categories | pricing:read | Your company's age bands (the age_category_id a party guest names). |
GET /v1/bookings/{booking_id}/price-lines | pricing:read | A v2 booking's sell-side price lines and total_sell. Legacy booking = engine_not_v2. |
POST /v1/pricing/quote | pricing:quote | Batch quote, no writes. Up to 200 items; everything but the whitelist is dropped. |
Full schemas, examples and try-it: Pricing v2 in the reference.
Good to know
- Every pricing answer is
{ ok, <thing>, warnings: [] }.warningsis always there — render it, never branch on whether it exists. - A refusal is
{ error, field, message, …extras }at 404 / 422 (500pricing_failedfrom the quote). Put the message on the inputfieldnames, e.g.items[0].party[1].age_category_id. You never seetenant_requiredhere — a key this gate accepted that the engine refuses is a 502 problem for us to fix. - Money is a fixed-2 string with an explicit
currency. Do not re-round; do not compute a price in the browser — quote instead, it is cheap enough to call on every keystroke. - Not-found and not-yours are both 404 on this face. Auth, scope and rate-limit refusals stay
application/problem+jsonlike the rest of the partner API. - Reads come straight off the tables; the quote runs on the backend engine and is passed through verbatim. A 503 Quote unavailable means that hop is down, not your request.
- Price lines on a booking are sell side only here: what the customer pays, never the operator's base or the margin.
- No writes on the partner API in this version. Sheets, periods, trips and plans are authored in the operator platform.