openapi: 3.1.0

info:
  title: Solar Juice Partner API
  version: 1.0.0
  summary: Catalogue, pricing, inventory, specials, shipping quotes and orders for Solar Juice sales channels.
  description: |
    # Overview

    The Solar Juice Partner API lets an approved sales channel (a partner with a
    Solar Juice trade account) read its own price list, sellable inventory and
    granted specials, obtain freight quotes that match the Solar Outlet checkout
    exactly, and place orders against its trade account.

    Everything a channel sees is scoped to that channel: the prices returned are
    the channel's prices, the specials are the specials granted to the channel,
    and the orders are the orders placed with the channel's keys. There is no
    way to see another channel's data.

    There is one base URL. Sandbox is not a separate host: a sandbox key
    (`sj_test_...`) is accepted by the same service as a production key
    (`sj_live_...`), against the same catalogue, the same prices and the same
    inventory, and freight quotes are real. What differs is the effect: orders
    placed with a `sj_test_` key are stored with `sandbox: true`, never reach
    operations, and are listed back to you flagged. Use a test key to build
    against, then swap in a live key with no other change.

    # Getting started

    ## 1. Get a key

    Keys are minted in the developer portal at https://dev.solarjuice.com.au
    by an owner on your channel. Portal access is by invitation from your
    Solar Juice account manager.

    A key looks like `sj_live_<keyid>_<secret>` or, in the sandbox,
    `sj_test_<keyid>_<secret>`. Sandbox keys read the same shapes but never
    touch live stock or your trade account, so start there. The secret is
    shown once, at creation. Store it somewhere your application can read at
    runtime and never commit it.

    ## 2. Make a request

    Send the key as a bearer token. Every response carries `X-Request-Id`;
    quote it when you contact support.

    ```
    curl -sS https://api.solarjuice.com.au/v1/catalogue?limit=2 \
      -H "Authorization: Bearer $SOLARJUICE_API_KEY"
    ```

    Every list response has the same envelope. `items` holds the page,
    `next_cursor` is the cursor for the following page and is `null` on the
    last one, and `as_of` is the freshest source timestamp behind the page.

    ```
    {
      "as_of": "2026-09-02T02:14:07Z",
      "price_list_version": "plv_9f3a2c1d84b6e05",
      "stale": false,
      "items": [ ... ],
      "next_cursor": "U0tVLTAxMQ"
    }
    ```

    ## 3. Sync a catalogue

    Read every page once, keep `as_of`, then pass it back as
    `updated_since` on the next run so you only fetch what changed. The
    official SDKs do this walk for you.

    ## 4. Sell and order

    Quote freight with `POST /v1/shipping/quotes`, then place the order with
    `POST /v1/orders`, passing the `quote_id` and the `price_list_version`
    you sold at. Sending the version is what lets us reject an order that
    was priced against a stale list rather than silently repricing it.

    Set a `client_reference` (your purchase order number) on every order. It
    is the idempotency key: a retry after a timeout with the same
    `client_reference` and the same body returns the original order rather
    than creating a second one.

    ## Sandbox and live

    The sandbox is not a separate host. The same base URL serves both, and
    the key decides which environment you are in. Move to a live key only
    once your order flow works end to end against the sandbox.

    A sandbox key reads the real catalogue, the real prices and the real
    inventory, and its freight quotes are real quotes from the real engine.
    What it does not do is simulate fulfilment. A sandbox order is validated,
    priced and stored exactly as a live one, reaches `accepted`, and stops
    there: operations never see it, so it will never move to `processing` or
    `dispatched`, and no stock is committed. Test your cancellation path
    instead, which does work in the sandbox from `accepted`.

    # SDKs

    Official clients, each with zero runtime dependencies and a conformance
    test that fails if this specification grows an operation the client does
    not implement.

    | Language | Package | Source |
    | --- | --- | --- |
    | Node.js 18+ | `@solarjuice/partner-api` | https://github.com/Solar-Juice/solarjuice-node |
    | PHP 8.1+ | `solarjuice/partner-api` | https://github.com/Solar-Juice/solarjuice-php |
    | Ruby 3.0+ | `solarjuice-partner-api` | https://github.com/Solar-Juice/solarjuice-ruby |

    Each handles authentication, cursor pagination, conditional requests,
    retry with backoff on 429 and 5xx, and maps every error code below to
    its own exception class.

    You do not need one. The API is plain JSON over HTTPS and works with any
    HTTP client.

    # Authentication

    Every request except `GET /v1/health` must carry a bearer token:

    ```
    Authorization: Bearer sj_live_k7m3qp2xr9vt_9f2c4b1e7a6d8c3f5e0b2a4d6c8e1f3a
    ```

    A key has the form `sj_<env>_<keyid>_<secret>` where `env` is `live` or
    `test`, `keyid` is 12 lower case base32 characters and `secret` is 32
    hexadecimal characters. The full key is shown once when it is issued; only the prefix
    `sj_live_<keyid>` is visible afterwards. Store the full key in a secrets
    manager, never in source control. A key can be revoked at any time, after
    which every request with it returns `401 UNAUTHORIZED`.

    Keys carry scopes. A request to an operation outside the key's scopes returns
    `403 FORBIDDEN`. Stage 1 scopes are `catalogue:read`, `inventory:read`,
    `specials:read`, `shipping:quote`, `orders:read` and `orders:write`.

    # Rate limits

    Limits are enforced per key with a sliding one minute window. The default
    allowance is 600 requests per minute; a different allowance can be agreed
    per key. Every response carries:

    | Header | Meaning |
    |---|---|
    | `RateLimit-Limit` | Requests allowed per window for this key |
    | `RateLimit-Remaining` | Requests remaining in the current window |
    | `RateLimit-Reset` | Seconds until the window resets |

    When the allowance is exhausted the API returns `429 RATE_LIMITED` with a
    `Retry-After` header in seconds. Back off for at least that long. Repeated
    429s without backing off may lead to the key being throttled further.

    `GET /v1/health` is the one exception. It is unauthenticated, so there is
    no key to count against: it carries no `RateLimit-*` headers and never
    answers `429`.

    # Request ids

    Every response carries `X-Request-Id` (for example
    `req_01j6zk3m5x8qw2r7y9v4b1n0pd`). The same value is repeated as
    `request_id` inside every error body. Quote it when raising a support
    request; it lets Solar Juice find the exact request in the logs.

    # Pagination and `updated_since`

    All list endpoints are cursor paginated:

    * `limit` sets the page size. Default 100, maximum 500.
    * `cursor` is an opaque string taken from the previous page's
      `next_cursor`. Do not construct or decode it.
    * Every list response carries `next_cursor`. It is `null` on the last page.
    * Every list response carries `as_of`, the freshest source timestamp
      behind the data on the page.

    * A `limit` above 500 is clamped to 500 rather than refused. A `limit`
      that is not a positive integer, and a `cursor` that is not one the API
      issued, are both `400 VALIDATION_FAILED`.

    A cursor encodes a position, not a query. Changing `limit`,
    `updated_since` or a filter partway through a walk is accepted and simply
    resumes from that position under the new query, so the page you get back
    is the one the new query would have produced from there. Finish a walk
    with the parameters you started it with if you want a coherent snapshot.

    Catalogue, inventory, specials and orders accept `updated_since=<ISO 8601>`
    and return only items changed at or after that instant. The filter is
    driven by the service's own change sequence, not the underlying product
    record's edit time, so it is safe to pass the `as_of` value from your last
    response: you will receive everything changed since, and nothing will be
    skipped because of clock differences. A sync loop is therefore:

    1. Fetch all pages of `/v1/catalogue` with no `updated_since`; remember `as_of`.
    2. Periodically fetch `/v1/catalogue?updated_since=<as_of>` and apply the changes.
    3. Store the new `as_of`.

    Inventory tuples that drop to zero are still returned by `updated_since`
    queries (with `total: 0`) so a client can clear them.

    ## `updated_since` does not see every price change

    On the catalogue, `updated_at` tracks the product read model: it moves
    when the product record changes, including its list price. It does not
    move when the price you pay changes for any other reason. A special
    starting or ending, a change to your channel discount, or a per SKU
    override being added all change `price` while `updated_at` stands still,
    so a catalogue sync driven by `updated_since` alone will keep selling at
    yesterday's price.

    `X-Price-List-Version` is the reliable signal. It is derived from the
    resolved prices themselves, so it changes whenever any price in your
    catalogue changes, whatever the cause. Keep the last value you saw,
    compare the header on every response, and when it differs re-read the
    full catalogue rather than an `updated_since` slice. The version is also
    what `POST /v1/orders` checks: submitting an order against a version that
    is no longer current is `409 PRICE_CHANGED`.

    # Webhooks

    There are none in stage 1. Solar Juice does not call your systems, and
    there is nothing to register or verify. Polling is the intended pattern:
    `GET /v1/orders?updated_since=` for order status, `X-Price-List-Version`
    for prices, and `GET /v1/inventory?updated_since=` for stock.

    # Idempotency

    `POST /v1/orders` is idempotent on `client_reference`, and on nothing
    else. Each channel has a unique `client_reference` per order (for example
    your purchase order number). The first submission returns `202` with the
    new order. Submitting the same `client_reference` again with the same
    body returns `200` with the order that already exists; submitting it with
    a different body returns `409 IDEMPOTENCY_CONFLICT`. The comparison is on
    the meaningful content of the request (reference, version, quote, rate,
    delivery address, lines and note), normalised for case and whitespace, so
    a retry that differs only in formatting is still a replay.

    Sandbox and live have separate `client_reference` namespaces: the same
    reference used with a `sj_test_` key and a `sj_live_` key is two orders,
    not a conflict.

    An `Idempotency-Key` header is accepted for compatibility with generic
    HTTP clients and is then ignored. It is not stored, not compared and not
    returned, so it cannot be used to reconcile anything. Use
    `client_reference`.

    `POST /v1/shipping/quotes` produces a new `quote_id` for a cart the API
    has not seen. It is not idempotent in the strict sense, but it is cached:
    an identical normalised cart, destination, origin and channel within the
    quote's lifetime is served the same `quote_id` again, as long as at least
    five minutes of its validity remain. In practice a repeated quote for an
    unchanged cart returns the same id for up to about 25 minutes, and then a
    fresh one. Never rely on either behaviour; read `quote_id` and
    `expires_at` from the response you have.

    # Versioning and deprecation

    The API is versioned in the path (`/v1`). Within a version, Solar Juice may
    add new fields to responses, add new optional request fields, add new
    endpoints and add new values to enumerations that are documented as open
    (order status, error code). Clients must ignore fields they do not know.

    Breaking changes ship as a new path version. When a version is deprecated
    Solar Juice gives at least 12 months notice. During that period every
    response from the deprecated version carries a `Deprecation` header and a
    `Sunset` header (RFC 8594) with the date after which it will stop
    responding. Deprecation notices are also sent to the technical contact
    registered for each channel.

    # Money and timestamps

    * Money is a decimal string with exactly two decimal places, in AUD and
      exclusive of GST unless the field name says otherwise: `"1234.50"`.
      Never a float. `gst` and `total` on an order are the exceptions: `gst`
      is the GST amount and `total` is inclusive of GST.
    * Percentages are JSON numbers, not strings, with at most two decimal
      places: `percent_off` for 15 percent is `15`, and for 12.5 percent is
      `12.5`. Money is a string and percentages are numbers; the two do not
      follow the same rule.
    * Quantities are integers.
    * Timestamps are UTC ISO 8601 with a `Z` suffix: `2026-09-02T04:10:11Z`.
    * SKUs are Solar Juice part numbers, case sensitive, for example
      `GW-5000-DNS-30`.
    * Metro names are title case: `Sydney`, `Melbourne`, `Brisbane`, `Perth`,
      `Adelaide`, `Townsville`.

    # Caching

    Priced responses are per channel and are served with
    `Cache-Control: private, no-store`: the catalogue, specials, shipping
    quotes and orders. `GET /v1/orders/{id}` returns a weak `ETag` of the form
    `W/"a1b2c3d4e5f60718"` and honours `If-None-Match` with a `304`; compare
    it byte for byte and send back exactly what you received, weak prefix
    included. There is no edge cache: every request reaches the API and
    counts against the rate limit.

    # Errors

    Every error response, whatever the status code, has the same body:

    ```json
    {
      "error": {
        "code": "PRICE_CHANGED",
        "message": "Human readable explanation",
        "details": [],
        "request_id": "req_01j6zk3m5x8qw2r7y9v4b1n0pd"
      }
    }
    ```

    `details` is a list of free-form objects whose keys depend on the code.
    Field problems carry `field` and `reason` (not `message`); `PRICE_CHANGED`
    and `IDEMPOTENCY_CONFLICT` use their own keys again. Read `code` and
    `message` for behaviour and treat `details` as diagnostic: new keys may
    be added at any time.

    | Code | HTTP | When |
    |---|---|---|
    | `UNAUTHORIZED` | 401 | Missing, malformed, revoked or wrong-environment key |
    | `FORBIDDEN` | 403 | Key lacks the scope for this operation, or the channel is suspended |
    | `NOT_FOUND` | 404 | No such SKU, order or route for this channel |
    | `VALIDATION_FAILED` | 422 | Request body or query does not validate; `details` lists each problem |
    | `RATE_LIMITED` | 429 | Per key allowance exhausted; honour `Retry-After` |
    | `PRICE_CHANGED` | 409 | Order line prices or `price_list_version` no longer match; `details` carries current values |
    | `IDEMPOTENCY_CONFLICT` | 409 | Same `client_reference` submitted with a different body |
    | `QUOTE_UNAVAILABLE` | 503 | Freight engine temporarily unreachable; honour `Retry-After` |
    | `STALE_DATA` | 503 | The catalogue or inventory read model is not loaded on the instance that answered; honour `Retry-After` and retry |
    | `INTERNAL` | 500 | Unexpected failure; retry with backoff and report the `request_id` if it persists |

    `STALE_DATA` means one thing only: the instance that answered has no
    catalogue or inventory document loaded, usually for a few seconds after a
    deployment or a publish. It is not a staleness threshold and it is not a
    trading halt. The separate `stale` flag on catalogue and inventory
    responses is the age signal, and orders are accepted normally while it is
    `true`: treat stale figures as indicative, not as a reason to stop.

  termsOfService: https://dev.solarjuice.com.au/terms
  contact:
    name: Solar Juice Developer Program
    url: https://dev.solarjuice.com.au
    email: developers@solarjuice.com.au
  license:
    name: Solar Juice Partner API Terms
    url: https://dev.solarjuice.com.au/terms

servers:
  - url: https://api.solarjuice.com.au
    description: >-
      The API. Accepts both sj_live_ and sj_test_ keys; a test key puts the
      request in sandbox mode, where orders are stored flagged and never reach
      operations.

security:
  - bearerAuth: []

tags:
  - name: Catalogue
    description: Products available to this channel with the channel's own price. Kits are excluded in stage 1.
  - name: Inventory
    description: Sellable quantity per metro, identical to what the Solar Outlet storefront sees. The API does not reserve stock.
  - name: Specials
    description: Time boxed price reductions granted to this channel. Granted specials are already reflected in catalogue prices.
  - name: Shipping
    description: Freight quotes produced by the same engine as the Solar Outlet checkout. A quote is required to place an order.
  - name: Orders
    description: Orders placed against this channel's trade account. Validation and acceptance are synchronous; poll the order for fulfilment status.
  - name: Health
    description: Unauthenticated liveness check.

paths:
  /v1/catalogue:
    get:
      tags: [Catalogue]
      operationId: listCatalogue
      summary: List catalogue products with this channel's prices
      description: |
        Returns every active product this channel can buy, with the channel's
        current price per SKU. `list_price` is the public list price (the
        compare-at figure) and `price` is what this channel pays; when a granted
        special is the reason `price` is lower, `special` carries its id.

        The response header `X-Price-List-Version` (also present as
        `price_list_version` in the body) identifies the price list the page was
        priced from. Send it back on `POST /v1/orders`; if the list has changed in
        between, the order is refused with `409 PRICE_CHANGED` and you should
        refresh.

        It is also the only reliable change signal for prices. `updated_at`
        moves when the product record changes; it does not move when a special
        starts or ends, or when your channel discount or a per SKU override
        changes, all of which change `price`. So watch
        `X-Price-List-Version`, and when it differs from the one you hold,
        re-read the whole catalogue rather than an `updated_since` slice.

        Supports cursor pagination, `updated_since` and the `brand` and
        `category` filters; see the introduction.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/updated_since'
        - name: brand
          in: query
          description: |
            Return only products with this brand. The whole value must match,
            but case is ignored, so `goodwe` and `GoodWe` are the same filter.
          required: false
          schema:
            type: string
            examples: [GoodWe]
        - name: category
          in: query
          description: |
            Return only products in this category, for example `Inverter`,
            `Panel`, `Battery` or `Rail`. Matched like `brand`: whole value,
            case ignored.
          required: false
          schema:
            type: string
            examples: [Inverter]
      responses:
        '200':
          description: A page of catalogue products.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-Price-List-Version:
              $ref: '#/components/headers/X-Price-List-Version'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CatalogueList'
              examples:
                firstPage:
                  summary: First page of two products, more pages follow
                  value:
                    as_of: '2026-09-02T04:10:11Z'
                    price_list_version: plv_9f3a2c1d84b6e05
                    stale: false
                    items:
                      - sku: GW-5000-DNS-30
                        title: GoodWe 5kW Single Phase Hybrid Inverter
                        brand: GoodWe
                        category: Inverter
                        list_price: '1234.50'
                        price: '1110.99'
                        special: null
                        weight_kg: '18.50'
                        images:
                          - https://cdn.shopify.com/s/files/1/0000/0000/products/gw-5000-dns-30.jpg
                        updated_at: '2026-09-02T04:10:11Z'
                      - sku: JKM-440N-54HL4-V
                        title: Jinko Tiger Neo 440W N-Type Mono
                        brand: Jinko
                        category: Panel
                        list_price: '145.00'
                        price: '119.00'
                        special: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                        weight_kg: '21.80'
                        images:
                          - https://cdn.shopify.com/s/files/1/0000/0000/products/jkm-440n.jpg
                        updated_at: '2026-09-01T22:30:00Z'
                    next_cursor: eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wMVQyMjozMDowMFoiLCJza3UiOiJKS00tNDQwTi01NEhMNC1WIn0
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/StaleData'

  /v1/catalogue/{sku}:
    get:
      tags: [Catalogue]
      operationId: getCatalogueProduct
      summary: Get one catalogue product
      description: |
        Returns one product with this channel's current price. `404 NOT_FOUND`
        is returned for SKUs that do not exist, are not active, or are kits
        (excluded in stage 1). The `X-Price-List-Version` header is the version
        to submit with an order for this SKU.
      parameters:
        - $ref: '#/components/parameters/sku'
      responses:
        '200':
          description: The product.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-Price-List-Version:
              $ref: '#/components/headers/X-Price-List-Version'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CatalogueProduct'
              examples:
                product:
                  summary: A product priced with the channel discount, no special
                  value:
                    sku: GW-5000-DNS-30
                    title: GoodWe 5kW Single Phase Hybrid Inverter
                    brand: GoodWe
                    category: Inverter
                    list_price: '1234.50'
                    price: '1110.99'
                    special: null
                    weight_kg: '18.50'
                    images:
                      - https://cdn.shopify.com/s/files/1/0000/0000/products/gw-5000-dns-30.jpg
                    updated_at: '2026-09-02T04:10:11Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/StaleData'

  /v1/inventory:
    get:
      tags: [Inventory]
      operationId: listInventory
      summary: List sellable inventory per metro
      description: |
        Returns sellable quantity (on hand minus committed) per metro for
        every SKU the inventory feed carries stock for. The figures are
        exactly the ones the Solar Outlet storefront sells from, refreshed on
        the same schedule.

        Inventory is not scoped per channel in stage 1: the feed is one set of
        figures shared by every channel, so this listing can contain a SKU
        that is not in your catalogue. Join to `/v1/catalogue` if you need
        only the products you can buy.

        * `locations` lists every metro the service knows about. `available`
          on each item only contains metros with a non zero quantity.
        * `as_of` is the freshest source timestamp in the data, `as_of_oldest`
          the stalest. The `X-Inventory-As-Of` header repeats `as_of`.
        * `stale` is `true` when the inventory feed is older than the
          configured threshold (default two hours). Treat stale figures as
          indicative only; orders are still accepted subject to availability.

        The API does not reserve stock. An order is accepted subject to
        availability and may later move to `on_hold` or `rejected` if stock
        cannot be allocated.

        Supports cursor pagination and `updated_since`; with `updated_since`,
        SKUs whose stock has dropped to zero are returned with `total: 0` and an
        empty `available` map so you can clear them.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/updated_since'
      responses:
        '200':
          description: A page of inventory items.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-Inventory-As-Of:
              $ref: '#/components/headers/X-Inventory-As-Of'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryList'
              examples:
                lastPage:
                  summary: Final page, one SKU has just gone to zero
                  value:
                    as_of: '2026-09-02T04:10:11Z'
                    as_of_oldest: '2026-09-02T03:55:00Z'
                    stale: false
                    locations: [Sydney, Melbourne, Brisbane, Perth, Adelaide, Townsville]
                    items:
                      - sku: GW-5000-DNS-30
                        available:
                          Sydney: 12
                          Melbourne: 4
                        total: 16
                        updated_at: '2026-09-02T04:10:11Z'
                      - sku: JKM-440N-54HL4-V
                        available: {}
                        total: 0
                        updated_at: '2026-09-02T04:08:40Z'
                    next_cursor: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/StaleData'

  /v1/inventory/{sku}:
    get:
      tags: [Inventory]
      operationId: getInventoryItem
      summary: Get sellable inventory for one SKU
      description: |
        Returns the per metro sellable quantity for one SKU together with the
        feed timestamps.

        Any SKU returns `200`. One with no stock anywhere, and one the
        inventory feed has never heard of, both come back with `total: 0` and
        an empty `available` map: the endpoint answers "how much can I sell",
        and the answer for an unknown SKU is none. There is deliberately no
        `404`, so this call cannot be used to test whether a SKU exists. Use
        `GET /v1/catalogue/{sku}`, which does `404`, for catalogue membership.
      parameters:
        - $ref: '#/components/parameters/sku'
      responses:
        '200':
          description: Inventory for the SKU.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            X-Inventory-As-Of:
              $ref: '#/components/headers/X-Inventory-As-Of'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryItemDetail'
              examples:
                unknownSku:
                  summary: A SKU the feed has never seen answers 200 with zero stock
                  value:
                    as_of: '2026-09-02T04:10:11Z'
                    as_of_oldest: '2026-09-02T03:55:00Z'
                    stale: false
                    locations: [Sydney, Melbourne, Brisbane, Perth, Adelaide, Townsville]
                    sku: GW-5000-DNS-99
                    available: {}
                    total: 0
                    updated_at: '2026-09-02T04:10:11Z'
                inStock:
                  summary: Stock in two metros
                  value:
                    as_of: '2026-09-02T04:10:11Z'
                    as_of_oldest: '2026-09-02T03:55:00Z'
                    stale: false
                    locations: [Sydney, Melbourne, Brisbane, Perth, Adelaide, Townsville]
                    sku: GW-5000-DNS-30
                    available:
                      Sydney: 12
                      Melbourne: 4
                    total: 16
                    updated_at: '2026-09-02T04:10:11Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/StaleData'

  /v1/specials:
    get:
      tags: [Specials]
      operationId: listSpecials
      summary: List specials granted to this channel
      description: |
        Returns active and upcoming specials granted to this channel, one row
        per SKU, oldest window first. A campaign covering several SKUs appears
        as several rows sharing the same `name`.

        `kind` says which sort of special it is: `price` carries an absolute
        `special_price` and a null `percent_off`, `percent` carries a
        `percent_off` and a null `special_price`. `effective_price` is the
        resulting price for this channel, and is `null` when the SKU is not an
        active catalogue product for you: there is then no list price to
        resolve a percentage against. `active` is `true` for the rows whose
        window includes the current instant, and `false` for rows that have
        not started yet.

        A special whose window has closed is not returned at all, including
        under `updated_since`. Reconcile removals by treating the response as
        the complete set of your live and upcoming specials: anything you hold
        that is not in a full walk has ended.

        Granted specials are already folded into `price` on the catalogue, so
        this endpoint is for display (badges, countdowns, campaign pages) and
        for knowing when a price will change. Pass `active=true` to restrict
        the list to specials live right now, and `sku` to restrict to one
        product.

        Supports cursor pagination and `updated_since`; see the introduction.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/updated_since'
        - name: active
          in: query
          description: When `true`, return only specials whose window includes the current instant.
          required: false
          schema:
            type: boolean
            default: false
        - name: sku
          in: query
          description: Restrict to specials for one SKU.
          required: false
          schema:
            type: string
            examples: [GW-5000-DNS-30]
      responses:
        '200':
          description: A page of specials.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SpecialsList'
              examples:
                twoSpecials:
                  summary: One live absolute price special and one upcoming percentage special
                  value:
                    as_of: '2026-09-02T04:00:00Z'
                    items:
                      - id: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                        name: September panel clearance
                        sku: JKM-440N-54HL4-V
                        kind: price
                        special_price: '119.00'
                        percent_off: null
                        effective_price: '119.00'
                        active: true
                        starts_at: '2026-09-01T00:00:00Z'
                        ends_at: '2026-09-30T13:59:59Z'
                        updated_at: '2026-08-29T02:15:00Z'
                      - id: spc_01j6zk3m5x8qw2r7y9v4b1n0pe
                        name: October inverter promo
                        sku: GW-5000-DNS-30
                        kind: percent
                        special_price: null
                        percent_off: 15
                        effective_price: '1049.33'
                        active: false
                        starts_at: '2026-10-01T00:00:00Z'
                        ends_at: '2026-10-31T12:59:59Z'
                        updated_at: '2026-09-02T04:00:00Z'
                    next_cursor: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/shipping/quotes:
    post:
      tags: [Shipping]
      operationId: createShippingQuote
      summary: Request a freight quote for a cart
      description: |
        Prices delivery of a set of lines to an Australian address. The engine
        behind this endpoint is the one that prices the Solar Outlet checkout,
        so a partner quote and a storefront quote for the same cart and address
        are identical, including dangerous goods handling for batteries.

        ## Quote status

        | `quote_status` | Meaning | Can I order with it? |
        |---|---|---|
        | `priced` | One or more carrier rates are available in `rates`. | Yes. Pass `quote_id` and the chosen `rate_service_code` to `POST /v1/orders` before `expires_at`. |
        | `manual_quote_required` | The cart or destination needs a human freight quote (typically oversize items, pallet quantities or remote postcodes). `rates` is empty. | Not through the API in stage 1. Contact your account manager with the `quote_id`. |
        | `unavailable` | No carrier can service this destination for these lines and no manual path applies. `rates` is empty. | No. |

        A `$0` rate is never returned. If the freight engine itself is
        unreachable the response is `503 QUOTE_UNAVAILABLE` with `Retry-After`;
        retry after that delay rather than treating it as `unavailable`.

        ## Origin

        Partners have no fulfilment location concept, and the API does not
        choose an origin from stock. Every quote is priced from one configured
        default metro, Sydney, unless you pass `origin_metro`, in which case
        it is priced from the metro you name. Nothing checks that the origin
        can actually fulfil the lines.

        This has a money consequence for interstate carts. A Perth cart quoted
        from the Sydney default is priced as a cross country consignment even
        when the goods would ship from Perth. If you know where the stock is,
        read `/v1/inventory` and pass `origin_metro`; if you do not, quote the
        default and expect the freight figure to be conservative.

        The metro actually used is returned in `origin_metro`.

        ## Validity

        A quote expires at `expires_at`, 30 minutes after it was created. It is
        bound to the exact lines, quantities, destination and address type it
        was requested with, and to the key's channel and sandbox side; an order
        that references it with anything different is refused with
        `422 VALIDATION_FAILED`.

        Requesting a quote for a cart the API has just quoted returns the same
        `quote_id` again rather than a fresh one, for as long as it has at
        least five minutes of life left. Duplicate quotes for an unchanged cart
        are therefore free, but the clock does not restart: always read
        `expires_at` off the response you have rather than assuming 30 minutes
        from now.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShippingQuoteRequest'
            examples:
              residential:
                summary: Two products to a residential address
                value:
                  destination:
                    suburb: Parramatta
                    postcode: '2150'
                    state: NSW
                    country: AU
                    address_type: residential
                  lines:
                    - sku: GW-5000-DNS-30
                      quantity: 1
                    - sku: JKM-440N-54HL4-V
                      quantity: 12
              forcedOrigin:
                summary: Force the Brisbane warehouse as origin
                value:
                  destination:
                    suburb: Toowoomba City
                    postcode: '4350'
                    state: QLD
                    country: AU
                    address_type: business
                  lines:
                    - sku: GW-5000-DNS-30
                      quantity: 2
                  origin_metro: Brisbane
      responses:
        '200':
          description: The quote. Check `quote_status` before reading `rates`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShippingQuote'
              examples:
                priced:
                  summary: Two rates available
                  value:
                    quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pd
                    quote_status: priced
                    expires_at: '2026-09-02T04:45:00Z'
                    origin_metro: Sydney
                    rates:
                      - service_code: ALLIED-GENERAL
                        service_name: Road Freight
                        carrier: Allied Express
                        total_price: '123.45'
                        currency: AUD
                        eta_days_min: 2
                        eta_days_max: 4
                        dangerous_goods: false
                      - service_code: ALLIED-PRIORITY
                        service_name: Priority Road Freight
                        carrier: Allied Express
                        total_price: '189.90'
                        currency: AUD
                        eta_days_min: 1
                        eta_days_max: 2
                        dangerous_goods: false
                manualQuote:
                  summary: Pallet quantity needs a manual quote
                  value:
                    quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pe
                    quote_status: manual_quote_required
                    expires_at: '2026-09-02T04:45:00Z'
                    origin_metro: Sydney
                    rates: []
                unavailable:
                  summary: Destination cannot be serviced
                  value:
                    quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pf
                    quote_status: unavailable
                    expires_at: '2026-09-02T04:45:00Z'
                    origin_metro: Sydney
                    rates: []
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/QuoteUnavailable'

  /v1/orders:
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Place an order
      description: |
        Places an order against this channel's trade account. The end customer's
        name, phone and address are delivery details only; they are never used
        to create or match a customer record.

        ## What is validated

        Everything below happens before the response is written; nothing about
        acceptance is deferred.

        1. The body parses, carries no unknown fields, and is under 64 KiB.
           A parse failure or an unknown field is `400`, an oversized body is
           `413`.
        2. Every field is within its length limit, the delivery postcode is
           four digits, the state is an Australian state or territory code,
           and `country` is `AU` (the only value supported). A missing
           `country` defaults to `AU`.
        3. Line SKUs are distinct. Two lines for the same SKU are refused
           rather than merged: send one line with the combined quantity. Every
           `quantity` is between 1 and 9999, and there are at most 200 lines.
        4. Every `sku` exists and is active in this channel's catalogue.
        5. Every `unit_price` equals the channel's current price for that SKU
           **and** `price_list_version` equals the current version. Otherwise
           `409 PRICE_CHANGED` is returned with the current values in
           `details`; refresh the catalogue and resubmit.
        6. `quote_id` is valid, unexpired, was issued by this channel with a
           key on this side of the sandbox boundary, for the same lines,
           destination and address type, and `rate_service_code` is one of its
           rates. Otherwise `422 VALIDATION_FAILED`.
        7. `client_reference` has not been used by this channel with a
           different body, otherwise `409 IDEMPOTENCY_CONFLICT`.

        Totals are computed server side from the current price list and the
        chosen rate. The response is authoritative; do not trust client side
        arithmetic.

        ## What happens next

        Acceptance is synchronous. A `202` response already carries status
        `accepted` and two events, `received` and `accepted`: everything the
        API can check has been checked and the order is queued for operations.
        The status is `202` rather than `200` because fulfilment has not
        started, not because validation is still running.

        Resubmitting the same `client_reference` with the same body returns
        `200` with the order that already exists, at whatever status it has
        reached by then. Treat `200` and `202` the same way: read the order
        out of the body.

        Account level checks (credit hold, unapproved account) and stock
        allocation run in the back office afterwards and surface as `on_hold`
        or `rejected`. Poll `GET /v1/orders/{id}` (using `If-None-Match`) or
        `GET /v1/orders?updated_since=` to track it. The full state machine is
        documented on the `Order` schema.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderRequest'
            examples:
              standard:
                summary: A two line order using a priced quote
                value:
                  client_reference: PO-88213
                  price_list_version: plv_9f3a2c1d84b6e05
                  quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pd
                  rate_service_code: ALLIED-GENERAL
                  delivery:
                    name: Jane Citizen
                    company: Example Solar Pty Ltd
                    phone: '+61400000000'
                    email: jane@example.com
                    address1: 12 Example Street
                    address2: Unit 4
                    suburb: Parramatta
                    postcode: '2150'
                    state: NSW
                    country: AU
                    instructions: Leave at side gate if no answer
                    address_type: business
                  lines:
                    - sku: GW-5000-DNS-30
                      quantity: 1
                      unit_price: '1110.99'
                    - sku: JKM-440N-54HL4-V
                      quantity: 12
                      unit_price: '119.00'
                  partner_note: Installer job 4471
      responses:
        '200':
          description: |
            This `client_reference` has already been used by this channel with
            the same body, so no second order was created. The body is the
            existing order at its current status.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '202':
          description: |
            The order has been validated, priced and recorded with status
            `accepted`. `202` says fulfilment has not started, not that
            validation is outstanding. A replay of the same
            `client_reference` and body answers `200`, not `202`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Location:
              description: URL of the created order.
              schema:
                type: string
                examples: [/v1/orders/ord_01j6zk3m5x8qw2r7y9v4b1n0pd]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
              examples:
                accepted:
                  summary: The order immediately after submission
                  value:
                    id: ord_01j6zk3m5x8qw2r7y9v4b1n0pd
                    channel_id: ch_01j6zk3m5x8qw2r7y9v4b1n0pd
                    api_key_id: key_01j6zk3m5x8qw2r7y9v4b1n0pd
                    client_reference: PO-88213
                    status: accepted
                    price_list_version: plv_9f3a2c1d84b6e05
                    currency: AUD
                    subtotal: '2538.99'
                    shipping_total: '123.45'
                    gst: '266.24'
                    total: '2928.68'
                    quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pd
                    rate_service_code: ALLIED-GENERAL
                    delivery:
                      name: Jane Citizen
                      company: Example Solar Pty Ltd
                      phone: '+61400000000'
                      email: jane@example.com
                      address1: 12 Example Street
                      address2: Unit 4
                      suburb: Parramatta
                      postcode: '2150'
                      state: NSW
                      country: AU
                      instructions: Leave at side gate if no answer
                      address_type: business
                    lines:
                      - sku: GW-5000-DNS-30
                        title: GoodWe 5kW Single Phase Hybrid Inverter
                        quantity: 1
                        unit_price: '1110.99'
                        line_total: '1110.99'
                        special: null
                      - sku: JKM-440N-54HL4-V
                        title: Jinko Tiger Neo 440W N-Type Mono
                        quantity: 12
                        unit_price: '119.00'
                        line_total: '1428.00'
                        special: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                    applied_specials:
                      - id: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                        sku: JKM-440N-54HL4-V
                        unit_price: '119.00'
                    partner_note: Installer job 4471
                    sandbox: false
                    received_at: '2026-09-02T04:20:15Z'
                    updated_at: '2026-09-02T04:20:15Z'
                    events:
                      - status: received
                        note: order received
                        actor: partner:key_01j6zk3m5x8qw2r7y9v4b1n0pd
                        created_at: '2026-09-02T04:20:15Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/OrderConflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/StaleData'
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List this channel's orders
      description: |
        Returns orders placed with this channel's keys, cursor paginated,
        **oldest first by `updated_at`**. The order is deliberate: it is what
        makes the `updated_since` sync loop work, because a walk that starts
        at the oldest change and pages forward cannot skip an order that
        changes while you are paging.

        Filter by `status` and use `updated_since` to poll for status changes
        efficiently: pass the `as_of` from the last page you read and you
        receive every order whose status or details changed at or after it.
        `as_of` is the newest `updated_at` on the page, so the order you
        already have comes back once more on the next poll; match on `id` and
        skip it.

        Pass `client_reference` to fetch the single order carrying your own
        reference, which is how to find an order again after a request timed
        out before you saw its `id`.

        A key only ever lists its own side of the sandbox boundary: a
        `sj_test_` key sees sandbox orders, a `sj_live_` key sees live ones.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/updated_since'
        - name: status
          in: query
          description: Return only orders currently in this status.
          required: false
          schema:
            $ref: '#/components/schemas/OrderStatus'
        - name: client_reference
          in: query
          description: Return the order with this `client_reference`, if any.
          required: false
          schema:
            type: string
            examples: [PO-88213]
      responses:
        '200':
          description: A page of orders.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'
              examples:
                page:
                  summary: One accepted order, no further pages
                  value:
                    as_of: '2026-09-02T04:20:16Z'
                    items:
                      - id: ord_01j6zk3m5x8qw2r7y9v4b1n0pd
                        channel_id: ch_01j6zk3m5x8qw2r7y9v4b1n0pd
                        api_key_id: key_01j6zk3m5x8qw2r7y9v4b1n0pd
                        client_reference: PO-88213
                        status: accepted
                        price_list_version: plv_9f3a2c1d84b6e05
                        currency: AUD
                        subtotal: '2538.99'
                        shipping_total: '123.45'
                        gst: '266.24'
                        total: '2928.68'
                        quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pd
                        rate_service_code: ALLIED-GENERAL
                        delivery:
                          name: Jane Citizen
                          company: Example Solar Pty Ltd
                          phone: '+61400000000'
                          email: jane@example.com
                          address1: 12 Example Street
                          address2: Unit 4
                          suburb: Parramatta
                          postcode: '2150'
                          state: NSW
                          country: AU
                          instructions: Leave at side gate if no answer
                          address_type: business
                        lines:
                          - sku: GW-5000-DNS-30
                            title: GoodWe 5kW Single Phase Hybrid Inverter
                            quantity: 1
                            unit_price: '1110.99'
                            line_total: '1110.99'
                            special: null
                          - sku: JKM-440N-54HL4-V
                            title: Jinko Tiger Neo 440W N-Type Mono
                            quantity: 12
                            unit_price: '119.00'
                            line_total: '1428.00'
                            special: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                        applied_specials:
                          - id: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                            sku: JKM-440N-54HL4-V
                            unit_price: '119.00'
                        partner_note: Installer job 4471
                        sandbox: false
                        received_at: '2026-09-02T04:20:15Z'
                        updated_at: '2026-09-02T04:20:16Z'
                        events:
                          - status: received
                            note: order received
                            actor: partner:key_01j6zk3m5x8qw2r7y9v4b1n0pd
                            created_at: '2026-09-02T04:20:15Z'
                          - status: accepted
                            note: validation passed
                            actor: system
                            created_at: '2026-09-02T04:20:16Z'
                    next_cursor: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/orders/{id}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Get one order with status, events and totals
      description: |
        Returns the order with its full event history. The response carries an
        `ETag`; send it back in `If-None-Match` when polling and the API answers
        `304 Not Modified` with no body while nothing has changed. A `304` still
        counts against the rate limit, so poll at a sensible interval (30 seconds
        or more) or use `GET /v1/orders?updated_since=` to poll all orders in one
        request.
      parameters:
        - $ref: '#/components/parameters/orderId'
        - $ref: '#/components/parameters/ifNoneMatch'
      responses:
        '200':
          description: The order.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
              examples:
                processing:
                  summary: Order keyed by operations and now processing
                  value:
                    id: ord_01j6zk3m5x8qw2r7y9v4b1n0pd
                    channel_id: ch_01j6zk3m5x8qw2r7y9v4b1n0pd
                    client_reference: PO-88213
                    status: processing
                    price_list_version: plv_9f3a2c1d84b6e05
                    currency: AUD
                    subtotal: '2538.99'
                    shipping_total: '123.45'
                    gst: '266.24'
                    total: '2928.68'
                    quote_id: qt_01j6zk3m5x8qw2r7y9v4b1n0pd
                    rate_service_code: ALLIED-GENERAL
                    delivery:
                      name: Jane Citizen
                      company: Example Solar Pty Ltd
                      phone: '+61400000000'
                      email: jane@example.com
                      address1: 12 Example Street
                      address2: Unit 4
                      suburb: Parramatta
                      postcode: '2150'
                      state: NSW
                      country: AU
                      instructions: Leave at side gate if no answer
                      address_type: business
                    lines:
                      - sku: GW-5000-DNS-30
                        title: GoodWe 5kW Single Phase Hybrid Inverter
                        quantity: 1
                        unit_price: '1110.99'
                        line_total: '1110.99'
                        special: null
                      - sku: JKM-440N-54HL4-V
                        title: Jinko Tiger Neo 440W N-Type Mono
                        quantity: 12
                        unit_price: '119.00'
                        line_total: '1428.00'
                        special: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                    applied_specials:
                      - id: spc_01j6zk3m5x8qw2r7y9v4b1n0pd
                        sku: JKM-440N-54HL4-V
                        unit_price: '119.00'
                    partner_note: Installer job 4471
                    sandbox: false
                    received_at: '2026-09-02T04:20:15Z'
                    updated_at: '2026-09-02T06:02:40Z'
                    events:
                      - status: received
                        note: order received
                        actor: partner:key_01j6zk3m5x8qw2r7y9v4b1n0pd
                        created_at: '2026-09-02T04:20:15Z'
                      - status: accepted
                        note: validation passed
                        actor: system
                        created_at: '2026-09-02T04:20:16Z'
                      - status: processing
                        note: Keyed to NetSuite SO-104233
                        actor: ops:alex@solarjuice.com.au
                        created_at: '2026-09-02T06:02:40Z'
        '304':
          description: Not modified since the `If-None-Match` value. No body.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/orders/{id}/cancel:
    post:
      tags: [Orders]
      operationId: cancelOrder
      summary: Cancel an order before it reaches operations
      description: |
        Cancels one of this channel's own orders. Requires the `orders:write`
        scope.

        A partner may cancel while the order is `received`, `accepted` or
        `on_hold`, which in practice means before operations key it into the
        fulfilment system. Once it is `processing`, `dispatched`, `cancelled`
        or `rejected` the API refuses and the cancellation has to go through
        your account manager. There is no un-cancel: `cancelled` is terminal.

        The body is optional. Send `{"note": "..."}` to record why; with no
        body the event note is recorded as `cancelled by partner`.

        The response is the order at its new status, with the cancellation
        appended to `events`. It is safe to call twice only in the sense that
        the second call is refused with `422`, not that it does anything: check
        `status` on the order you already hold before calling.
      parameters:
        - $ref: '#/components/parameters/orderId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCancelRequest'
            examples:
              withNote:
                summary: Cancel with a reason
                value:
                  note: Customer changed the panel selection
      responses:
        '200':
          description: The order, now `cancelled`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            The order moved while this request was in flight, so the
            cancellation was not applied. `details` carries the status the
            order actually holds. Re-read the order and decide again.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                raced:
                  value:
                    error:
                      code: VALIDATION_FAILED
                      message: order status changed while this request was in flight; re-read the order and retry
                      details:
                        - status: processing
                          expected: accepted
                          requested: cancelled
                      request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
        '422':
          description: |
            The order is past the point a partner can cancel it, or the note
            is not valid JSON object content.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                tooLate:
                  value:
                    error:
                      code: VALIDATION_FAILED
                      message: order is already processing and cannot be cancelled by the partner
                      details: []
                      request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/health:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Liveness check
      description: |
        Unauthenticated. Returns `200 {"status": "ok"}` when the API process is
        up and serving. It does not report the freshness of catalogue or
        inventory data; use the `stale` flag and `as_of` on inventory responses
        for that.

        This operation sits outside the authenticated surface, so unlike every
        other operation it carries no `RateLimit-*` headers and never answers
        `429`. It is a liveness probe, not a service: do not poll it in place
        of the endpoint you actually want.
      security: []
      responses:
        '200':
          description: The service is up.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
              examples:
                ok:
                  value:
                    status: ok
        '500':
          $ref: '#/components/responses/Internal'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sj_live_<keyid>_<secret> or sj_test_<keyid>_<secret>
      description: |
        Partner API key issued by Solar Juice. Format
        `sj_<env>_<keyid>_<32 hex secret>`. Both `sj_live_` and `sj_test_` keys
        are accepted on the same host; a `sj_test_` key puts the request in
        sandbox mode. Send as `Authorization: Bearer <full key>`. Keys carry
        scopes; an operation outside the key's scopes returns `403 FORBIDDEN`.

  parameters:
    limit:
      name: limit
      in: query
      description: |
        Page size. Default 100, maximum 500. A larger value is clamped to 500
        rather than refused; a value that is not a positive integer is `400`.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 100
    cursor:
      name: cursor
      in: query
      description: Opaque cursor from the previous page's `next_cursor`. Omit for the first page.
      required: false
      schema:
        $ref: '#/components/schemas/Cursor'
    updated_since:
      name: updated_since
      in: query
      description: |
        Return only items changed at or after this UTC instant. Safe to pass the
        `as_of` value from your previous response; see the introduction.
      required: false
      schema:
        $ref: '#/components/schemas/Timestamp'
    sku:
      name: sku
      in: path
      description: Solar Juice part number. Case sensitive.
      required: true
      schema:
        $ref: '#/components/schemas/Sku'
    orderId:
      name: id
      in: path
      description: Order id as returned by `POST /v1/orders`.
      required: true
      schema:
        $ref: '#/components/schemas/OrderId'
    ifNoneMatch:
      name: If-None-Match
      in: header
      description: ETag from a previous response. When it still matches, the API returns `304` with no body.
      required: false
      schema:
        type: string
        examples: ['W/"a1b2c3d4e5f60718"']
    idempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Accepted and ignored. It is here so that a generic HTTP client that
        sets the header on every unsafe request is not rejected; the API does
        not read it, store it, compare it or return it. `client_reference` in
        the body is the only idempotency key, and sending this header instead
        of one will not deduplicate anything.
      deprecated: true
      required: false
      schema:
        type: string
        maxLength: 255
        examples: [3f2c9b7e-1c4a-4a2e-9a1f-0b7d5e6c8a90]

  headers:
    X-Request-Id:
      description: Unique id for this request. Quote it in support requests. Repeated as `request_id` in error bodies.
      schema:
        $ref: '#/components/schemas/RequestId'
    RateLimit-Limit:
      description: Requests allowed per one minute sliding window for this key.
      schema:
        type: integer
        minimum: 0
        examples: [600]
    RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
        minimum: 0
        examples: [597]
    RateLimit-Reset:
      description: Seconds until the current window resets.
      schema:
        type: integer
        minimum: 0
        examples: [42]
    Retry-After:
      description: Seconds to wait before retrying.
      schema:
        type: integer
        minimum: 1
        examples: [30]
    X-Price-List-Version:
      description: |
        Opaque version of the price list the response was priced from. Submit
        it as `price_list_version` on orders, and watch it for price changes:
        it is the only signal that catches a price moving because a special
        started or a discount changed, neither of which touches `updated_at`.
      schema:
        $ref: '#/components/schemas/PriceListVersion'
    X-Inventory-As-Of:
      description: Freshest source timestamp behind the inventory figures in the response.
      schema:
        $ref: '#/components/schemas/Timestamp'
    ETag:
      description: |
        Weak entity tag for the order representation, derived from its id and
        `updated_at`. Send it back verbatim in `If-None-Match`, `W/` prefix
        included.
      schema:
        type: string
        examples: ['W/"a1b2c3d4e5f60718"']

  responses:
    Unauthorized:
      description: Missing, malformed, revoked or wrong environment API key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            unauthorized:
              value:
                error:
                  code: UNAUTHORIZED
                  message: api key is missing, invalid or revoked
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    Forbidden:
      description: The key does not have the scope for this operation, or the channel is suspended.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            forbidden:
              value:
                error:
                  code: FORBIDDEN
                  message: this key does not have the orders:write scope
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    NotFound:
      description: No such resource for this channel.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            notFound:
              value:
                error:
                  code: NOT_FOUND
                  message: 'no such sku: GW-5000-DNS-99'
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    ValidationFailed:
      description: The request did not validate. `details` lists every problem found.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            badQuantity:
              summary: Quantity out of range, and a duplicated SKU
              value:
                error:
                  code: VALIDATION_FAILED
                  message: order request is invalid
                  details:
                    - field: lines[1].quantity
                      reason: must be 1..9999
                    - field: lines[2].sku
                      reason: duplicate sku; merge quantities into one line
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
            badAddress:
              summary: The postcode and state do not check out
              value:
                error:
                  code: VALIDATION_FAILED
                  message: order request is invalid
                  details:
                    - field: delivery.postcode
                      reason: must be 4 digits
                    - field: delivery.state
                      reason: must be an Australian state or territory code
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
            quoteMismatch:
              summary: The quote was computed for a different cart
              value:
                error:
                  code: VALIDATION_FAILED
                  message: quote was computed for different lines or destination; request a new quote
                  details:
                    - field: quote_id
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
            unorderableSku:
              summary: A line names a SKU this channel cannot buy
              value:
                error:
                  code: VALIDATION_FAILED
                  message: one or more skus are not orderable
                  details:
                    - sku: GW-5000-DNS-99
                      reason: unknown or inactive sku
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    BadRequest:
      description: |
        The request could not be read, or a query parameter could not be
        parsed. The code is `VALIDATION_FAILED` at status `400` rather than
        `422`, because nothing about the request was understood well enough to
        validate. This is what a malformed JSON body, a body that is not a
        single JSON value, a field of the wrong JSON type, an unknown field, a
        `limit` that is not a positive integer, a `cursor` the API did not
        issue and an unparsable `updated_since` all return.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            unknownField:
              summary: A misspelled field is refused rather than ignored
              value:
                error:
                  code: VALIDATION_FAILED
                  message: unknown field "deliverry"
                  details:
                    - field: deliverry
                      reason: unknown field
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
            malformedJson:
              summary: The body is not JSON
              value:
                error:
                  code: VALIDATION_FAILED
                  message: malformed JSON at offset 42
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
            badCursor:
              summary: A cursor the API did not issue
              value:
                error:
                  code: VALIDATION_FAILED
                  message: cursor is not valid
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    PayloadTooLarge:
      description: |
        The request body is over the limit for this operation: 64 KiB on
        `POST /v1/orders`, 1 MiB elsewhere. An order of 200 lines with full
        delivery details is a few KiB, so this normally means a runaway
        client rather than a large order.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            tooLarge:
              value:
                error:
                  code: VALIDATION_FAILED
                  message: request body exceeds 64 KiB
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    RateLimited:
      description: The key's allowance for the current window is exhausted.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            rateLimited:
              value:
                error:
                  code: RATE_LIMITED
                  message: rate limit exceeded
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    OrderConflict:
      description: |
        Either the prices submitted no longer match the channel's current price
        list (`PRICE_CHANGED`, refresh the catalogue and resubmit with the
        current `unit_price` values and `price_list_version` from `details`) or
        this `client_reference` was already used with a different body
        (`IDEMPOTENCY_CONFLICT`, choose a new reference or fetch the existing
        order).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            priceChanged:
              summary: The version is out of date and one line has moved
              value:
                error:
                  code: PRICE_CHANGED
                  message: prices or price list version have changed; refresh the catalogue and resubmit
                  details:
                    - price_list_version: plv_4c81ba09e7d2f6
                      current_price_list_version: plv_9f3a2c1d84b6e05
                    - sku: GW-5000-DNS-30
                      unit_price: '1110.99'
                      current_price: '1099.00'
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
            idempotencyConflict:
              summary: client_reference reused with a different body
              value:
                error:
                  code: IDEMPOTENCY_CONFLICT
                  message: client_reference was already used for a different order request
                  details:
                    - order_id: ord_01j6zk3m5x8qw2r7y9v4b1n0pd
                      client_reference: PO-88213
                      status: accepted
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    QuoteUnavailable:
      description: The freight engine is temporarily unreachable. Retry after the `Retry-After` delay.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            quoteUnavailable:
              value:
                error:
                  code: QUOTE_UNAVAILABLE
                  message: shipping quote is unavailable, retry shortly
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    StaleData:
      description: |
        The instance that answered has no catalogue or inventory document
        loaded, so it has nothing to serve this operation from. It is a
        transient condition, normally a few seconds after a deployment or a
        read model publish, not a data age threshold. Retry after the
        `Retry-After` delay.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            staleData:
              value:
                error:
                  code: STALE_DATA
                  message: catalogue read model is not loaded
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd
    Internal:
      description: Unexpected failure. Retry with backoff; report the `request_id` if it persists.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            internal:
              value:
                error:
                  code: INTERNAL
                  message: internal error
                  details: []
                  request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd

  schemas:
    # Primitives

    Money:
      type: string
      description: Decimal amount with exactly two decimal places, AUD, exclusive of GST unless the field name says otherwise. Never a float.
      pattern: '^-?[0-9]+\.[0-9]{2}$'
      examples: ['1234.50']

    Percent:
      type: number
      description: |
        Percentage as a JSON number with at most two decimal places: `15` for
        15 percent, `12.5` for 12.5 percent. Money is a decimal string and
        percentages are numbers; the two do not follow the same rule.
      minimum: 0
      maximum: 100
      multipleOf: 0.01
      examples: [15]

    Timestamp:
      type: string
      format: date-time
      description: UTC ISO 8601 with a `Z` suffix.
      examples: ['2026-09-02T04:10:11Z']

    PriceListVersion:
      type: string
      description: |
        Identifies the channel's current price list. It is an opaque token,
        `plv_` followed by a short hexadecimal digest, derived from the
        resolved price of every SKU in the channel's catalogue. It changes
        whenever any of those prices changes, whatever the cause: a base
        price edit, a change to the channel discount, a per SKU override, or
        a special starting or ending.

        It is not a timestamp and carries no ordering. Compare two values for
        equality only; a difference means re-read the catalogue, it does not
        mean one is newer. The format may change without notice, so store it
        as an opaque string.
      pattern: '^plv_[0-9a-f]{1,16}$'
      examples: [plv_9f3a2c1d84b6e05]

    Sku:
      type: string
      description: Solar Juice part number. Case sensitive.
      minLength: 1
      maxLength: 64
      examples: [GW-5000-DNS-30]

    Cursor:
      type: string
      description: Opaque pagination cursor. Do not construct or decode.
      examples: [eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wMVQyMjozMDowMFoiLCJza3UiOiJKS00tNDQwTi01NEhMNC1WIn0]

    RequestId:
      type: string
      description: Request id, `req_` followed by a 26 character ULID.
      pattern: '^req_[0-9a-hjkmnp-tv-z]{26}$'
      examples: [req_01j6zk3m5x8qw2r7y9v4b1n0pd]

    OrderId:
      type: string
      description: Order id, `ord_` followed by a 26 character ULID.
      pattern: '^ord_[0-9a-hjkmnp-tv-z]{26}$'
      examples: [ord_01j6zk3m5x8qw2r7y9v4b1n0pd]

    ChannelId:
      type: string
      description: Channel id, `ch_` followed by a 26 character ULID. Identifies your sales channel.
      pattern: '^ch_[0-9a-hjkmnp-tv-z]{26}$'
      examples: [ch_01j6zk3m5x8qw2r7y9v4b1n0pd]

    SpecialId:
      type: string
      description: Special id, `spc_` followed by a 26 character ULID.
      pattern: '^spc_[0-9a-hjkmnp-tv-z]{26}$'
      examples: [spc_01j6zk3m5x8qw2r7y9v4b1n0pd]

    QuoteId:
      type: string
      description: Shipping quote id as returned by `POST /v1/shipping/quotes`. Opaque.
      minLength: 1
      maxLength: 64
      examples: [qt_01j6zk3m5x8qw2r7y9v4b1n0pd]

    Metro:
      type: string
      description: Metro name in title case. The set may grow; treat unknown values as new metros.
      examples: [Sydney, Melbourne, Brisbane, Perth, Adelaide, Townsville]

    AustralianState:
      type: string
      description: Australian state or territory abbreviation.
      enum: [NSW, VIC, QLD, WA, SA, TAS, ACT, NT]

    # Errors

    ErrorCode:
      type: string
      description: |
        Machine readable error code. Open enumeration; new codes may be added
        with notice. Clients should fall back on the HTTP status for unknown
        codes.

        * `UNAUTHORIZED` (401): missing, malformed, revoked or wrong environment key.
        * `FORBIDDEN` (403): key lacks the scope, or the channel is suspended.
        * `NOT_FOUND` (404): no such SKU, order or route for this channel.
        * `VALIDATION_FAILED` (422): request does not validate; see `details`.
        * `RATE_LIMITED` (429): allowance exhausted; honour `Retry-After`.
        * `PRICE_CHANGED` (409): order prices or version out of date; current values in `details`.
        * `IDEMPOTENCY_CONFLICT` (409): `client_reference` reused with a different body.
        * `QUOTE_UNAVAILABLE` (503): freight engine unreachable; honour `Retry-After`.
        * `STALE_DATA` (503): backing data too old to serve this operation safely.
        * `INTERNAL` (500): unexpected failure.
      enum:
        - UNAUTHORIZED
        - FORBIDDEN
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
        - PRICE_CHANGED
        - IDEMPOTENCY_CONFLICT
        - QUOTE_UNAVAILABLE
        - STALE_DATA
        - INTERNAL

    ErrorDetail:
      type: object
      description: |
        One problem with the request. The keys depend on the error code, so
        treat a detail as a diagnostic bag rather than a fixed shape, and
        never branch on it where `code` would do.

        * Field problems (`VALIDATION_FAILED`) carry `field`, a JSON path
          into the request body or a query parameter name, and `reason`, a
          short explanation. There is no `message` key on a detail.
        * `PRICE_CHANGED` returns one object carrying `price_list_version`
          and `current_price_list_version`, followed by one object per line
          that moved, carrying `sku`, `unit_price` (what you sent) and
          `current_price`.
        * `IDEMPOTENCY_CONFLICT` returns one object carrying `order_id`,
          `client_reference` and `status` for the order that already exists.
        * An unorderable SKU returns `sku` and `reason`.

        New keys may be added at any time.
      properties:
        field:
          type: string
          description: JSON path into the request body, or a query or header name.
          examples: ['lines[0].unit_price']
        reason:
          type: string
          description: Short explanation of this detail.
          examples: [must be 1..9999]
        sku:
          $ref: '#/components/schemas/Sku'
      additionalProperties: true

    Error:
      type: object
      required: [code, message, details, request_id]
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: Human readable explanation. Do not parse; use `code`.
          examples: [One or more prices no longer match the current price list.]
        details:
          type: array
          description: Zero or more specific problems. Empty when there is nothing to add.
          items:
            $ref: '#/components/schemas/ErrorDetail'
        request_id:
          $ref: '#/components/schemas/RequestId'

    ErrorEnvelope:
      type: object
      description: The body of every error response, whatever the status code.
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/Error'
      examples:
        - error:
            code: NOT_FOUND
            message: 'no such sku: GW-5000-DNS-99'
            details: []
            request_id: req_01j6zk3m5x8qw2r7y9v4b1n0pd

    # Catalogue

    CatalogueProduct:
      type: object
      description: A product priced for this channel.
      required: [sku, title, brand, category, list_price, price, special, weight_kg, images, updated_at]
      properties:
        sku:
          $ref: '#/components/schemas/Sku'
        title:
          type: string
          description: Product title as shown on Solar Outlet.
          examples: [GoodWe 5kW Single Phase Hybrid Inverter]
        brand:
          type: string
          examples: [GoodWe]
        category:
          type: string
          description: Product category. `Rail`, `Panel` and `Battery` are used by the freight engine; other values are the product's category string.
          examples: [Inverter]
        list_price:
          $ref: '#/components/schemas/Money'
          description: |
            Public list price, ex GST: the compare-at figure. It is normally
            at or above `price`, but nothing enforces that, so a channel
            override above list will show `price` higher than `list_price`.
            Do not derive a discount badge without checking.
        price:
          $ref: '#/components/schemas/Money'
          description: This channel's price, ex GST, after channel discount, per SKU override and any granted special. Submit as `unit_price` on orders.
        special:
          oneOf:
            - $ref: '#/components/schemas/SpecialId'
            - type: 'null'
          description: Id of the granted special that produced `price`, or `null` when no special applies.
        weight_kg:
          type: [string, 'null']
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: |
            Unit weight in kilograms as a decimal string, or `null` when the
            product has no weight recorded. A product with no weight still
            quotes freight, from its shipping specification.
          examples: ['18.50']
        images:
          type: array
          description: Public image URLs, primary first. May be empty.
          items:
            type: string
            format: uri
            examples: [https://cdn.shopify.com/s/files/1/0000/0000/products/gw-5000-dns-30.jpg]
        updated_at:
          $ref: '#/components/schemas/Timestamp'
          description: When this product, or its price for this channel, last changed.

    CatalogueList:
      type: object
      required: [as_of, price_list_version, stale, items, next_cursor]
      properties:
        as_of:
          $ref: '#/components/schemas/Timestamp'
          description: |
            When the catalogue document behind this page was published. Safe
            to pass as `updated_since` on the next sync, but see the note in
            the introduction: it will not pick up a price that moved because
            a special started or a channel discount changed.
        price_list_version:
          $ref: '#/components/schemas/PriceListVersion'
        stale:
          type: boolean
          description: |
            True when the catalogue document is older than the configured
            threshold. The prices are still served and orders are still
            accepted; it is a freshness signal, not a stop.
        items:
          type: array
          items:
            $ref: '#/components/schemas/CatalogueProduct'
        next_cursor:
          oneOf:
            - $ref: '#/components/schemas/Cursor'
            - type: 'null'
          description: Cursor for the next page, or `null` on the last page.

    # Inventory

    AvailableByMetro:
      type: object
      description: Sellable quantity keyed by metro name. Metros with zero stock are omitted.
      additionalProperties:
        type: integer
        minimum: 0
      examples:
        - Sydney: 12
          Melbourne: 4

    InventoryItem:
      type: object
      required: [sku, available, total, updated_at]
      properties:
        sku:
          $ref: '#/components/schemas/Sku'
        available:
          $ref: '#/components/schemas/AvailableByMetro'
        total:
          type: integer
          minimum: 0
          description: Sum of `available` across all metros.
          examples: [16]
        updated_at:
          $ref: '#/components/schemas/Timestamp'
          description: When this SKU's figures last changed.

    InventoryList:
      type: object
      required: [as_of, as_of_oldest, stale, locations, items, next_cursor]
      properties:
        as_of:
          $ref: '#/components/schemas/Timestamp'
          description: Freshest source timestamp in the feed.
        as_of_oldest:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
          description: Stalest source timestamp in the feed, or `null` when the feed does not report one.
        stale:
          type: boolean
          description: True when the feed is older than the configured threshold (default two hours).
        locations:
          type: array
          description: Every metro the service tracks, whether or not any item on this page has stock there.
          items:
            $ref: '#/components/schemas/Metro'
        items:
          type: array
          items:
            $ref: '#/components/schemas/InventoryItem'
        next_cursor:
          oneOf:
            - $ref: '#/components/schemas/Cursor'
            - type: 'null'
          description: Cursor for the next page, or `null` on the last page.

    InventoryItemDetail:
      type: object
      description: One SKU's inventory together with the feed timestamps.
      required: [as_of, as_of_oldest, stale, locations, sku, available, total, updated_at]
      properties:
        as_of:
          $ref: '#/components/schemas/Timestamp'
        as_of_oldest:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        stale:
          type: boolean
        locations:
          type: array
          items:
            $ref: '#/components/schemas/Metro'
        sku:
          $ref: '#/components/schemas/Sku'
        available:
          $ref: '#/components/schemas/AvailableByMetro'
        total:
          type: integer
          minimum: 0
          examples: [16]
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    # Specials

    Special:
      type: object
      description: |
        A time boxed price for one SKU granted to this channel. `kind` says
        which of `special_price` and `percent_off` is populated; the other is
        always `null`.
      required: [id, name, sku, kind, special_price, percent_off, effective_price, active, starts_at, ends_at, updated_at]
      properties:
        id:
          $ref: '#/components/schemas/SpecialId'
        name:
          type: string
          description: Campaign name. Rows for several SKUs in one campaign share it.
          examples: [September panel clearance]
        sku:
          $ref: '#/components/schemas/Sku'
        kind:
          type: string
          description: |
            `price` for an absolute special price, `percent` for a percentage
            off the list price.
          enum: [price, percent]
        special_price:
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
          description: Absolute special price ex GST when `kind` is `price`, otherwise `null`.
        percent_off:
          oneOf:
            - $ref: '#/components/schemas/Percent'
            - type: 'null'
          description: Percentage off list price when `kind` is `percent`, otherwise `null`.
        effective_price:
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
          description: |
            The price this special produces for this channel, ex GST. `null`
            when the SKU is not an active product in your catalogue, because
            there is then no list price to apply the special to. A special can
            be granted for a SKU you cannot buy, so check for `null` before
            displaying a price.
        active:
          type: boolean
          description: |
            True when the current instant falls inside `[starts_at, ends_at]`.
            False for a special that has not started yet; specials whose
            window has closed are not returned at all.
        starts_at:
          $ref: '#/components/schemas/Timestamp'
        ends_at:
          $ref: '#/components/schemas/Timestamp'
          description: Inclusive end of the window.
        updated_at:
          $ref: '#/components/schemas/Timestamp'
          description: |
            When the special record itself last changed. It does not move when
            the window opens or closes, so it cannot be used to detect a
            special going live.

    SpecialsList:
      type: object
      required: [as_of, items, next_cursor]
      properties:
        as_of:
          $ref: '#/components/schemas/Timestamp'
          description: The instant the specials were evaluated for `active` and for window filtering.
        items:
          type: array
          items:
            $ref: '#/components/schemas/Special'
        next_cursor:
          oneOf:
            - $ref: '#/components/schemas/Cursor'
            - type: 'null'
          description: Cursor for the next page, or `null` on the last page.

    # Shipping

    Destination:
      type: object
      description: Where the goods are going. Australian addresses only.
      required: [suburb, postcode, state]
      properties:
        suburb:
          type: string
          minLength: 1
          maxLength: 100
          examples: [Parramatta]
        postcode:
          type: string
          pattern: '^[0-9]{4}$'
          examples: ['2150']
        state:
          $ref: '#/components/schemas/AustralianState'
        country:
          type: string
          enum: [AU]
          default: AU
          description: Only `AU` is supported.
        address_type:
          type: string
          enum: [residential, business]
          default: residential
          description: Residential deliveries may attract a surcharge or different carrier; say which it is.

    QuoteLine:
      type: object
      required: [sku, quantity]
      properties:
        sku:
          $ref: '#/components/schemas/Sku'
        quantity:
          type: integer
          minimum: 1
          maximum: 9999
          examples: [12]

    ShippingQuoteRequest:
      type: object
      required: [destination, lines]
      properties:
        destination:
          $ref: '#/components/schemas/Destination'
        lines:
          type: array
          minItems: 1
          maxItems: 200
          description: Lines to ship. Duplicate SKUs are merged before quoting.
          items:
            $ref: '#/components/schemas/QuoteLine'
        origin_metro:
          $ref: '#/components/schemas/Metro'
          description: Optional. Force dispatch from this metro instead of the automatically chosen one.

    ShippingRate:
      type: object
      required: [service_code, service_name, carrier, total_price, currency, eta_days_min, eta_days_max, dangerous_goods]
      properties:
        service_code:
          type: string
          description: Stable code identifying the service. Submit as `rate_service_code` on the order.
          examples: [ALLIED-GENERAL]
        service_name:
          type: string
          description: Display name for the service.
          examples: [Road Freight]
        carrier:
          type: string
          examples: [Allied Express]
        total_price:
          $ref: '#/components/schemas/Money'
          description: Freight charge ex GST for the whole consignment, including any dangerous goods surcharge. Never `0.00`.
        currency:
          type: string
          enum: [AUD]
        eta_days_min:
          type: integer
          minimum: 0
          description: Earliest expected transit time in business days.
          examples: [2]
        eta_days_max:
          type: integer
          minimum: 0
          description: Latest expected transit time in business days.
          examples: [4]
        dangerous_goods:
          type: boolean
          description: True when the consignment is handled as dangerous goods (batteries) and the rate includes the DG surcharge.

    ShippingQuote:
      type: object
      description: |
        A freight quote. `quote_status` governs how to read it: `priced` means
        `rates` has at least one entry and the quote can be used on an order
        until `expires_at`; `manual_quote_required` means a person must quote
        (contact your account manager, quoting `quote_id`); `unavailable` means
        the destination cannot be serviced for these lines. `rates` is empty for
        the latter two.
      required: [quote_id, quote_status, expires_at, origin_metro, rates]
      properties:
        quote_id:
          $ref: '#/components/schemas/QuoteId'
        quote_status:
          type: string
          enum: [priced, manual_quote_required, unavailable]
        expires_at:
          $ref: '#/components/schemas/Timestamp'
          description: |
            When the quote can no longer be used on an order. Always set,
            including on `manual_quote_required` and `unavailable` quotes:
            the id stays resolvable until then so support can look it up.
        origin_metro:
          $ref: '#/components/schemas/Metro'
          description: The metro the consignment was priced from.
        rates:
          type: array
          description: Available services, cheapest first. Empty unless `quote_status` is `priced`.
          items:
            $ref: '#/components/schemas/ShippingRate'

    # Orders

    OrderStatus:
      type: string
      description: |
        Partner facing order status. Open enumeration; see the `Order` schema
        for the state machine.
      enum: [received, accepted, on_hold, processing, dispatched, cancelled, rejected]

    DeliveryAddress:
      type: object
      description: |
        End customer delivery details. Used for the consignment only; never
        used to create or match a customer record.

        Every string field here is optional in the JSON sense: omit it and it
        is an empty string, never `null`. The API returns `""` for a field
        that was not supplied, so a client that checks for `null` will not
        find one. `name`, `address1`, `suburb`, `postcode` and `state` must be
        non empty; the rest may be left out.
      required: [name, address1, suburb, postcode, state]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Recipient name.
          examples: [Jane Citizen]
        company:
          type: string
          maxLength: 200
          examples: [Example Solar Pty Ltd]
        phone:
          type: string
          maxLength: 200
          description: |
            Contact number for the carrier. Not validated and not required,
            but a consignment without one is harder to deliver, so send it.
          examples: ['+61400000000']
        email:
          type: string
          maxLength: 200
          description: Optional. Receives carrier tracking notifications if provided. Not validated as an address.
          examples: [jane@example.com]
        address1:
          type: string
          minLength: 1
          maxLength: 200
          examples: [12 Example Street]
        address2:
          type: string
          maxLength: 200
          examples: [Unit 4]
        suburb:
          type: string
          minLength: 1
          maxLength: 200
          examples: [Parramatta]
        postcode:
          type: string
          pattern: '^[0-9]{4}$'
          description: Four digits. Checked the same way the shipping quote checks it.
          examples: ['2150']
        state:
          $ref: '#/components/schemas/AustralianState'
          description: Accepted in any case and returned upper case.
        country:
          type: string
          enum: [AU]
          default: AU
          description: Only `AU` is supported. Omitted, it defaults to `AU`.
        instructions:
          type: string
          maxLength: 1000
          description: Delivery instructions passed to the carrier.
          examples: [Leave at side gate if no answer]
        address_type:
          type: string
          enum: [residential, business]
          description: |
            Load bearing, because residential and business deliveries price
            differently. Omit it and the order adopts the address type the
            quote was priced for, which is what the stored order then shows.
            Send it and it must equal the quote's, or the order is refused
            with `422 VALIDATION_FAILED`: quoting a business address and then
            ordering to a residential one is a repricing, not a detail.
          examples: [business]

    OrderLineRequest:
      type: object
      required: [sku, quantity, unit_price]
      properties:
        sku:
          $ref: '#/components/schemas/Sku'
        quantity:
          type: integer
          minimum: 1
          maximum: 9999
          examples: [12]
        unit_price:
          $ref: '#/components/schemas/Money'
          description: The `price` you were shown on the catalogue for this SKU, ex GST. Must equal the current channel price.

    OrderRequest:
      type: object
      required: [client_reference, price_list_version, quote_id, rate_service_code, delivery, lines]
      properties:
        client_reference:
          type: string
          minLength: 1
          maxLength: 128
          description: |
            Your unique reference for this order (purchase order number). The
            idempotency key, unique per channel per sandbox side.
          examples: [PO-88213]
        price_list_version:
          $ref: '#/components/schemas/PriceListVersion'
          description: The `X-Price-List-Version` you priced the cart from.
        quote_id:
          $ref: '#/components/schemas/QuoteId'
          description: A `priced`, unexpired quote for exactly these lines and this delivery address.
        rate_service_code:
          type: string
          maxLength: 200
          description: The `service_code` of the chosen rate on that quote.
          examples: [ALLIED-GENERAL]
        delivery:
          $ref: '#/components/schemas/DeliveryAddress'
        lines:
          type: array
          minItems: 1
          maxItems: 200
          description: |
            One entry per SKU. SKUs must be distinct: two lines for the same
            SKU are refused with `422 VALIDATION_FAILED` rather than merged,
            so combine the quantities yourself. This differs from
            `POST /v1/shipping/quotes`, which does merge duplicates.
          items:
            $ref: '#/components/schemas/OrderLineRequest'
        partner_note:
          type: string
          maxLength: 1000
          description: Free text for Solar Juice operations. Not shown to the end customer. Empty string when not supplied, never `null`.
          examples: [Installer job 4471]

    OrderLine:
      type: object
      required: [sku, title, quantity, unit_price, line_total, special]
      properties:
        sku:
          $ref: '#/components/schemas/Sku'
        title:
          type: string
          description: Product title at the time the order was received.
          examples: [GoodWe 5kW Single Phase Hybrid Inverter]
        quantity:
          type: integer
          minimum: 1
          maximum: 9999
        unit_price:
          $ref: '#/components/schemas/Money'
          description: Ex GST unit price charged.
        line_total:
          $ref: '#/components/schemas/Money'
          description: The `unit_price` multiplied by `quantity`, ex GST.
        special:
          oneOf:
            - $ref: '#/components/schemas/SpecialId'
            - type: 'null'
          description: |
            Id of the special that set `unit_price`, or `null`. The field is
            named `special`, matching the catalogue, not `special_id`.

    AppliedSpecial:
      type: object
      description: |
        A special that set the price of a line on this order. Recorded for
        redemption accounting. One entry per line that a special applied to;
        the quantity and the list price are on the matching entry in `lines`.
      required: [id, sku, unit_price]
      properties:
        id:
          $ref: '#/components/schemas/SpecialId'
        sku:
          $ref: '#/components/schemas/Sku'
        unit_price:
          $ref: '#/components/schemas/Money'
          description: The ex GST unit price the special produced.

    OrderEvent:
      type: object
      description: One status transition on the order.
      required: [status, note, actor, created_at]
      properties:
        status:
          $ref: '#/components/schemas/OrderStatus'
        note:
          type: string
          description: |
            Free text from the actor, for example a back office reference or a
            hold reason. Empty string when there is none, never `null`.
          examples: [Keyed to NetSuite SO-104233]
        actor:
          type: string
          description: |
            Who made the transition, as a prefixed identifier rather than a
            fixed set of words: `system` for the API itself, `partner:<key
            id>` for something you did with your own key, and `ops:<operator>`
            for a Solar Juice operator. Match on the prefix before the colon,
            and expect new prefixes.
          examples: [partner:key_01j6zk3m5x8qw2r7y9v4b1n0pd, system, 'ops:alex@solarjuice.com.au']
        created_at:
          $ref: '#/components/schemas/Timestamp'

    Order:
      type: object
      description: |
        An order placed by this channel. Totals are computed server side and are
        authoritative: `subtotal` and `shipping_total` are ex GST, `gst` is the
        GST amount, `total` is inclusive of GST.

        ## Status state machine

        ```
        received --> accepted --> processing --> dispatched
            |            |
            |            +--> on_hold --> processing
            |                    |
            +--> rejected        +--> rejected
        any of received, accepted, on_hold --> cancelled
        ```

        | Status | Meaning |
        |---|---|
        | `received` | The first event on every order, recorded the instant the request arrived. An order is never returned in this status: acceptance happens in the same request, so it is already `accepted` by the time you see it. |
        | `accepted` | Passed every check the API can make and is queued for operations. This is the status a successful `POST /v1/orders` returns. |
        | `on_hold` | Held by the back office, typically an account check (credit hold, unapproved account) or stock allocation. `events[].note` says why. Resolves to `processing` or `rejected`. |
        | `processing` | Operations have keyed the order into the fulfilment system. The order can no longer be cancelled through the API. |
        | `dispatched` | Goods have left the warehouse. Stage 1 sets this manually; carrier tracking is a later addition. |
        | `cancelled` | Cancelled by operations or by the partner before `processing`. Terminal. |
        | `rejected` | Failed a check after receipt (for example account hold not cleared, stock not allocatable). `events[].note` says why. Terminal. |

        Every transition is appended to `events` with a timestamp, the actor and
        an optional note. `updated_at` moves with every transition, so
        `GET /v1/orders?updated_since=` picks up status changes. New statuses may
        be added with notice; treat the enumeration as open.
      required:
        - id
        - channel_id
        - client_reference
        - status
        - price_list_version
        - currency
        - subtotal
        - shipping_total
        - gst
        - total
        - quote_id
        - rate_service_code
        - delivery
        - lines
        - applied_specials
        - partner_note
        - sandbox
        - received_at
        - updated_at
        - events
      properties:
        id:
          $ref: '#/components/schemas/OrderId'
        channel_id:
          $ref: '#/components/schemas/ChannelId'
        api_key_id:
          type: string
          description: |
            The id of the key the order was placed with. Present on orders
            placed through the API and absent on the rare order created
            without a key, so treat it as optional.
          examples: [key_01j6zk3m5x8qw2r7y9v4b1n0pd]
        client_reference:
          type: string
          examples: [PO-88213]
        status:
          $ref: '#/components/schemas/OrderStatus'
        price_list_version:
          $ref: '#/components/schemas/PriceListVersion'
          description: The version the order was priced against.
        currency:
          type: string
          enum: [AUD]
        subtotal:
          $ref: '#/components/schemas/Money'
          description: Sum of `lines[].line_total`, ex GST.
        shipping_total:
          $ref: '#/components/schemas/Money'
          description: Freight charge for the chosen rate, ex GST.
        gst:
          $ref: '#/components/schemas/Money'
          description: GST amount on `subtotal` plus `shipping_total`.
        total:
          $ref: '#/components/schemas/Money'
          description: Sum of `subtotal`, `shipping_total` and `gst`. Inclusive of GST.
        quote_id:
          $ref: '#/components/schemas/QuoteId'
        rate_service_code:
          type: string
          examples: [ALLIED-GENERAL]
        delivery:
          $ref: '#/components/schemas/DeliveryAddress'
        lines:
          type: array
          items:
            $ref: '#/components/schemas/OrderLine'
        applied_specials:
          type: array
          items:
            $ref: '#/components/schemas/AppliedSpecial'
        partner_note:
          type: string
          description: Empty string when none was supplied, never `null`.
        sandbox:
          type: boolean
          description: True for orders placed with `sj_test_` keys. Sandbox orders never reach operations.
        received_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
          description: Time of the most recent change to the order, including status transitions.
        events:
          type: array
          description: Status history, oldest first.
          items:
            $ref: '#/components/schemas/OrderEvent'

    OrderCancelRequest:
      type: object
      description: Optional body for `POST /v1/orders/{id}/cancel`.
      properties:
        note:
          type: string
          maxLength: 1000
          description: Why the order is being cancelled. Recorded on the event; defaults to `cancelled by partner`.
          examples: [Customer changed the panel selection]
      additionalProperties: false

    OrderList:
      type: object
      required: [as_of, items, next_cursor]
      properties:
        as_of:
          $ref: '#/components/schemas/Timestamp'
          description: |
            The newest `updated_at` on this page. Pass it as `updated_since`
            on the next poll; the last order on this page comes back once
            more, which is the price of never missing one.
        items:
          type: array
          description: Oldest first by `updated_at`, then by `id`.
          items:
            $ref: '#/components/schemas/Order'
        next_cursor:
          oneOf:
            - $ref: '#/components/schemas/Cursor'
            - type: 'null'
          description: Cursor for the next page, or `null` on the last page.

    # Health

    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]
