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.

Try the quote in the playgroundReference: Pricing v2 →

What it is, in ten lines

  1. Every operator is on the legacy engine until 12SA staff flips that one operator to v2. Nothing flips by itself.
  2. An operator on v2 sells trips: a departure plus the price sheet it is sold at.
  3. A sheet holds cabin prices, occupancy adjustments, age rules, rate plans, agency plans and fees — as rows, each with its own time_unit.
  4. Three layers share that one body: the boat standard sheet, periods (seasons), and the trip sheet.
  5. A new trip is pre-filled from the narrowest period covering its departure, else the boat sheet, else it starts empty.
  6. From then on the trip's prices are frozen: editing a period, a plan or a cabin moves no existing trip.
  7. The quote runs per guest, in a fixed order, percentages compounding on the running result, never below 0.
  8. Every amount is a fixed-2 string in the operator's currency and SUM(lines) === total, literally.
  9. A refusal is { error, field, message } and field is the input path. warnings is always present.
  10. 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.

LayerkindOne perWhat it is
Boat standard sheetboatboatThe operator's default price list. Ignored while incomplete — never half-copied.
Periodperiodfree, many per boatA sheet with date_from / date_to. Overlap is a warning; the narrowest period wins.
Trip sheettriptripThe 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

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 kindWhat it is
1cabinThe cabin's per-guest figure × its own time-unit multiplier (days, nights or 1).
2single / extra_guestThe occupancy adjustment. An extra guest beyond base occupancy replaces steps 1–2 (hotel extra-bed reading).
3non_diverA fixed amount × its own multiplier. Never a percent.
4age_ruleOnly when the guest's berth position ≥ discount_from_guest.
5rate_planA percent of the running result.
6agencyThe smart-agency percent of the running result.
—feeamount_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.

steplineamountrunning
1cabin 3,000,000 × 5 days15,000,00015,000,000
2single +50% of 15,000,0007,500,00022,500,000
3non_diver 700,000 × 5 days — not scaled by the 50%−3,500,00019,000,000
5rate_plan −10% of 19,000,000 — it reduces the single too−1,900,00017,100,000
—fee 200,000 × 5 days × 1 guest, never discounted1,000,00018,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.

1
Find an operator on v2. List the operators your key can see and read engine, mode and currency. Anything on legacy is not priced by this model yet.
Request
curl https://demo-openapi.12seasalliance.com/v1/pricing/operators \
  -H "x-api-key: sk_test_..."
200 OK
{
  "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": []
}
2
Read a trip and its effective sheet. List trips on a boat, then fetch one. The 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.
Trips on a boat
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_..."
One trip, effective sheet
curl https://demo-openapi.12seasalliance.com/v1/pricing/trips/TRP-9 \
  -H "x-api-key: sk_test_..."
200 OK
{
  "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": []
}
3
Quote it. One call, any number of items (≤ 200), no writes. Name the trip, the cabin, an optional rate plan and the party; the server resolves the type, the currency, the capacity and whether a guest takes a berth — a body that sends those has them dropped. This is worked example A; the answer is the 18100000.00 above.
POST /v1/pricing/quote — example A
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
        }
      ]
    }
  ]
}'
200 OK
{
  "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 }
    }
  ]
}
…or, while the operator is still on legacy
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.

DoorScopeAnswers
GET /v1/pricing/operatorspricing:readEvery operator you can see, with engine, mode and currency. Cursor-paged.
GET /v1/pricing/operators/{operator_id}/settingspricing:readOne operator's pricing settings (contract §1). No row = the defaults, engine legacy.
GET /v1/pricing/boats/{boat_id}/sheetpricing:readThe boat standard sheet; sheet: null + boat_sheet_incomplete when there is none.
GET /v1/pricing/periods?boat_id=&from=&to=pricing:readSeasons on a boat, with the overlaps[] each one has.
GET /v1/pricing/periods/{sheet_id}pricing:readOne period's full sheet body.
GET /v1/pricing/trips?boat_id=&from=&to=pricing:readTrips (departures) on a boat or operator. Cursor-paged by departure date.
GET /v1/pricing/trips/{trip_id}pricing:readThe trip and its EFFECTIVE sheet (the followed period's when following).
GET /v1/pricing/rate-plans?operator_id=pricing:readRate plans: name, percent, scope, active, agents_allowed.
GET /v1/pricing/agency-plans?operator_id=pricing:readAgency plans: name, percent.
GET /v1/pricing/fee-types?operator_id=pricing:readFee types. A fee type is a name; the amount lives on the sheet.
GET /v1/pricing/age-categoriespricing:readYour company's age bands (the age_category_id a party guest names).
GET /v1/bookings/{booking_id}/price-linespricing:readA v2 booking's sell-side price lines and total_sell. Legacy booking = engine_not_v2.
POST /v1/pricing/quotepricing:quoteBatch 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