openapi: 3.1.0
info:
  title: Partner Booking API
  version: 1.4.0
  description: |
    Partner (vendor) booking API. Every route lives under `/api/partner/v1`
    on the operator's booking domain.

    **Auth:** `Authorization: Bearer <keyId>.<secret>`. Test keys (`pk_test_…`)
    work as soon as a vendor is enabled; live keys (`pk_live_…`) only once live
    access is switched on (otherwise `SANDBOX_ONLY`). Keys are issued in the
    operator's admin (Vendors); the secret is shown once. Keep it server-side.

    **Envelope:** `{ ok: true, data, requestId }` or
    `{ ok: false, error: { code, message, details? }, requestId }`; the
    `X-Request-Id` header carries the same id (send your own to correlate).
    `error.details` is machine-readable context — every property optional,
    listed on `ErrorEnvelope` and on each response; branch on `error.code`
    first, then on `details.reason` / `details.field`, and treat an unknown
    `reason` like the code's generic case (new values can be added in a
    minor version).

    **Units:** money is integer cents (USD); dates `YYYY-MM-DD`; times `HH:mm`
    in the venue's local time.

    **Limits:** per key `rateLimitPerMinute` (default 60) → `429 RATE_LIMITED`
    with `Retry-After` and `details.retryAfterSeconds` (the same number of
    seconds); plus 120/min per IP. Every response to an
    authenticated key carries `X-RateLimit-Limit` (your per-minute key limit)
    and `X-RateLimit-Remaining` (requests left in the current minute); an
    authentication refusal (401, or a 403 for the key itself) does not.
    A per-IP `429` is answered before your key is read, in the same error
    envelope: `requestId` + `X-Request-Id`, `details.retryAfterSeconds` and
    `Retry-After` (the same number of seconds), and `X-RateLimit-Limit` (the
    per-IP limit) with `X-RateLimit-Remaining: 0`.

    **No card data** is accepted by any route. Two settlement modes, set per
    account (`GET /me` → `settlementMode`):
    - **on account** — the booking is confirmed at once and invoiced to you on
      a monthly statement (`paymentStatus: invoiced_pending`), up to your
      account's credit limit (`GET /me` → `creditLimitCents`, `openCents`):
      a booking that would take your open + invoiced (unpaid) statements
      (plus any on-account booking not yet on a statement) over it →
      `409 CREDIT_LIMIT` (`details.reason: credit_limit`).
    - **pay link** — the booking is created `awaiting_payment` and its
      capacity is HELD for you at once (nobody else can take those seats) until
      `holdExpiresAt` (your account's `holdMinutes`, never past the start).
      The customer pays on the operator's hosted secure page at `payLinkUrl`
      (emailed to the customer, or to your notification address when your
      account routes confirmations to you). Paid → `confirmed` and the
      confirmation email is sent; not paid in time → `expired` and the seats
      are released. Poll `GET /bookings/{ref}` for the current status. A
      pay-link booking cannot be rescheduled before it is paid — cancel it and
      book again. Pay-link settlement is not available with net-rate pricing.
      At most 25 unpaid pay-link bookings (100 seats) can hold seats for you at
      once. While the customer's payment is being processed the booking reads
      `payment_in_progress`.

    **Availability rules:** a slot is `open` only when the experience runs that
    day (not blacked out, opening hours set), the slot is not blocked by staff,
    it is outside the experience's lead time and — for today — outside the
    site-wide booking lead time, and resource capacity remains. `remaining` is
    a seat indicator (the number of resources with room in the tightest group,
    `8` when the experience has no resource limits), not an exact count.

    **Quotes are advisory:** `POST /bookings` prices through the same code and
    additionally runs the full checkout cart validation (warm-up lines without
    a main experience, checkout rules, add-on eligibility such as
    self-drive-only extras and per-experience extras, extra time-slot blocks)
    and a capacity check on the final cart. A quote holds nothing.

    **Idempotency:** `POST /bookings` requires an `Idempotency-Key` header
    (1-100 chars of `A-Z a-z 0-9 _ . -`), unique per booking you create.
    Sending the same key again with the SAME body returns the original booking
    with `200` and `replayed: true`; the same key with a DIFFERENT body →
    `409 DUPLICATE` (`details.reason: body_mismatch` — key order in the JSON
    does not matter); two concurrent first requests with
    the same key → one `201`, the other `409 DUPLICATE` (retry to read it).
    Keys are scoped per partner and per mode (test and live never collide);
    the scope is your account itself, so a change to your `slug` (`GET /me`)
    never re-opens a key you already used.

    **Ownership:** a key reads and cancels only bookings its partner created
    through this API, in its own mode — a test key sees only sandbox bookings
    (`TST-…`), a live key only live ones (the operator's booking numbers, e.g. `DR…`). Anything else is `404`.

    **Sandbox (test keys, `pk_test_…`):** the same code with live catalogue and
    availability, but a booking is quarantined: numbered `TST-<n>`, holds NO
    capacity (the capacity checks still answer), moves NO money, writes no
    commission or statement, is invisible to the operator's staff tools, and emails ONLY
    your notification address with a `[SANDBOX] ` subject (never the customer
    or drivers; no SMS). Pay-link vendors get a `payLinkUrl` to a sandbox pay
    page where you choose "pay" or "decline" (decline can be retried; paid,
    cancelled and expired are final). Sandbox bookings are deleted 7 days
    after creation. Waiver links are `null` in the sandbox.

    **Sandbox triggers** — put a tag in the CUSTOMER email's local part
    (`ann+soldout@example.com`; tags combine):
    `+soldout` → `409 UNAVAILABLE`; `+duplicate` → `409 DUPLICATE`;
    `+declined` → the sandbox pay page always declines; `+slow` → the
    booking call waits 3 s. Tags do nothing on a live key.
    A trigger error also carries `details.trigger` (`soldout`, `duplicate`);
    `+soldout` is `details.reason: sold_out`.

    **Sandbox certification:** before live keys are enabled, complete the
    checklist at `GET /certification` with your test key (also summarised on
    `GET /me`); the operator confirms it, then switches live access on.

    **Browser (CORS) access** is for TEST keys only, from your listed
    origins; live keys are server-to-server (no CORS headers). Every route
    answers an `OPTIONS` preflight with `204` and `Vary: Origin`. A preflight
    carries no key, so it allows your `Origin` when an enabled account with an
    active test key lists that origin: then `Access-Control-Allow-Origin`
    (your origin), `Access-Control-Allow-Methods: GET, POST, OPTIONS`,
    `Access-Control-Allow-Headers: Authorization, Content-Type,
    Idempotency-Key, X-Request-Id` and `Access-Control-Max-Age: 600`;
    otherwise none of those headers. The real request gets
    `Access-Control-Allow-Origin` only when it carries a test key and your
    account lists the origin, plus `Vary: Origin` and
    `Access-Control-Expose-Headers: X-Request-Id, Retry-After,
    X-RateLimit-Limit, X-RateLimit-Remaining`.
    A per-IP `429` is answered before your key is read and carries no CORS
    headers, so a browser app sees it as a network error; pace browser calls
    under 120/min per IP.

    **Limits on bookings:** 30 requests/min per IP across `/bookings*`, plus
    10 writes/min per key (create and cancel), on top of the per-key limit.
servers:
  - url: https://{bookingDomain}/api/partner/v1
    variables:
      bookingDomain:
        default: dreamracing.com
        description: "Your booking site's domain"
security:
  - partnerKey: []
paths:
  /health:
    get:
      operationId: health
      summary: Liveness check (no auth)
      security: []
      responses:
        '200':
          description: Up
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          status: { type: string, const: ok }
                          version: { type: string, const: v1 }
                          time: { type: string, format: date-time }
  /me:
    get:
      operationId: getMe
      summary: The calling partner, key mode and experience allow-list
      responses:
        '200':
          description: Profile
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Me' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /certification:
    get:
      operationId: getCertification
      summary: Sandbox certification checklist + the exact request to make next (test keys only)
      description: |
        Before live keys are enabled, work through a fixed checklist with your
        TEST key. Every step is detected automatically from the calls you make
        (we keep pathnames, status codes and error codes only — never bodies or
        query strings); progress is the same on `GET /me` and in the operator's
        admin. When every required step has passed the operator confirms and
        switches on live access.

        Steps, in order (ids are stable): `catalog` GET /experiences ·
        `experience` GET /experiences/{slug} · `availability` GET /availability ·
        `slots` GET /availability/slots · `quote` POST /quote ·
        `booking_created` POST /bookings → 201 · `booking_fetched` GET
        /bookings/{ref} · `booking_listed` GET /bookings · `pay_success` pay a
        sandbox booking on its `payLinkUrl` page · `pay_declined` decline one
        (or book with a `+declined` customer email) · `sold_out_handled` POST
        /bookings → 409 UNAVAILABLE (`+soldout`) · `duplicate_handled` POST
        /bookings → 409 DUPLICATE (`+duplicate`, or the same Idempotency-Key with
        a different body) · `replay` POST /bookings → 200 `replayed: true` (send
        the same request twice) · `cancel` POST /bookings/{ref}/cancel ·
        `rate_limit_handled` (optional) a 429 followed by a successful retry at
        least 1 s later · `error_shape` (optional) any 4xx. The two pay steps
        are required only for pay-link accounts (`/me` → `settlementMode`); an
        on-account account's sandbox bookings have no pay page.

        `nextStep` is the first unpassed required step (then the optional
        ones) with a ready-made request: `path` is relative to
        `/api/partner/v1`; `method: BROWSER` means open the absolute `path` URL
        (the sandbox pay page) in a browser. Replace the example date/time with
        an open slot from `/availability/slots`. For the two pay steps
        `request` is `BROWSER` only when you have an unpaid sandbox pay-link
        booking; otherwise it is the `POST /bookings` that creates one. Where
        we have nothing to fill in (no enabled experience, no booking yet) the
        placeholder `{slug}`, `{optionId}` or `{ref}` is left in the path/body.
        A live key → `403 FORBIDDEN`
        (`details.reason: live_key`). Progress older than 30 days falls off
        unless the operator has already confirmed it.
      responses:
        '200':
          description: Checklist progress
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/Certification'
                          - type: object
                            properties:
                              nextStep:
                                oneOf:
                                  - $ref: '#/components/schemas/CertificationNextStep'
                                  - type: 'null'
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /experiences:
    get:
      operationId: listExperiences
      summary: Allow-listed experiences with options priced for your pricing mode
      responses:
        '200':
          description: Catalogue
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Experience' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /experiences/{slug}:
    get:
      operationId: getExperience
      summary: One allow-listed experience
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Experience
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Experience' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /availability:
    get:
      operationId: getAvailability
      summary: Per-date availability (max 92 days per request)
      parameters:
        - { name: experience, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: false, description: Defaults to `from`., schema: { type: string, format: date } }
      responses:
        '200':
          description: Days
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          experience: { type: string }
                          from: { type: string, format: date }
                          to: { type: string, format: date }
                          days:
                            type: array
                            items: { $ref: '#/components/schemas/DayAvailability' }
        '400':
          description: |
            VALIDATION with `details.field`: `experience` (missing), `from` or
            `to` (not `YYYY-MM-DD`, or `to` before `from`). A range longer than
            92 days → `details: { field: to, maxDays: 92 }`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /availability/slots:
    get:
      operationId: getSlots
      summary: Every slot of one day with the seats left
      description: Slots run from opening at the configured slot size; a closed day returns `status closed` and no slots.
      parameters:
        - { name: experience, in: query, required: true, schema: { type: string } }
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
      responses:
        '200':
          description: Slots
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          experience: { type: string }
                          date: { type: string, format: date }
                          status: { $ref: '#/components/schemas/DayStatus' }
                          slotMinutes: { type: integer }
                          slots:
                            type: array
                            items: { $ref: '#/components/schemas/SlotAvailability' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /quote:
    post:
      operationId: createQuote
      summary: Price, concession fee and capacity answer for a cart (advisory; nothing is held)
      description: |
        Prices come from the list price plus our dynamic pricing rules for the
        date and time; the concession fee from the same calculator as checkout.
        `capacity.ok` requires the time to be a bookable slot for every
        experience in the cart and the whole cart to fit. Advisory —
        see "Quotes are advisory" above. Body limit 64 KB.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteRequest' }
      responses:
        '200':
          description: Quote
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: Unknown experience or unknown / inactive extra
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '413':
          description: Body larger than 64 KB (code VALIDATION)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /bookings:
    post:
      operationId: createBooking
      summary: Create a booking (on account, pay link, or a sandbox booking on a test key)
      description: |
        Validates like our checkout (see the description at the top), then:
        **on account** (live key) → a confirmed booking invoiced to you on your
        monthly statement (`paymentStatus: invoiced_pending`) — unless
        `openCents` (`GET /me`: your open + invoiced statements plus any
        on-account booking not yet on a statement) plus this booking's total
        (`totals.totalCents` of a quote) would exceed your credit limit
        (`creditLimitCents`; `0` = no on-account bookings) → `409 CREDIT_LIMIT`
        (`details.reason: credit_limit`, `details.limitCents`,
        `details.openCents`, `details.requestedCents` — integer cents) and
        nothing is created; the same Idempotency-Key may be retried once an
        invoice is paid. Exactly at the limit is allowed. Test keys are never
        refused by the credit limit, so `CREDIT_LIMIT` cannot be rehearsed on a
        test key. Seats of an expired pay-link hold are back on sale within 2
        minutes of `holdExpiresAt`; **pay link**
        (live key) → `status: awaiting_payment`, `paymentStatus: pending`, the
        capacity held until `holdExpiresAt`, and `payLinkUrl` — the hosted page
        where the customer pays (the pay-link email goes to the customer, or to
        your notification address when confirmations are routed to you — you
        may also hand the customer `payLinkUrl` yourself). A pay-link account on
        net-rate pricing → `409 UNAVAILABLE` (`details.reason:
        pay_link_not_available`). A pay-link booking needs someone to send the
        link to: `sendCustomerEmails: false` without a notification address on
        your account → `400 VALIDATION` (`details.field: sendCustomerEmails`).
        A time starting in less than 5 minutes → `409 UNAVAILABLE`
        (`details.reason: too_close_to_start`). If every seat cannot be held
        at the moment of booking (just sold out) → `409 UNAVAILABLE`
        (`details.reason: capacity`, `details.unallocated`) and nothing is
        created — the same Idempotency-Key may be retried. At most 25 unpaid
        pay-link bookings and 100 driving seats may be held for you at once;
        beyond that → `409 UNAVAILABLE` (`details.reason:
        too_many_open_holds`, `details.limit: 25`, `details.seatLimit: 100`;
        when the seat limit is the one reached, also `details.heldSeats` —
        driving seats your unpaid bookings already hold — and
        `details.requestedSeats` — driving seats in this booking)
        until customers pay or you cancel unpaid ones. Add-on seats (e.g. a
        shuttle) are checked for every booking: too few left → `409
        UNAVAILABLE` (`details.reason: capacity`, `details.extra`,
        `details.needed`, `details.free`).
        **test key** → a sandbox booking (see Sandbox).
        Confirmation email: to the customer, or to your notification address
        when your account routes confirmations to you (or when you send
        `sendCustomerEmails: false`); for a pay-link booking it is sent once the
        customer has paid. Waiver requests and pre-visit reminders still go to
        the drivers / customer. No SMS is sent for API bookings.
        Body limit 64 KB.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, pattern: '^[A-Za-z0-9_.-]{1,100}$' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingRequest' }
      responses:
        '201':
          description: Created
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/BookingCreated' }
        '200':
          description: Replay of an Idempotency-Key you already used (`replayed:true`)
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/BookingCreated' }
        '400':
          description: |
            VALIDATION; nothing is created. Either `details.field` — the input
            at fault: `Idempotency-Key` (missing or malformed), a body path
            such as `date`, `time`, `items`, `items[0].optionId`, `extras[1].id`,
            `customer.email`, `drivers`, `drivers[2].dob`, `notes`, or
            `sendCustomerEmails` (pay link with nowhere to send the link) — or
            `details.reason` from the checkout cart validation. `reason` is an
            open string; known values: `warmup_no_experience`,
            `addon_requires_self_drive`, `rule_violation`,
            `pricing_option_not_applicable`, `pricing_option_admin_only`,
            `date_in_past`, `date_missing`, `price_mismatch`,
            `amount_mismatch`, `car_not_found`, `items_unparseable`,
            `items_empty`, `item_missing_carid` (`extra_slot_blocked` is a
            `409 UNAVAILABLE`, below). A body that is not a JSON object carries
            neither.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: Unknown experience or unknown / inactive extra
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: |
            Nothing is created. By `error.code`:
            - **UNAVAILABLE** — `details.reason`:
              `closed`, `not_a_bookable_time`, `blocked`, `past_cutoff`,
              `sold_out` (the time is not bookable for `details.experience`,
              the experience slug); `capacity` (the cart does not fit — with
              `details.unallocated` when the seats could not be held, or
              `details.extra`, `details.needed`, `details.free` for add-on
              seats); `extra_slot_blocked` (an add-on is blocked at that time);
              `pay_link_not_available` (pay link on net-rate pricing);
              `too_close_to_start` (pay link, start under 5 minutes away);
              `too_many_open_holds` (`details.limit`, `details.seatLimit`, and
              `details.heldSeats` / `details.requestedSeats` when the seat limit
              is reached). Sandbox `+soldout`: `sold_out` with
              `details.trigger: soldout`.
            - **CREDIT_LIMIT** — `details.reason: credit_limit`,
              `details.limitCents`, `details.openCents`,
              `details.requestedCents` (integer cents). Do not retry
              automatically; retry with the same Idempotency-Key once an invoice
              is paid or the limit is raised.
            - **DUPLICATE** — the Idempotency-Key is taken: by a different body
              (`details.reason: body_mismatch` — use a new key), by a concurrent
              first request with the same key (no `details`; retry to read the
              booking), or in another mode or account (no `details`). Sandbox
              `+duplicate`: `details.trigger: duplicate`.

            `details.reason` is open: the values above are complete today, but
            treat an unknown value like the code's generic case.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ErrorEnvelope'
                  - type: object
                    properties:
                      error:
                        type: object
                        properties:
                          code: { type: string, enum: [UNAVAILABLE, CREDIT_LIMIT, DUPLICATE] }
                          details:
                            type: object
                            properties:
                              reason:
                                description: Known values for this response; open — an unknown value is the code's generic case.
                                anyOf:
                                  - type: string
                                    enum: [closed, not_a_bookable_time, blocked, past_cutoff, sold_out, capacity, extra_slot_blocked, pay_link_not_available, too_close_to_start, too_many_open_holds, credit_limit, body_mismatch]
                                  - type: string
        '413':
          description: Body larger than 64 KB (code VALIDATION)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      operationId: listBookings
      summary: Your own API bookings (newest first), optionally by experience date
      parameters:
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: 'Experience date ≥ from (inclusive)' }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: 'Experience date ≤ to (inclusive)' }
        - { name: cursor, in: query, required: false, schema: { type: string }, description: 'Opaque — `nextCursor` of the previous page' }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        '200':
          description: A page of bookings
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          bookings:
                            type: array
                            items: { $ref: '#/components/schemas/Booking' }
                          nextCursor: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /bookings/{ref}:
    get:
      operationId: getBooking
      summary: One of your bookings by bookingRef
      parameters:
        - { name: ref, in: path, required: true, schema: { type: string }, description: 'bookingRef (the operator''s booking number live, TST-… sandbox)' }
      responses:
        '200':
          description: The booking
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Booking' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /bookings/{ref}/cancel:
    post:
      operationId: cancelBooking
      summary: Cancel one of your bookings
      description: |
        Free when made at least `freeCancelHours` (see `GET /me`) before the
        start, in the venue's local time (`refund: none`). Later than that the
        booking is still cancelled, and the operator's team reviews any
        fee/refund (`refund: manual`). An on-account booking leaves your open
        statement. **Pay link:** an unpaid booking is cancelled at once, its held
        capacity released and its pay link closed (`refund: none`, also after
        the start time); while the customer is paying at that moment →
        `409 UNAVAILABLE` (`details.reason: payment_in_progress`, retry shortly).
        A paid pay-link booking → `refund: manual` (the operator's team refunds
        the customer; nothing is refunded automatically). A live booking that
        has already started → `409 UNAVAILABLE` (`details.reason: started`).
        Idempotent: a second call answers `alreadyCancelled: true`. No customer
        email is sent.
      parameters:
        - { name: ref, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Cancelled (or already cancelled)
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CancelResult' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409':
          description: 'UNAVAILABLE, not cancelled — `details.reason: payment_in_progress` (the customer is paying right now; retry shortly) or `started` (a live booking that has already started; contact the operator).'
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
components:
  securitySchemes:
    partnerKey:
      type: http
      scheme: bearer
      description: '`<keyId>.<secret>`, e.g. `pk_test_AbC123dEf456GhI7.xxxxxxxx`'
  parameters:
    Slug:
      name: slug
      in: path
      required: true
      schema: { type: string }
  headers:
    RequestId:
      description: The request id (yours when you sent a valid X-Request-Id, else generated)
      schema: { type: string }
    RateLimitLimit:
      description: Your per-key request limit per minute. Sent only once the key is authenticated — absent on 401 and on 403 refusals of the key itself. On a per-IP 429 (answered before the key is read) it is the per-IP limit instead (120, or 30 on `/bookings*`).
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests left for this key in the current minute (0 on a 429). Sent only once the key is authenticated — absent on 401 and on 403 refusals of the key itself.
      schema: { type: integer }
  responses:
    Error:
      description: Error envelope
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        Retry-After: { description: On 429 only, schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: |
        RATE_LIMITED. Wait `Retry-After` seconds, then retry. Over your per-key
        limit (or the 10 writes/min per key on `POST /bookings` and cancel):
        `details.retryAfterSeconds` (same value as `Retry-After`),
        `X-RateLimit-Limit` (your key limit) and `X-RateLimit-Remaining: 0`.
        Over a per-IP limit (120/min, or 30/min on `/bookings*`): answered
        before your key is read, in the same envelope — `requestId` +
        `X-Request-Id`, `details.retryAfterSeconds` (same value as
        `Retry-After`), `X-RateLimit-Limit` (the per-IP limit) and
        `X-RateLimit-Remaining: 0`.
      headers:
        Retry-After: { description: Seconds to wait (always sent on a 429), schema: { type: integer, minimum: 1 } }
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Reset: { description: 'Per-IP 429 only: when the window resets (ISO 8601)', schema: { type: string, format: date-time } }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  schemas:
    OkEnvelope:
      type: object
      required: [ok, data, requestId]
      properties:
        ok: { type: boolean, const: true }
        data: {}
        requestId: { type: string }
    ErrorEnvelope:
      type: object
      required: [ok, error, requestId]
      properties:
        ok: { type: boolean, const: false }
        requestId: { type: string }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [VALIDATION, AUTHENTICATION, FORBIDDEN, NOT_FOUND, UNAVAILABLE, DUPLICATE, CREDIT_LIMIT, RATE_LIMITED, SANDBOX_ONLY, INTERNAL]
            message: { type: string }
            details:
              type: object
              description: |
                Machine-readable context. Every property is optional and appears
                only on the errors that use it (see each response); more
                properties may be added in a minor version — ignore unknown
                ones. Money is integer cents (USD).
              additionalProperties: true
              properties:
                field:
                  type: string
                  description: 'The input at fault: a body path (`items[0].optionId`, `drivers[1].dob`, `customer.email`), a query parameter (`to`) or the `Idempotency-Key` header. On VALIDATION, and on FORBIDDEN / NOT_FOUND for an experience or extra.'
                reason:
                  type: string
                  description: |
                    Why — an OPEN string (treat an unknown value like the code's
                    generic case). Known values: UNAVAILABLE `closed`,
                    `not_a_bookable_time`, `blocked`, `past_cutoff`, `sold_out`,
                    `capacity`, `extra_slot_blocked`, `pay_link_not_available`,
                    `too_close_to_start`, `too_many_open_holds` (POST /bookings),
                    `payment_in_progress`, `started` (cancel); CREDIT_LIMIT
                    `credit_limit`; DUPLICATE `body_mismatch`; FORBIDDEN
                    `live_key` (GET /certification); VALIDATION — the checkout
                    cart validation on POST /bookings (see that response).
                trigger:
                  type: string
                  description: 'Sandbox trigger that produced this error (`soldout`, `duplicate`). Test keys only.'
                experience:
                  type: string
                  description: 'Experience slug whose time is not bookable (UNAVAILABLE `closed` / `not_a_bookable_time` / `blocked` / `past_cutoff` / `sold_out`).'
                maxDays:
                  type: integer
                  description: 'Longest date range allowed (`92`, GET /availability, with `field: to`).'
                maxBytes:
                  type: integer
                  description: 'Largest body accepted (`65536`, 413 VALIDATION).'
                retryAfterSeconds:
                  type: integer
                  minimum: 1
                  description: 'Seconds to wait (every 429 RATE_LIMITED, per-key or per-IP; same value as `Retry-After`).'
                limit:
                  type: integer
                  description: 'Most unpaid pay-link bookings that may hold seats for you at once (`25`, `too_many_open_holds`).'
                seatLimit:
                  type: integer
                  description: 'Most driving seats those bookings may hold (`100`, `too_many_open_holds`).'
                heldSeats:
                  type: integer
                  description: 'Driving seats your unpaid pay-link bookings already hold (`too_many_open_holds`, seat limit reached).'
                requestedSeats:
                  type: integer
                  description: 'Driving seats in the refused booking (`too_many_open_holds`, seat limit reached).'
                unallocated:
                  type: array
                  items: { type: string }
                  description: 'Opaque labels of what could not be held (`capacity`, pay link). Informational — do not parse.'
                extra:
                  type: string
                  description: 'Id of the add-on that is short of seats (`capacity`).'
                needed:
                  type: integer
                  description: 'Add-on seats this booking needs (`capacity`, with `extra`).'
                free:
                  type: integer
                  description: 'Add-on seats left at that time (`capacity`, with `extra`).'
                limitCents:
                  type: integer
                  description: 'Your on-account credit limit, integer cents (`credit_limit`).'
                openCents:
                  type: integer
                  description: 'Your open + invoiced statements plus on-account bookings not yet on a statement, integer cents (`credit_limit`).'
                requestedCents:
                  type: integer
                  description: 'This booking''s total (a quote''s `totals.totalCents`), integer cents (`credit_limit`).'
    PricingMode:
      type: string
      enum: [list_plus_commission, net_rate, list_only]
    Me:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        mode: { type: string, enum: [test, live] }
        sandbox: { type: boolean }
        keyId: { type: string }
        settlementMode: { type: string, enum: [pay_link, on_account] }
        commsMode: { type: string, enum: [customer, vendor] }
        pricingMode: { $ref: '#/components/schemas/PricingMode' }
        netRateBps: { type: integer, description: net_rate only }
        commission:
          type: object
          description: list_plus_commission only (percentage value in basis points; fixed value in cents per booking)
          properties:
            type: { type: string, enum: [percentage, fixed] }
            value: { type: integer }
            onFee: { type: boolean, description: 'true = commission on the total incl. the concession fee; false = on the total minus the fee' }
        holdMinutes: { type: integer }
        freeCancelHours: { type: integer }
        rateLimitPerMinute: { type: integer }
        creditLimitCents: { type: integer, minimum: 0, maximum: 2147483647, description: 'On-account credit limit in cents (0 = no on-account bookings). Since 1.4.0.' }
        openCents: { type: integer, description: 'What you owe on account right now, in cents: the net of your open + invoiced (unpaid) statements, plus the full total of any confirmed on-account booking not yet on a statement. A new on-account booking is refused when openCents + its total > creditLimitCents. OPTIONAL: omitted (never an error) when the balance cannot be read at that moment — treat a missing value as unknown, not 0. Since 1.4.0.' }
        experiences:
          type: array
          items:
            type: object
            properties:
              slug: { type: string }
              name: { type: string }
        certification:
          description: 'TEST keys only (absent on a live key, and omitted if the progress lookup is temporarily unavailable). Sandbox certification progress — see GET /certification.'
          allOf:
            - $ref: '#/components/schemas/Certification'
            - type: object
              properties:
                nextStep:
                  oneOf:
                    - type: object
                      properties:
                        id: { $ref: '#/components/schemas/CertificationStepId' }
                        title: { type: string }
                        required: { type: boolean }
                        hint: { type: string }
                    - type: 'null'
    CertificationStepId:
      type: string
      enum: [catalog, experience, availability, slots, quote, booking_created, booking_fetched, booking_listed, pay_success, pay_declined, sold_out_handled, duplicate_handled, replay, cancel, rate_limit_handled, error_shape]
    Certification:
      type: object
      properties:
        steps:
          type: array
          description: Always every step, in checklist order.
          items:
            type: object
            properties:
              id: { $ref: '#/components/schemas/CertificationStepId' }
              title: { type: string }
              required: { type: boolean }
              passed: { type: boolean }
              passedAt: { type: [string, 'null'], format: date-time, description: 'The earliest qualifying call' }
        requiredPassed: { type: integer }
        requiredTotal: { type: integer }
        complete: { type: boolean, description: Every required step passed }
        certified: { type: boolean, description: 'The operator has confirmed your certification (live access can be switched on)' }
    CertificationNextStep:
      type: object
      properties:
        id: { $ref: '#/components/schemas/CertificationStepId' }
        title: { type: string }
        required: { type: boolean }
        hint: { type: string }
        request:
          description: 'The call to make; null when the step is a sequence (rate_limit_handled)'
          oneOf:
            - type: object
              properties:
                method: { type: string, enum: [GET, POST, BROWSER] }
                path: { type: string, description: 'Relative to /api/partner/v1 (query string included); an absolute URL when method is BROWSER' }
                headers: { type: object, additionalProperties: { type: string } }
                body: { type: object, additionalProperties: true }
            - type: 'null'
    Experience:
      type: object
      properties:
        slug: { type: string }
        name: { type: string }
        description: { type: string }
        category:
          type: object
          properties:
            slug: { type: string }
            name: { type: [string, 'null'] }
        images:
          type: object
          properties:
            thumb: { type: [string, 'null'], format: uri }
            main: { type: [string, 'null'], format: uri }
            hero: { type: [string, 'null'], format: uri }
        publicUrl: { type: string, format: uri }
        leadTimeHours: { type: [integer, 'null'] }
        options:
          type: array
          items: { $ref: '#/components/schemas/ExperienceOption' }
    ExperienceOption:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        lapCount: { type: integer }
        isRideAlong: { type: boolean }
        listPriceCents: { type: integer }
        netPriceCents: { type: integer, description: net_rate only }
        commissionCents: { type: integer, description: list_plus_commission only }
    DayStatus:
      type: string
      enum: [open, sold_out, closed, past_cutoff]
    DayAvailability:
      type: object
      properties:
        date: { type: string, format: date }
        status: { $ref: '#/components/schemas/DayStatus' }
        remaining: { type: integer, description: 'Most seats in any open slot (seat indicator; 8 when unconstrained). 0 unless open.' }
        bestTime: { type: [string, 'null'], description: 'HH:mm, earliest slot with the most room' }
    SlotAvailability:
      type: object
      properties:
        time: { type: string }
        remaining: { type: integer, description: 'Seat indicator: resources with room in the tightest group; 8 when the experience has no resource limits; 0 unless open.' }
        status: { type: string, enum: [open, sold_out, blocked, past_cutoff] }
    QuoteRequest:
      type: object
      required: [date, time, items]
      properties:
        date: { type: string, format: date }
        time: { type: string, pattern: '^([01]\d|2[0-3]):[0-5]\d$' }
        items:
          type: array
          minItems: 1
          description: At most 50 experience lines in total after `quantity` is expanded.
          items:
            type: object
            required: [experience, optionId]
            properties:
              experience: { type: string, description: Experience slug }
              optionId: { type: string }
              driverIndex: { type: integer, minimum: 0, maximum: 49, description: 'Groups lines driven by the same person; omit → each line is its own driver (ride-alongs take no seat). A line with driverIndex must have quantity 1.' }
              quantity: { type: integer, minimum: 1, maximum: 20, default: 1, description: 'Expands into that many lines, one driver each. Must be 1 when driverIndex is set.' }
        extras:
          type: array
          maxItems: 50
          description: Active extras only (unknown or inactive id → 404 NOT_FOUND).
          items:
            type: object
            required: [id]
            properties:
              id: { type: string }
              quantity: { type: integer, minimum: 1, maximum: 50, default: 1 }
    Quote:
      type: object
      properties:
        date: { type: string, format: date }
        time: { type: string }
        currency: { type: string, const: USD }
        pricingMode: { $ref: '#/components/schemas/PricingMode' }
        lines:
          type: array
          items:
            type: object
            properties:
              experience: { type: string }
              optionId: { type: string }
              optionName: { type: string }
              isRideAlong: { type: boolean }
              driverIndex: { type: [integer, 'null'] }
              listPriceCents: { type: integer }
              priceCents: { type: integer, description: After dynamic pricing rules }
              pricingRule: { type: [string, 'null'] }
              netPriceCents: { type: integer, description: net_rate only }
        extras:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              quantity: { type: integer }
              priceCents: { type: integer }
        fees:
          type: array
          items:
            type: object
            properties:
              label: { type: string }
              amountCents: { type: integer }
        totals:
          type: object
          properties:
            subtotalCents: { type: integer }
            feeCents: { type: integer }
            totalCents: { type: integer }
            netTotalCents: { type: integer, description: 'net_rate only: net share of every line + the full fee' }
            commissionCents: { type: integer, description: 'list_plus_commission only: on the order total, or the total minus the fee when your commission excludes the fee (`/me` commission.onFee)' }
        capacity:
          type: object
          properties:
            ok: { type: boolean }
            reason:
              type: string
              enum: [closed, not_a_bookable_time, blocked, past_cutoff, sold_out, capacity]
              description: 'The first failing check: the slot for `experience` (closed / not_a_bookable_time / blocked / past_cutoff / sold_out), else the whole-cart capacity check (capacity).'
            message: { type: string }
            experience: { type: string, description: 'Slug whose slot failed (slot reasons only)' }
    BookingRequest:
      type: object
      required: [date, time, items, customer, drivers]
      description: 'Same `date`, `time`, `items`, `extras` as QuoteRequest (same limits), plus:'
      properties:
        date: { type: string, format: date }
        time: { type: string, pattern: '^([01]\d|2[0-3]):[0-5]\d$' }
        items: { $ref: '#/components/schemas/QuoteRequest/properties/items' }
        extras: { $ref: '#/components/schemas/QuoteRequest/properties/extras' }
        customer:
          type: object
          required: [name, email]
          properties:
            name: { type: string, maxLength: 120 }
            email: { type: string, format: email, description: 'Sandbox triggers read its +tag' }
            phone: { type: string, maxLength: 40 }
        drivers:
          type: array
          minItems: 1
          maxItems: 50
          description: 'One entry per driver; must cover every driverIndex used in items (lines without driverIndex are numbered 0, 1, … after the explicit ones).'
          items:
            type: object
            required: [name]
            properties:
              name: { type: string, maxLength: 120 }
              email: { type: string, format: email, description: 'Waiver requests go here (live)' }
              phone: { type: string, maxLength: 40 }
              dob: { type: string, format: date }
        notes: { type: string, maxLength: 1000, description: 'Shown to our team on the booking' }
        sendCustomerEmails: { type: boolean, default: true, description: 'false → the confirmation (and, for pay link, the payment-link email) goes to your notification address instead of the customer; on account with no notification address it is not sent; pay link with no notification address → 400' }
    BookingStatus:
      type: string
      enum: [confirmed, awaiting_payment, payment_in_progress, payment_declined, expired, cancelled]
      description: |
        confirmed — booked (on account, or paid); awaiting_payment — pay link
        not yet paid (capacity held until holdExpiresAt); payment_in_progress —
        the customer's payment is being processed or checked by the operator
        (seats stay held, never `expired` meanwhile; cancel answers 409
        `payment_in_progress`); payment_declined —
        sandbox pay page declined (retryable); expired — the pay link lapsed
        unpaid and the held capacity was released (final); cancelled — final.
    Booking:
      type: object
      properties:
        bookingRef: { type: string, description: 'The operator''s booking number (live, e.g. DR…) or TST-… (sandbox)' }
        idempotencyKey: { type: [string, 'null'] }
        sandbox: { type: boolean }
        status: { $ref: '#/components/schemas/BookingStatus' }
        bookingStatus: { type: string, description: 'Operational schedule status (scheduled, cancelled, completed, no_show…)' }
        paymentStatus: { type: string, description: 'invoiced_pending (on account); pay link: pending (awaiting payment), captured (paid), pending_review (payment received, being checked by the operator); sandbox: captured (paid), failed (declined)' }
        settlementMode: { type: string, enum: [on_account, pay_link] }
        date: { type: [string, 'null'], format: date }
        time: { type: [string, 'null'] }
        customer:
          type: object
          properties:
            name: { type: string }
            email: { type: string }
            phone: { type: [string, 'null'] }
        drivers:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              name: { type: string }
              email: { type: [string, 'null'] }
              phone: { type: [string, 'null'] }
        items:
          type: array
          items:
            type: object
            properties:
              experience: { type: [string, 'null'] }
              optionId: { type: [string, 'null'] }
              optionName: { type: [string, 'null'] }
              driverIndex: { type: [integer, 'null'] }
              isRideAlong: { type: boolean }
              priceCents: { type: integer }
        extras:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              quantity: { type: integer }
              priceCents: { type: integer }
        totals:
          type: object
          properties:
            subtotalCents: { type: integer }
            feeCents: { type: integer }
            totalCents: { type: integer }
            commissionCents: { type: [integer, 'null'], description: 'Your commission on this booking (null when none / sandbox)' }
        payLinkUrl: { type: [string, 'null'], format: uri, description: 'While `status` is `awaiting_payment`: the hosted page where the customer pays (live) or the sandbox pay page (test key); null otherwise' }
        holdExpiresAt: { type: [string, 'null'], format: date-time, description: 'Pay link: when the capacity hold and the pay link lapse if unpaid. null once paid, cancelled or expired, and for on-account bookings' }
        waivers:
          type: array
          items:
            type: object
            properties:
              driverIndex: { type: integer }
              driver: { type: string }
              status: { type: string, enum: [signed, pending] }
              url: { type: [string, 'null'], format: uri, description: 'The booking''s forwardable waiver link (same for every driver); null in the sandbox and while a pay-link booking is unpaid / expired' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    BookingCreated:
      allOf:
        - $ref: '#/components/schemas/Booking'
        - type: object
          properties:
            replayed: { type: boolean }
    CancelResult:
      type: object
      properties:
        booking: { $ref: '#/components/schemas/Booking' }
        alreadyCancelled: { type: boolean }
        refund: { type: string, enum: [none, manual] }
        lateCancellation: { type: boolean }
        hoursBeforeStart: { type: [integer, 'null'] }
        freeCancelHours: { type: integer }
