# EQUOS Public API — OpenAPI 3.1 specification.
#
# SOURCE OF TRUTH lives in this repo next to the router it describes
# (core_server/main.go, /e9v3 route block; core_server/payments_server for
# /e9v3/billing). core_server/openapi_sync_test.go
# fails the build when a route is added or removed without updating this file.
# The docs sites (api_docs + equos_console developer-docs) serve this file
# statically; equos_console holds a build-time copy.
openapi: 3.1.0
info:
  title: EQUOS API
  version: "3.0"
  description: |
    Machine-to-machine API for bringing external order sources into the EQUOS
    platform and reading back orders, inventory, contacts and manufacturing
    state.

    ## Authentication

    Every request needs a Bearer access token obtained via the OAuth 2.0
    client-credentials flow from the EQUOS ID token endpoint. API keys
    are provisioned in the subscriber console (Integrations → API Keys); each
    key is an EQUOS ID machine-to-machine (M2M) client whose client secret can
    be rotated or revoked at any time without code changes — never embed static
    secrets.

    Classic keys bind to one business; `/e9v3` ignores `X-Business-ID` and
    resolves the tenant from the key. Multi-business keys (opt-in at mint)
    require `X-Business-ID` on every `/e9v3` request; omit it and the API
    returns 400 `businessId required`. The value must be one of the businesses
    bound to the key (403 `key not bound to business` otherwise).

    Request a token with your key's `client_id` / `client_secret`, the
    resource `https://api.equos.one`, and the scopes your key was granted.
    Tokens expire; refresh by requesting a new one.

    ## Asynchronous writes

    Order and inventory writes are queued, not applied inline: a `2xx`
    response returns a `jobId`, and `GET /e9v3/jobs/{jobId}` reports
    `pending → processing → completed | partial | failed`. Supply
    `sourceRecordId` (your system's record id) on writes — retries with the
    same `sourceRecordId` are deduplicated instead of creating duplicates.

    Instead of polling, either long-poll with `GET /e9v3/jobs/{jobId}?wait=25`
    (the request is held until the job settles or the wait elapses) or
    subscribe to the `job.completed` / `job.failed` webhooks on
    `/e9v3/webhooks`.

    On `POST /e9v3/orders/send` and `POST /e9v3/orders/receive` the dedupe is
    order-level and permanent: resubmitting the same `sourceRecordId` for the
    same business returns HTTP 200 with `duplicate: true`, the ORIGINAL
    `jobId`, its `status`, and (once created) the existing `orderId` /
    `orderNumber`. No second order is created, even after the first job has
    completed. Only a job that failed before the order was created is retried.
    A resubmission's `advance` is not applied to the existing order — use the
    order action endpoints for that.

    ## Advancing orders past draft (finalize / fulfil / commit)

    Orders are created as drafts by default. Keys with the `orders:fulfil`
    scope can also move orders through the workflow, two ways:

    - **On create:** add `advance: {finalize, fulfil, commit}` to
      `/orders/send`, `/orders/receive` or `/orders/send-matched`. Each flag
      is a separate choice; any combination is allowed, but `fulfil` and
      `commit` require `finalize: true`. Any `true` flag requires
      `orders:fulfil` (403 otherwise) and a `sourceRecordId` (400 otherwise).
    - **On an existing order:** `POST /e9v3/orders/{orderType}/{orderId}/finalize`,
      `/fulfil` or `/commit` (send / receive orders). Each enqueues one job.

    Steps always run in the order finalize → fulfil → commit. The job
    `result.steps[]` reports each requested step as
    `{step, status: completed | failed | skipped, errorMessage?}` alongside
    `orderId` / `orderNumber`. Execution stops at the first failing step and
    the order stays at its last good state; when the order was created but an
    advance step failed, the job status is `partial`.

    Rollout: these surfaces are behind a server flag and return **501** until
    the worker-side support ships. Bulk variants are planned for a later phase,
    after the single-order flow is proven.

    ## Webhooks

    Subscriptions are managed in the subscriber console (Integrations →
    Webhooks) or by API key on `/e9v3/webhooks`. Deliveries are signed with
    HMAC-SHA256 in the `X-Equos-Signature` header. See the Webhooks guide for
    event types and payloads.

    Every delivery is a `POST` of the JSON envelope
    `{ event, data, timestamp, deliveryId }` with headers
    `X-Equos-Signature: sha256=<hex>` (HMAC-SHA256 of the exact raw request
    body, keyed with the subscription `secret`), `X-Equos-Event` and
    `X-Equos-Delivery` (= `deliveryId`). Header names are case-insensitive.
    Verify the signature over the raw bytes before parsing, with a
    constant-time compare. Respond 2xx within 10 seconds. Failed attempts are
    retried inline (3 attempts, 1 s / 2 s backoff). After that the delivery is
    dropped for that subscription, and 10 consecutive failed deliveries
    disable the subscription. Delivery is at-least-once: de-duplicate on
    `deliveryId`.

    Order and shipment events for API-key subscriptions (`order.status_changed`,
    `shipment.created`, `shipment.status_changed`, `shipment.delivered`; see the
    top-level `webhooks` section) are emitted when a send order's stage or
    freight consignments change, whoever made the change. Their `deliveryId` is
    deterministic per logical event, so a re-sent event keeps the same id. They
    go to subscriptions created on `/e9v3/webhooks` (`payloadVersion` 2).
    Console-created subscriptions keep the legacy job-based payload for those
    event names. Each shipment carries `shipmentId` and `lines` (order lines
    and quantities, with `linesKnown`), the same as on the order reads.

    Order advance (`advance` on create, or the per-order finalize / fulfil /
    commit routes) reports its outcome as ONE `order.status_changed` per
    request: `operation` is `Advance` (create, `status` `completed` or
    `partial`) or `Finalize` / `Fulfill` / `Commit` (action route, `status`
    `completed`), with the per-step results in `data.steps`
    (`OrderAdvanceStepResult`). Failed jobs send no `order.status_changed`.

    Job settlement (`job.completed`, `job.failed`) is sent once a job an
    API key enqueued settles. An API-key subscription receives it only for
    jobs its own key enqueued (a key with `webhooks:manage` receives all of
    the business's); dashboard subscriptions receive all. It carries the same fields as
    `GET /e9v3/jobs/{jobId}` plus `sourceRecordId` and the order identity.
    Its `deliveryId` is deterministic per settlement.

    ## App billing (payments host)

    `/e9v3/billing/*` is served by `https://equospayments.equosnine.com`, not
    `api.equos.one` (each path carries a `servers` override). It lets an app
    registered with EQUOS (for example Swift Axis) sell its own subscription
    plans to its own users through the EQUOS Stripe account: Checkout, the
    Customer Portal, cancel / resume at period end and status. The token is
    the same client-credentials token (resource `https://api.equos.one`) and
    needs `billing:write`. The app is the one registered for the key's home
    business; `X-Business-ID` is ignored, and a key whose business has no app
    gets 403 `not_app_client`. Users are identified by your own
    `externalUserId`; one Stripe customer is kept per app and user, and a key
    only ever sees its own app's users. Plans, cadences, redirect origins and
    trial days are configured by EQUOS per app.
  contact:
    name: EQUOS Nine
    url: https://console.equos.one
  license:
    name: Proprietary
    url: https://equos.one/terms
servers:
  - url: https://api.equos.one
    description: Production

security:
  - clientCredentials: []

tags:
  - name: Orders
    description: Create and read send/receive orders (async writes).
  - name: Jobs
    description: Poll queued write jobs.
  - name: Inventory
    description: Read stock levels; declare absolute on-hand quantities.
  - name: Contacts
    description: Discover customers and suppliers; export or erase a shopper's personal data (GDPR).
  - name: Manufacturing
    description: Read manufacturing tasks.
  - name: Addresses
    description: Australian address search and validation (G-NAF).
  - name: Secure Documents
    description: Upload documents for secure, shareable delivery.
  - name: Integration
    description: M2M key metadata for integration apps (e.g. Shopify mapping pickers).
  - name: Billing
    description: App-client subscription billing (Checkout, portal, cancel / resume, status) on equospayments.equosnine.com.

paths:
  /e9v3/key-info:
    get:
      tags: [Integration]
      operationId: getKeyInfo
      summary: M2M key metadata (businesses and ship-from locations)
      description: |
        Returns metadata about the authenticated API key: granted scopes, whether
        the key covers multiple businesses, and for each bound business the
        display name, portal default ship-from location, and every warehouse
        location the business may ship from (same rule as `shipFromLocationId`
        on order create). No OAuth scope is required beyond a valid M2M token.
        Ignores `X-Business-ID` and always lists every business the key covers
        (home first, then `extra_business_ids`). Responses are cached for 60s per
        key server-side (`Cache-Control: private, max-age=60`). Large keys may
        return `truncated: true` (cap 20 businesses, 200 locations per business).
        Businesses whose Firestore `businessdata` doc is missing are omitted;
        `skipped` counts how many were dropped (home missing is logged at WARN).
      responses:
        "200":
          description: Key metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KeyInfoResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "502":
          description: Ship-from location metadata could not be loaded.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success: { type: boolean, enum: [false] }

  /e9v3/orders/sync:
    post:
      tags: [Orders]
      operationId: syncOrder
      summary: Ingest an order (generic)
      description: |
        Generic order ingestion: `orderType` selects send or receive. Requires
        an existing `customerId`/`supplierId` (discover via `/e9v3/contacts`).
        Scope: `orders:write`. `advance` is not supported here (400) — use
        `/e9v3/orders/send` or `/e9v3/orders/receive`.

        When `sourceRecordId` is set, resubmitting the same value is
        deduplicated the same way as `/e9v3/orders/send` and `/receive`: HTTP
        200 with `duplicate: true` and the original `jobId` (and `orderId` /
        `orderNumber` when the first job already created the order).
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderSyncRequest" }
            example:
              orderType: send
              customerId: cust_8f3a2b1c9d
              reference1: WEB-10428
              orderDate: "2026-07-10T09:30:00Z"
              items:
                - sku: SKU-COFFEE-250
                  productName: Single Origin Coffee 250g
                  quantity: 12
                - sku: SKU-MUG-STD
                  productName: Ceramic Mug
                  quantity: 6
              notes: Leave at reception
              sourceRecordId: shopify-4571
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/orders/send:
    post:
      tags: [Orders]
      operationId: createSendOrder
      summary: Create a send (outbound) order
      description: |
        Creates an outbound order for an existing customer. Scope: `orders:write`.
        The order is a draft unless `advance` asks for more (needs `orders:fulfil`,
        see "Advancing orders past draft"). Resubmitting the same `sourceRecordId`
        returns the existing job/order with `duplicate: true` (no second order).

        HTTP 200 returns a `jobId` only. When the job completes, `result` includes
        a minted platform `orderNumber` (`WO-####`) and `orderId` (`ord_…`).
        `reference1` is an optional external/PO reference — it is **not** the
        work-order number. Ship-from warehouse comes from `shipFromLocationId`
        or the business portal `defaultLocationId` (required; job fails if neither
        is set). Contact field `locationId` (on sync) is not ship-from.
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendOrderByContactRequest" }
            example:
              customerId: cust_8f3a2b1c9d
              reference1: WEB-10429
              shipFromLocationId: wh_archerfield
              orderDate: "2026-07-11T02:15:00Z"
              items:
                - sku: SKU-COFFEE-250
                  productName: Single Origin Coffee 250g
                  quantity: 4
              sourceRecordId: shopify-4572
              advance: { finalize: true, fulfil: true, commit: false }
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/AdvanceNotEnabled" }

  /e9v3/orders/receive:
    post:
      tags: [Orders]
      operationId: createReceiveOrder
      summary: Create a receive (inbound) order
      description: |
        Creates an inbound order from a supplier. Scope: `orders:write`.
        Optional `advance` (needs `orders:fulfil`) and order-level
        `sourceRecordId` dedupe behave exactly as on `/e9v3/orders/send`.
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReceiveOrderRequest" }
            example:
              supplierEmail: orders@brightbeansupply.com.au
              supplierName: Bright Bean Supply Co
              supplierBusinessName: Bright Bean Supply Pty Ltd
              reference1: PO-2026-0087
              orderDate: "2026-07-09T23:00:00Z"
              items:
                - sku: SKU-COFFEE-250
                  productName: Single Origin Coffee 250g
                  quantity: 500
              sourceRecordId: erp-po-0087
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/AdvanceNotEnabled" }

  /e9v3/orders/send-matched:
    post:
      tags: [Orders]
      operationId: createSendOrderMatched
      summary: Create a send order with customer match-or-create
      description: |
        Like `/e9v3/orders/send`, but no pre-existing `customerId` is
        required: supply `customerEmail` / `customerName` / `customerPhone`
        and the platform resolves the customer, creating a contact when no
        match exists. Designed for storefront/marketplace sources where the
        customer is only known by their checkout details.
        Scope: `orders:write`. Optional `advance` (needs `orders:fulfil`)
        behaves as on `/e9v3/orders/send`. Retries with the same
        `sourceRecordId` are deduplicated by the matching worker.
        When `shipFromLocationId` is set it is validated up front (400 with
        `field: shipFromLocationId` if the location is not a ship-from
        warehouse for the request business); omit it to use the portal default.

        **Contact rule.** The request must identify a customer: send
        `customerId`, or at least one of `customerEmail` / `customerPhone`
        (blank or whitespace-only values do not count). When `customerId` is
        absent, `customerName` is also required. An order with none of the
        three (a guest checkout with no email or phone) is rejected with
        HTTP 400 and nothing is created:
        `{"success":false,"error":"Validation failed","details":[{"field":"contact","message":"one of customerId, customerEmail, or customerPhone is required"}]}`.
        Email or phone without `customerName` is rejected with
        `details[].field: customerName`
        (`required when customerId is not provided (used to create a new contact if no match is found)`).
        All violations are returned together in `details`. The platform does
        not substitute a placeholder contact; callers must supply one or not
        send the order.
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendOrderRequest" }
            example:
              customerEmail: jordan.lee@example.com
              customerPhone: "+61412345678"
              customerName: Jordan Lee
              customerAddressLine1: 12 Latrobe Street
              customerSuburb: Melbourne
              customerState: VIC
              customerPostcode: "3000"
              externalCustomerId: shopify:equos-test.myshopify.com:customer:99321
              reference1: SHOP-55012
              orderDate: "2026-07-11T04:45:00Z"
              items:
                - sku: SKU-MUG-STD
                  productName: Ceramic Mug
                  quantity: 2
              sourceRecordId: shopify-55012
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/AdvanceNotEnabled" }

  /e9v3/orders/submissions/{sourceRecordId}:
    get:
      tags: [Orders]
      operationId: getOrderSubmission
      summary: Get order submission status by sourceRecordId
      description: |
        Scope `orders:write`. Resolves the durable `apiOrderSubmissions`
        marker (and the live processJobs doc when present) for a prior
        `/orders/send-matched` ingest. Counts as a read for rate limiting
        (not against the order-write token bucket). `sourceRecordId` must be
        URL-encoded when it contains reserved characters.
      parameters:
        - name: sourceRecordId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Submission status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderSubmissionStatusResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /e9v3/orders/submissions/lookup:
    post:
      tags: [Orders]
      operationId: lookupOrderSubmissions
      summary: Batch lookup order submission status
      description: |
        Scope `orders:write`. Returns up to 50 `sourceRecordIds` per request.
        Unknown ids are returned with `status: not_found`. Read rate limit only.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderSubmissionLookupRequest" }
      responses:
        "200":
          description: Results keyed by sourceRecordId.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderSubmissionLookupResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /e9v3/orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      description: |
        Scope `orders:read`. Listing send orders (`type` omitted or `send`)
        also accepts `orders:write`, so Shopify app keys can read back their
        orders; every other `type` still needs `orders:read` (403 otherwise).
        `search` is filtered in-memory over the first
        `limit+1` documents; there is no cursor pagination. To poll for
        changed send orders use `GET /e9v3/orders/send/changes`.

        Each order carries `stage`, `shipments` and `updatedAt`; send orders
        also carry `shipFromLocationId` (see `OrderSummary`).
      parameters:
        - name: type
          in: query
          description: |
            Order type to list (optional). Both `returns` and its alias `return`
            are accepted and canonicalized to `returns`.
          schema: { type: string, enum: [send, receive, transfer, returns, return], default: send }
        - name: archived
          in: query
          description: Set true to list archived orders instead of open ones.
          schema: { type: boolean, default: false }
        - name: status
          in: query
          schema: { type: string }
        - name: search
          in: query
          description: Matches order number, contact name and reference1.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: Orders page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrdersListResponse" }
              example:
                success: true
                count: 2
                orders:
                  - orderId: ord_7c1e9a3f5b
                    orderNumber: SO-100428
                    orderType: send
                    status: fulfilled
                    contactName: Jordan Lee
                    contactEmail: jordan.lee@example.com
                    lineCount: 2
                    totalItems: 18
                    reference1: WEB-10428
                    created: "2026-07-10T09:30:12Z"
                    modified: "2026-07-11T02:14:09.512Z"
                    orderDate: "2026-07-10T09:30:00Z"
                    stage: Fulfilled
                    shipFromLocationId: loc_mel_dc1
                    shipments:
                      - shipmentId: csg_8f2a61d0
                        carrierId: aramex
                        carrierName: Aramex
                        trackingNumber: "3DF00012345"
                        trackingUrl: https://freight.equos.one/track/3DF00012345
                        status: in_transit
                        despatchDate: "2026-07-11"
                        deliveredAt: null
                        parcelCount: 2
                        lines: []
                        linesKnown: false
                        linesSource: unknown
                    updatedAt: "2026-07-11T02:14:09.512Z"
                  - orderId: ord_2b8d4e6a1c
                    orderNumber: SO-100427
                    orderType: send
                    status: draft
                    contactName: Riverside Cafe
                    contactEmail: hello@riversidecafe.com.au
                    lineCount: 1
                    totalItems: 4
                    reference1: WEB-10427
                    created: "2026-07-10T08:05:41Z"
                    modified: "2026-07-10T08:05:41Z"
                    orderDate: "2026-07-10T08:05:00Z"
                    stage: Draft
                    shipFromLocationId: loc_mel_dc1
                    shipments: []
                    updatedAt: "2026-07-10T08:05:41Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/orders/send/changes:
    get:
      tags: [Orders]
      operationId: listSendOrderChanges
      summary: Poll for changed send orders
      description: |
        Scope `orders:read` or `orders:write`, same business scoping as the
        order reads. Returns
        send orders (open and archived) whose `updatedAt` moved, oldest first,
        ordered by `updatedAt` then `orderId`. Each item has the same shape as
        the list endpoint, including `stage`, `shipFromLocationId` and
        `shipments`.

        Start with `updatedSince` (RFC 3339, inclusive) or with neither
        parameter (from the beginning). Then pass the returned `nextCursor`
        back as `cursor` on every later call; never send both. `nextCursor`
        is always returned, even on an empty page, so it can be stored and
        polled indefinitely. While `hasMore` is true, call again immediately;
        when it is false, wait before polling again.

        Cursors are opaque. Delivery is at-least-once: an order reappears
        each time it changes, so de-duplicate on `orderId` + `updatedAt`.
        Changes from the last 5 seconds are held back to the next poll so
        writes that commit out of order are not skipped. Orders without a
        `modified` timestamp are not in the feed (they are still readable via
        `GET /e9v3/orders/send/{orderId}`).
      parameters:
        - name: cursor
          in: query
          description: Opaque `nextCursor` from the previous page.
          schema: { type: string }
        - name: updatedSince
          in: query
          description: RFC 3339 start time (inclusive), for the first call only. Sending both `cursor` and `updatedSince` is a 400.
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          description: Page size. Values above 100 are capped at 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Changes page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderChangesResponse" }
              example:
                success: true
                count: 1
                hasMore: false
                nextCursor: eyJ2IjoxLCJ0IjoiMjAyNi0wNy0xMlQwNTo0MTozMFoiLCJpZCI6Im9yZF83YzFlOWEzZjViIn0
                orders:
                  - orderId: ord_7c1e9a3f5b
                    orderNumber: SO-100428
                    orderType: send
                    status: fulfilled
                    contactName: Jordan Lee
                    lineCount: 2
                    totalItems: 18
                    reference1: WEB-10428
                    created: "2026-07-10T09:30:12Z"
                    modified: "2026-07-12T05:41:30Z"
                    stage: Fulfilled
                    shipFromLocationId: loc_mel_dc1
                    shipments:
                      - shipmentId: csg_8f2a61d0
                        carrierId: aramex
                        carrierName: Aramex
                        trackingNumber: "3DF00012345"
                        trackingUrl: https://freight.equos.one/track/3DF00012345
                        status: delivered
                        despatchDate: "2026-07-11"
                        deliveredAt: "2026-07-12T05:41:00Z"
                        parcelCount: 2
                        lines:
                          - lineId: "13790429610214"
                            orderLineId: line_a1
                            sku: SKU-COFFEE-250
                            quantity: 12
                          - lineId: ""
                            orderLineId: line_a2
                            sku: SKU-MUG-STD
                            quantity: 6
                        linesKnown: true
                        linesSource: consignment
                    updatedAt: "2026-07-12T05:41:30Z"
        "400":
          description: Invalid `cursor`, `updatedSince` or `limit`, or both `cursor` and `updatedSince` sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: false }
                  error: { type: string }
              example:
                success: false
                error: use either cursor or updatedSince, not both
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/orders/{orderType}/{orderId}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Get an order with its lines
      description: |
        Looks in open first, then archived. Scope `orders:read`; for
        `orderType` `send` `orders:write` is also accepted (receive, transfer
        and returns still need `orders:read`, 403 otherwise). The order
        carries `stage`, `shipments` and `updatedAt`; send orders also carry
        `shipFromLocationId` (see `OrderSummary`).
      parameters:
        - name: orderType
          in: path
          required: true
          description: |
            The `return` alias is accepted and canonicalized to `returns`.
          schema: { type: string, enum: [send, receive, transfer, returns, return] }
        - name: orderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Order detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderDetailResponse" }
              example:
                success: true
                order:
                  orderId: ord_7c1e9a3f5b
                  orderNumber: SO-100428
                  orderType: send
                  status: fulfilled
                  contactName: Jordan Lee
                  contactEmail: jordan.lee@example.com
                  locationName: Melbourne DC
                  lineCount: 2
                  totalItems: 18
                  reference1: WEB-10428
                  created: "2026-07-10T09:30:12Z"
                  modified: "2026-07-12T05:41:30Z"
                  orderDate: "2026-07-10T09:30:00Z"
                  stage: Fulfilled
                  shipFromLocationId: loc_mel_dc1
                  shipments:
                    - shipmentId: csg_8f2a61d0
                      carrierId: aramex
                      carrierName: Aramex
                      trackingNumber: "3DF00012345"
                      trackingUrl: https://freight.equos.one/track/3DF00012345
                      status: delivered
                      despatchDate: "2026-07-11"
                      deliveredAt: "2026-07-12T05:41:00Z"
                      parcelCount: 2
                      lines:
                        - lineId: "13790429610214"
                          orderLineId: line_a1
                          sku: SKU-COFFEE-250
                          quantity: 12
                        - lineId: ""
                          orderLineId: line_a2
                          sku: SKU-MUG-STD
                          quantity: 6
                      linesKnown: true
                      linesSource: consignment
                  updatedAt: "2026-07-12T05:41:30Z"
                lines:
                  - lineId: line_a1
                    externalLineId: "13790429610214"
                    sku: SKU-COFFEE-250
                    productName: Single Origin Coffee 250g
                    quantity: 12
                    availableQty: 320
                    unitOfMeasure: each
                  - lineId: line_a2
                    sku: SKU-MUG-STD
                    productName: Ceramic Mug
                    quantity: 6
                    availableQty: 84
                    unitOfMeasure: each
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/orders/{orderType}/{orderId}/finalize:
    post:
      tags: [Orders]
      operationId: finalizeOrder
      summary: Finalize an existing order
      description: |
        Enqueues one `OrderFinalize` job for an existing order and returns its
        `jobId` (poll `GET /e9v3/jobs/{jobId}`). Scope: `orders:fulfil`.
        No request body: line items are always read from the order itself.
        Finalizing reserves stock for the order's lines.
        A second call while the previous finalize job for the same order is
        still pending/processing returns that job with `duplicate: true`.
        These routes have their own per-key rate budget, separate from order
        creation. Returns 501 until the server enables order advance.
      parameters:
        - name: orderType
          in: path
          required: true
          schema: { type: string, enum: [send, receive] }
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/ClientTimeZone"
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/AdvanceNotEnabled" }

  /e9v3/orders/{orderType}/{orderId}/fulfil:
    post:
      tags: [Orders]
      operationId: fulfilOrder
      summary: Fulfil (send to warehouse) an existing order
      description: |
        Enqueues one `OrderFulfill` job for an existing order and returns its
        `jobId` (poll `GET /e9v3/jobs/{jobId}`). Scope: `orders:fulfil`.
        No request body: line items are always read from the order itself.
        Creates the warehouse task (outbound for send, inbound for receive). The order must be finalized; otherwise the job fails.
        A second call while the previous fulfil job for the same order is
        still pending/processing returns that job with `duplicate: true`.
        These routes have their own per-key rate budget, separate from order
        creation. Returns 501 until the server enables order advance.
      parameters:
        - name: orderType
          in: path
          required: true
          schema: { type: string, enum: [send, receive] }
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/ClientTimeZone"
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/AdvanceNotEnabled" }

  /e9v3/orders/{orderType}/{orderId}/commit:
    post:
      tags: [Orders]
      operationId: commitOrder
      summary: Commit an existing order
      description: |
        Enqueues one `OrderCommit` job for an existing order and returns its
        `jobId` (poll `GET /e9v3/jobs/{jobId}`). Scope: `orders:fulfil`.
        No request body: line items are always read from the order itself.
        Commits stock movements for the order. The order must be finalized; otherwise the job fails.
        A second call while the previous commit job for the same order is
        still pending/processing returns that job with `duplicate: true`.
        These routes have their own per-key rate budget, separate from order
        creation. Returns 501 until the server enables order advance.
      parameters:
        - name: orderType
          in: path
          required: true
          schema: { type: string, enum: [send, receive] }
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/ClientTimeZone"
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/AdvanceNotEnabled" }

  /e9v3/jobs/{jobId}:
    get:
      tags: [Jobs]
      operationId: getJobStatus
      summary: Get the status of a queued write job
      description: |
        Scope `orders:read`. Reads `error` (integrations worker) and legacy
        `errorMessage`. When the processJobs doc has expired, falls back to the
        durable `apiOrderSubmissions/{jobId}` marker when one exists.

        **Long poll.** With `wait` (seconds), a job that is `pending` or
        `processing` holds the request until it settles (`completed`,
        `partial`, `failed`) and answers at once when it does, or answers with
        the current state (still `pending` / `processing`) when `wait`
        elapses; then call again. The body is the same as without `wait`. A
        job that has already settled answers immediately. One long poll counts
        as one request for rate limiting. Set your HTTP client timeout above
        `wait` (for example `wait` + 10 s). For push delivery instead, subscribe
        to the `job.completed` / `job.failed` webhooks.
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
        - name: wait
          in: query
          required: false
          description: |
            Seconds to wait for a pending or processing job to settle. Whole
            number, 0–30; larger values are capped at 30. 0 or absent returns
            at once. Negative, fractional or non-numeric values return `400`.
          schema: { type: integer, minimum: 0, maximum: 30, default: 0 }
          example: 25
      responses:
        "200":
          description: Job status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobStatusResponse" }
              example:
                success: true
                jobId: inbound_1720664103218500000_a1b2c3d4e5f6a7b8
                status: completed
                processType: DirectOrderCreate
                createdAt: "2026-07-11T02:15:03Z"
                completedAt: "2026-07-11T02:15:05Z"
                result:
                  orderId: ord_7c1e9a3f5b
                  orderNumber: WO-0042
        "400":
          description: Invalid `wait`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: false }
                  error: { type: string }
              example:
                success: false
                error: wait must be a whole number of seconds (0-30)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/contacts:
    get:
      tags: [Contacts]
      operationId: listContacts
      summary: List contacts (customers / suppliers)
      description: |
        Scope `contacts:read`. Omitting `cursor` returns the first page — the
        same `search` + `limit` request existing clients already make. Each page
        is at most 500 contacts. When `hasMore` is true, pass `nextCursor` as
        `cursor` to continue. `hasMore` and `nextCursor` are additive fields;
        clients that ignore them keep working.

        List and search both page the published ContactIndex projection
        (name, email, phone, suburb/state/postcode, and street when the index
        generation includes it), not the live contacts collection. `404` is
        `{ success: false, error: "Contact index not published" }` when no
        index version exists or the version has no batch docs. Empty matches
        are `200` with an empty list. Get-by-id still reads the live contact
        document.
      parameters:
        - name: search
          in: query
          description: |
            Optional substring match on name, business name and email.
            Omit or pass empty to page the full published index.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: cursor
          in: query
          description: |
            Opaque page position (the previous page's nextCursor). Omit for
            the first page; existing clients that omit it are unchanged.
          schema: { type: string }
      responses:
        "200":
          description: Contacts page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactsListResponse" }
              example:
                success: true
                count: 2
                hasMore: false
                contacts:
                  - contactId: cust_8f3a2b1c9d
                    name: Jordan Lee
                    email: jordan.lee@example.com
                    phone: "+61412345678"
                    contactType: customer
                    status: active
                    addressStatus: validated
                  - contactId: supp_4d7e2a9b6c
                    name: Bright Bean Supply Co
                    businessName: Bright Bean Supply Pty Ltd
                    email: orders@brightbeansupply.com.au
                    contactType: supplier
                    status: active
                    addressStatus: postcode_only
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/contacts/{contactId}:
    get:
      tags: [Contacts]
      operationId: getContact
      summary: Get a contact
      description: Scope `contacts:read`.
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Contact detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactDetailResponse" }
              example:
                success: true
                count: 1
                contacts:
                  - contactId: cust_8f3a2b1c9d
                    name: Jordan Lee
                    email: jordan.lee@example.com
                    phone: "+61412345678"
                    contactType: customer
                    status: active
                    addressStatus: validated
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/contacts/export:
    post:
      tags: [Contacts]
      operationId: exportShopperData
      summary: Export a shopper's personal data (customers/data_request)
      description: |
        Scope `contacts:read`. Returns everything EQUOS holds for the matched
        contacts of the key's business: the contact record, its external refs
        and, when the key may read orders, each order's summary, the personal
        fields on it (`personalData`) and its lines. Orders follow the order
        read rules: `orders:read` sees every order type, `orders:write` send
        orders only, neither → `ordersIncluded: false`. Read-only; every call
        writes one audit row. Counts against the per-key write rate limit
        (POST).

        **Matching** (within the key's business only; inputs are ORed and
        `matchedBy` lists every input a contact matched):

        - `externalRef` — exact match (after trimming) of
          `{provider}:{store}:customer:{id}` against the ref owner record,
          `externalRefs[]` and the legacy `externalId`; an erased contact still
          matches by the ref (stored as a SHA-256 tombstone).
        - `email` — case-insensitive (`lower(trim)`) against `emailNormalized`,
          `emailAddress` and legacy `email`. Values stored with different case
          and no `emailNormalized` are not found by the index lookup.
        - `phone` — non-digits stripped, last 10 digits (the send-matched
          convention), plus the AU `+61` ↔ `0` forms, against `phoneNumber`,
          `mobile` and legacy `phone`. Stored values are found when stored in
          one of those forms. Fewer than 8 digits or a 1300/1800/1900 number is
          400.

        More than 25 matching contacts is 422 (narrow the request, e.g. by
        `externalRef`). No match is 200 with `count: 0`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShopperDataRequest" }
            example:
              externalRef: shopify:acme.myshopify.com:customer:7012345
              email: jordan.lee@example.com
              requestId: "gid://shopify/DataRequest/123"
              reason: customers/data_request
      responses:
        "200":
          description: Export (possibly empty).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShopperExportResponse" }
              example:
                success: true
                requestId: "gid://shopify/DataRequest/123"
                auditId: "812"
                generatedAt: "2026-10-25T09:30:00Z"
                count: 1
                contacts:
                  - contactId: con_8f3a2b1c
                    matchedBy: [email, externalRef]
                    status: active
                    contact:
                      contactId: con_8f3a2b1c
                      name: Jordan Lee
                      email: jordan.lee@example.com
                      phone: "0412345678"
                      contactType: customer
                      status: active
                      address: { line1: 1 Sample St, suburb: Sampletown, state: VIC, postcode: "3000", country: Australia }
                      addressStatus: unverified
                    externalRefs: [shopify:acme.myshopify.com:customer:7012345]
                    ordersIncluded: true
                    orderTypes: [send]
                    ordersTruncated: false
                    orders:
                      - orderId: ord_1a2b3c4d
                        orderType: send
                        archived: false
                        role: customer
                        orderNumber: SO-1001
                        status: fulfilled
                        stage: Fulfilled
                        reference1: "#1001"
                        lineCount: 1
                        totalItems: 2
                        personalData:
                          customerName: Jordan Lee
                          customerDetails: { contactName: Jordan Lee, emailAddress: jordan.lee@example.com }
                        lines:
                          - { lineId: line_1, sku: SKU-1, productName: Widget, quantity: 2, unitPrice: "19.95" }
        "400": { $ref: "#/components/responses/ShopperValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ShopperTooManyMatches" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ShopperAuditUnavailable" }

  /e9v3/contacts/erase:
    post:
      tags: [Contacts]
      operationId: eraseShopperData
      summary: Erase a shopper's personal data (customers/redact)
      description: |
        Scope `contacts:write`. Matches contacts exactly like
        `POST /e9v3/contacts/export` (within the key's business only) and,
        for each:

        - **Contact:** name, business name, email, phone, mobile, fax,
          website, address fields and notes are blanked; `emailNormalized` and
          the legacy `externalId` are removed; `externalRefs` is emptied (a
          SHA-256 of each ref is kept so a repeat call by ref reports
          `already_erased`); the ref owner records are deleted and the contact
          is archived (`isArchived`, `erasedAt`, `erasedBy`). The record stays
          so orders keep their `customerId`. Kept: id, type, tags, category,
          ABN/ACN and GST flag.
        - **Orders** where the contact is the customer (or supplier): the
          party's name / email / phone / address fields and detail snapshots,
          receiver (or sender) details in freight consignments, and the text of
          notes imported through the API (e.g. the Shopify order note) are
          blanked; `piiErasedAt` is set. Kept: order id and number,
          references, status, dates, totals, metadata, lines (SKU, product,
          quantity, price), carrier and tracking. `modified` is not changed.
        - **Settled order jobs** that carried the shopper's details are
          blanked; jobs still `pending`/`processing` are counted in
          `jobsInFlight` and left alone (they expire 390 seconds after settling).
        - Stored webhook delivery payloads that mention the contact or its
          orders, and stored Shopify webhook payloads for the customer, are
          redacted.

        Synchronous and idempotent: repeat calls succeed, report
        `already_erased` (by `externalRef`) or `no_match` (by email/phone,
        which are gone), and finish anything a failed call left. `outcome:
        partial` means repeat the call. `dryRun: true` reports what would
        change and writes nothing (except the audit row). Every call writes
        one audit row (ids, statuses and counts — no personal data); without
        the audit log the call is 503 and nothing is changed. More than 25
        matching contacts is 422 and nothing is changed. Up to 1000 orders
        per contact are erased per call (`ordersRemaining: true` → repeat).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShopperDataRequest" }
            example:
              externalRef: shopify:acme.myshopify.com:customer:7012345
              email: jordan.lee@example.com
              phone: "+61 412 345 678"
              requestId: "shopify-webhook-5f0c…"
              reason: customers/redact
      responses:
        "200":
          description: Erasure result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShopperEraseResponse" }
              example:
                success: true
                outcome: erased
                requestId: "shopify-webhook-5f0c…"
                auditId: "813"
                dryRun: false
                matched: 1
                contacts:
                  - contactId: con_8f3a2b1c
                    matchedBy: [email, externalRef, phone]
                    status: erased
                    ordersMatched: 3
                    ordersErased: 3
                    ordersRemaining: false
                    jobsScrubbed: 1
                    jobsInFlight: 0
                    refsRemoved: 1
                totals:
                  contacts: 1
                  ordersErased: 3
                  jobsScrubbed: 1
                  webhookLogsRedacted: 4
                  shopifyDeliveriesRedacted: 2
        "400": { $ref: "#/components/responses/ShopperValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ShopperTooManyMatches" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ShopperAuditUnavailable" }

  /e9v3/contacts/erase/shop:
    post:
      tags: [Contacts]
      operationId: eraseShopShopperData
      summary: Erase every shopper of a Shopify shop (shop/redact)
      description: |
        Scope `contacts:write`. Erases, one page per call, every contact of
        the key's business linked to `shopify:{shop}:customer:*` (the ref
        owner records first, then contacts that only carry the ref in the
        legacy `externalId`). Each contact is erased as in
        `POST /e9v3/contacts/erase`, except that a contact that is also linked
        to another shop or provider is only **unlinked**: its refs for this
        shop are removed and only the orders that came from this shop
        (`metadata.shopDomain`) are blanked; its contact details stay.

        Call again with `nextCursor` until `complete` is `true` (the last call
        also redacts every stored Shopify webhook payload of the shop). Each
        call is bounded by `limit` (default 25, max 100) and audit-logged.
        Repeating the whole run is safe.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShopperEraseShopRequest" }
            example:
              shop: acme.myshopify.com
              limit: 50
              requestId: "shopify-webhook-91aa…"
              reason: shop/redact
      responses:
        "200":
          description: One page of the shop erasure.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShopperEraseShopResponse" }
              example:
                success: true
                outcome: erased
                shop: acme.myshopify.com
                requestId: "shopify-webhook-91aa…"
                auditId: "814"
                dryRun: false
                processed: 2
                contacts:
                  - { contactId: con_1, matchedBy: [externalRef], status: erased, ordersMatched: 2, ordersErased: 2, ordersRemaining: false, jobsScrubbed: 0, jobsInFlight: 0, refsRemoved: 1 }
                  - { contactId: con_4, matchedBy: [externalRef], status: unlinked, ordersMatched: 1, ordersErased: 1, ordersRemaining: false, jobsScrubbed: 0, jobsInFlight: 0, refsRemoved: 1 }
                totals: { contacts: 2, ordersErased: 3, jobsScrubbed: 0, webhookLogsRedacted: 0, shopifyDeliveriesRedacted: 0 }
                complete: false
                nextCursor: eyJwIjoxLCJhIjoic2hvcGlmeTphY21lLm15c2hvcGlmeS5jb206Y3VzdG9tZXI6NCJ9
        "400": { $ref: "#/components/responses/ShopperValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ShopperAuditUnavailable" }

  /e9v3/inventory:
    get:
      tags: [Inventory]
      operationId: listInventory
      summary: List inventory items with stock levels
      description: |
        Scope `inventory:read`. Omitting `cursor` returns the first page — the
        same `search` + `limit` request existing clients already make. Each page
        is at most 500 items. When `hasMore` is true, pass `nextCursor` as
        `cursor` to continue. `hasMore` and `nextCursor` are additive fields;
        clients that ignore them keep working.

        List and search both page the published InventoryIndex projection
        (SKU, product name, category, barcode), not the live Inventory
        collection. Variant SKUs are extra rows with `itemId` of the parent.
        Quantities are as of the last IndexRegenerate. `404` is
        `{ success: false, error: "Inventory index not published" }` when no
        index version exists or the version has no batch docs. Empty matches
        are `200` with an empty list.
      parameters:
        - name: search
          in: query
          description: |
            Optional substring match on SKU, product name, category and barcode.
            Omit or pass empty to page the full published index.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: cursor
          in: query
          description: |
            Opaque page position (the previous page's nextCursor). Omit for
            the first page; existing clients that omit it are unchanged.
          schema: { type: string }
      responses:
        "200":
          description: Inventory page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryListResponse" }
              example:
                success: true
                count: 2
                hasMore: false
                inventory:
                  - itemId: item_5a2c8f1d3e
                    businessId: biz_equos_demo
                    sku: SKU-COFFEE-250
                    productName: Single Origin Coffee 250g
                    description: Whole bean, medium roast
                    category: Beverages
                    barcode: "9312345678907"
                    availableQuantity: 320
                    totalQuantity: 400
                    reservedQuantity: 80
                    orderedQuantity: 0
                    returnsQuantity: 0
                    unitOfMeasure: each
                    isActive: true
                    isComposite: false
                    stockStatus: inStock
                  - itemId: item_9b4e6a2c7f
                    businessId: biz_equos_demo
                    sku: SKU-MUG-STD
                    productName: Ceramic Mug
                    description: 350ml stoneware mug
                    category: Homewares
                    barcode: "9312345671123"
                    availableQuantity: 12
                    totalQuantity: 120
                    reservedQuantity: 6
                    orderedQuantity: 0
                    returnsQuantity: 0
                    unitOfMeasure: each
                    isActive: true
                    isComposite: false
                    stockStatus: critical
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/{itemId}:
    get:
      tags: [Inventory]
      operationId: getInventoryItem
      summary: Get an inventory item
      description: |
        Scope `inventory:read`. Reads the live `Inventory/{itemId}` document
        (one Get). List/search use the versioned InventoryIndex instead.
      parameters:
        - name: itemId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Item detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryItemResponse" }
              example:
                success: true
                count: 1
                inventory:
                  - itemId: item_5a2c8f1d3e
                    businessId: biz_equos_demo
                    sku: SKU-COFFEE-250
                    productName: Single Origin Coffee 250g
                    description: Whole bean, medium roast
                    category: Beverages
                    barcode: "9312345678907"
                    availableQuantity: 320
                    totalQuantity: 400
                    reservedQuantity: 80
                    orderedQuantity: 0
                    returnsQuantity: 0
                    unitOfMeasure: each
                    isActive: true
                    isComposite: false
                    stockStatus: inStock
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/levels:
    get:
      tags: [Inventory]
      operationId: listInventoryLevels
      summary: Per-warehouse stock levels
      description: |
        Scope `inventory:read` or `inventory:write`. `locationId` must be a ship-from warehouse id from
        `GET /e9v3/key-info`. Levels are per item (variants roll up to their
        parent item) and are recomputed after every stock movement. They are
        conservative, so stock is never overstated:

        - `onHand`: the warehouse's own stock (the item's per-location
          record). If the per-location records add up to more than the
          business-wide on-hand, the excess is subtracted from every location.
        - `reserved`: the business-wide reservation total. Reservations are
          not tracked per warehouse, so every location carries all of them.
        - `available`: `onHand - reserved`, never below 0. The sum over all
          locations never exceeds the business-wide available.

        An item with no row for this location (no per-location stock recorded,
        or not yet backfilled after deploy) is absent from the response. Absent
        means unknown: do not treat it as 0, and do not push it.
      parameters:
        - name: locationId
          in: query
          required: true
          schema: { type: string }
        - name: skus
          in: query
          description: Optional comma-separated SKU filter.
          schema: { type: string }
        - name: updatedSince
          in: query
          description: RFC3339 timestamp; return rows updated at or after this time.
          schema: { type: string, format: date-time }
        - name: cursor
          in: query
          description: Opaque product id cursor from the previous page.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 250, default: 250 }
      responses:
        "200":
          description: Level page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryLevelsResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/changes:
    get:
      tags: [Inventory]
      operationId: listInventoryChanges
      summary: Monotonic inventory level change feed
      description: |
        Scope `inventory:read` or `inventory:write`. Safety-net poll for `inventory.level_changed`
        webhooks. Retains at least 7 days; `410` when `cursor` is older than
        retained history (full resync required).
      parameters:
        - name: cursor
          in: query
          description: Last seen `seq` (integer). Omit to start from oldest retained row.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 500 }
      responses:
        "200":
          description: Change page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryChangesResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "410":
          description: Cursor expired; perform a full inventory resync.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryChangesResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/sync:
    post:
      tags: [Inventory]
      operationId: syncInventory
      summary: Declare the absolute on-hand quantity for a SKU
      description: |
        Sets (not adjusts) the on-hand quantity for a SKU. Applied through the
        platform's transactional inventory pipeline as an async job. Scope:
        `inventory:write`.
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InventorySyncRequest" }
            example:
              sku: SKU-COFFEE-250
              quantity: 320
              sourceRecordId: shopify-inv-8842
      responses:
        "200": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/items:
    post:
      tags: [Inventory]
      operationId: upsertCatalogItem
      summary: Upsert a catalog item by SKU
      description: |
        Creates or updates a live `businessdata/{businessId}/Inventory/{itemId}`
        document and seeds/updates the matching Postgres `inventory` row
        (metadata only — stock quantities are never changed). Idempotent when
        the same `sourceRecordId` and SKU are retried. Returns `409` when the
        SKU is already owned by a different `sourceRecordId`. Setting
        `active: false` archives the item (same as the archive route). Scope:
        `inventory:write`.

        SKU length is capped at 256 characters (Postgres `inventory.sku` is
        TEXT with no hard limit; the API enforces 256 for integration safety).
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CatalogItemUpsertRequest" }
      responses:
        "200":
          description: Item created or updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogItemUpsertResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: SKU owned by another source record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogItemUpsertResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/items/batch:
    post:
      tags: [Inventory]
      operationId: upsertCatalogItemsBatch
      summary: Upsert up to 50 catalog items
      description: |
        Same semantics as `POST /e9v3/inventory/items`, with per-item results
        (partial success allowed). More than 50 items returns `400`. Scope:
        `inventory:write`. Subject to the `/e9v3` write rate limit (1 rps,
        burst 10).
      parameters:
        - $ref: "#/components/parameters/ClientTimeZone"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CatalogItemBatchRequest" }
      responses:
        "200":
          description: Batch processed (check per-item success flags).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogItemBatchResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/inventory/items/{sku}/archive:
    post:
      tags: [Inventory]
      operationId: archiveCatalogItem
      summary: Archive a catalog item by SKU
      description: |
        Archives a live inventory catalog item (moves to `InventoryArchived` via
        an `InventoryArchive` job — never hard-deletes). Stock quantities are
        untouched. Scope: `inventory:write`.
      parameters:
        - name: sku
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/ClientTimeZone"
      responses:
        "200":
          description: Archive enqueued (idempotent when already archived).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogItemArchiveResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/webhooks:
    get:
      tags: [Webhooks]
      operationId: listE9v3Webhooks
      summary: List webhook subscriptions
      description: |
        Scope `webhooks:manage` lists every subscription of the key's
        business. A key without it but with `inventory:read`,
        `inventory:write`, `orders:read` or `orders:write` sees only the
        subscriptions it created itself whose events it may manage (see the
        scope table on `POST /e9v3/webhooks`).
      responses:
        "200":
          description: Subscriptions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/E9v3WebhookListResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Webhooks]
      operationId: createE9v3Webhook
      summary: Create a webhook subscription
      description: |
        Each event is checked against the key's scopes (any one listed scope
        suffices, otherwise `403`):

        | Events | Scopes |
        |--------|--------|
        | `inventory.level_changed` | `webhooks:manage`, `inventory:read`, `inventory:write` |
        | `inventory.adjusted`, `inventory.low_stock`, `inventory.transfer` | `webhooks:manage`, `inventory:read` |
        | `order.status_changed`, `shipment.created`, `shipment.status_changed`, `shipment.delivered` | `webhooks:manage`, `orders:read`, `orders:write` |
        | `job.completed`, `job.failed` (only for jobs the subscribing key enqueued, unless `webhooks:manage`) | `webhooks:manage`, `orders:read`, `orders:write` |
        | `subscription.activated`, `subscription.renewed`, `subscription.updated`, `subscription.canceled`, `subscription.payment_failed` | `webhooks:manage`, `billing:write` |
        | every other event (`order.created`, `contact.*`, `payment.status_changed`, ...) | `webhooks:manage` |

        Only `webhooks:manage` manages the business's subscriptions as a
        whole: it lists, reads, updates and deletes every subscription,
        including console-created ones and other keys'. Every other scope
        manages only the subscriptions the same API key created, for the
        events that scope covers above; any other id returns `404`. Shopify
        app keys (`orders:write` + `inventory:write`) therefore manage their
        own order/shipment and `inventory.level_changed` subscriptions. The subscription is recorded
        against the calling API key, scoped to the key's business, and stamped
        `payloadVersion: 2`. Deliveries stop while that key is revoked,
        expired, unbound from the business, or no longer records a qualifying
        scope. The signing `secret` is returned once. Deliveries use
        HMAC-SHA256 in `X-Equos-Signature` over the JSON envelope
        `{ event, data, timestamp, deliveryId }`. Payloads are described in the
        top-level `webhooks` section.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/E9v3WebhookCreateRequest" }
      responses:
        "200":
          description: Created subscription (includes secret).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/E9v3WebhookCreateResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/webhooks/{id}:
    get:
      tags: [Webhooks]
      operationId: getE9v3Webhook
      summary: Get a webhook subscription
      description: |
        `webhooks:manage` reads any subscription of the business. Other
        qualifying scopes read only subscriptions created by the same API key
        whose events they cover; any other id returns `404`. The secret is
        never returned.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/E9v3WebhookResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Webhooks]
      operationId: updateE9v3Webhook
      summary: Update a webhook subscription
      description: |
        Changes `url`, `events`, `description` and/or `isActive`; omitted
        fields keep their value. The secret, owner key and `payloadVersion`
        never change. New `events` are checked against the key's scopes as on
        create (`403`). Ownership is the same as `GET`: other keys'
        subscriptions return `404` unless the key has `webhooks:manage`.
        Setting `isActive: true` on a disabled subscription also resets its
        failure count.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/E9v3WebhookUpdateRequest" }
      responses:
        "200":
          description: Updated subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/E9v3WebhookResponse" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Webhooks]
      operationId: deleteE9v3Webhook
      summary: Delete a webhook subscription
      description: |
        `webhooks:manage` deletes any subscription of the business. Other
        qualifying scopes delete only subscriptions created by the same API
        key whose events they cover; any other id returns `404`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/catalog:
    get:
      tags: [Catalog]
      operationId: getCatalog
      summary: Get a published catalog feed batch
      description: |
        Scope `inventory:read`. Returns one batch of the published B2B catalog
        snapshot (`businessdata/{businessId}/b2bCatalog`) — a quantity-free,
        orderable product feed. This is a snapshot read; it does not hit live
        inventory.

        Iterate `?batch=0..version.batchCount-1` to page the whole feed (500
        items per batch). Each item's `sku` is the parent SKU and `variantSku`
        (when present) is the orderable variant; the effective join key against
        prices is `variantSku ?? sku`.

        When a public feed price tier is configured for the business
        (console → Pricing → Public feed tier) and published, `price` and `gst`
        are merged onto each item whose effective SKU is priced, and a
        top-level `pricing` block names the tier. When no tier is configured or
        the tier is not yet published, `price`/`gst` and `pricing` are omitted.

        Responses carry a strong `ETag` derived from the catalog publish time,
        the merged price-tier version and the batch index. Send it back in
        `If-None-Match` to revalidate; an unchanged feed returns `304` with no
        body. `Cache-Control: no-cache` instructs caches to always revalidate.

        `404` is returned with `{ success: false, error: "Catalog not
        published" }` when the business has never published a catalog, and with
        `{ success: false, error: "Batch out of range" }` when `batch` is
        greater than or equal to `version.batchCount`.
      parameters:
        - name: batch
          in: query
          description: Zero-based batch index. Non-integer or negative values are rejected with 400.
          schema: { type: integer, minimum: 0, default: 0 }
        - name: If-None-Match
          in: header
          required: false
          description: A previously returned ETag; a match returns 304.
          schema: { type: string }
      responses:
        "200":
          description: A catalog feed batch.
          headers:
            ETag:
              description: Strong validator for this batch; echo in If-None-Match.
              schema: { type: string }
            Cache-Control:
              description: Always `no-cache` (revalidate before reuse).
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogResponse" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/manufacturing:
    get:
      tags: [Manufacturing]
      operationId: listManufacturing
      summary: List manufacturing tasks
      description: |
        Scope `orders:read`. `search` is filtered in-memory over the first
        `limit+1` documents; there is no cursor pagination.
      parameters:
        - name: type
          in: query
          description: Task type to list (optional).
          schema: { type: string, enum: [create, assembly, quality, batch], default: create }
        - name: archived
          in: query
          description: Set true to list archived tasks instead of open ones.
          schema: { type: boolean, default: false }
        - name: status
          in: query
          description: |
            Filter by derived workflow status: `pending`, `finalized`,
            `committed`, `completed` or `voided`.
          schema: { type: string }
        - name: search
          in: query
          description: Matches task number and description.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: Manufacturing tasks.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ManufacturingListResponse" }
              example:
                success: true
                count: 1
                tasks:
                  - taskId: task_3f9a1c7e2b
                    taskNumber: MF-2026-0042
                    taskType: assembly
                    status: finalized
                    description: Assemble gift hamper (coffee + mug)
                    businessId: biz_equos_demo
                    locationName: Main Warehouse
                    outputItems:
                      - sku: SKU-HAMPER-01
                        productName: Coffee Gift Hamper
                        quantity: 20
                        uom: each
                    inputItems:
                      - sku: SKU-COFFEE-250
                        productName: Single Origin Coffee 250g
                        quantity: 20
                        uom: each
                      - sku: SKU-MUG-STD
                        productName: Ceramic Mug
                        quantity: 20
                        uom: each
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/manufacturing/{taskType}/{taskId}:
    get:
      tags: [Manufacturing]
      operationId: getManufacturingTask
      summary: Get a manufacturing task
      description: Scope `orders:read`.
      parameters:
        - name: taskType
          in: path
          required: true
          schema: { type: string, enum: [create, assembly, quality, batch] }
        - name: taskId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Task detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ManufacturingTaskResponse" }
              example:
                success: true
                count: 1
                tasks:
                  - taskId: task_3f9a1c7e2b
                    taskNumber: MF-2026-0042
                    taskType: assembly
                    status: finalized
                    description: Assemble gift hamper (coffee + mug)
                    businessId: biz_equos_demo
                    locationName: Main Warehouse
                    outputItems:
                      - sku: SKU-HAMPER-01
                        productName: Coffee Gift Hamper
                        quantity: 20
                        uom: each
                    inputItems:
                      - sku: SKU-COFFEE-250
                        productName: Single Origin Coffee 250g
                        quantity: 20
                        uom: each
                      - sku: SKU-MUG-STD
                        productName: Ceramic Mug
                        quantity: 20
                        uom: each
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/addresses/search:
    get:
      tags: [Addresses]
      operationId: searchAddresses
      summary: Search Australian addresses (G-NAF)
      description: Scope `contacts:read`.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Address candidates.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    sla: { type: string }
                    score: { type: number }
                  additionalProperties: true
              example:
                - sla: 12 LATROBE STREET, MELBOURNE VIC 3000
                  score: 0.98
                - sla: 12 LATROBE STREET, MELBOURNE VIC 3004
                  score: 0.71
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /e9v3/billing/checkout-sessions:
    servers:
      - url: https://equospayments.equosnine.com
        description: Payments host
    post:
      tags: [Billing]
      operationId: createAppBillingCheckoutSession
      summary: Start a subscription Checkout for an app user
      description: |
        Scope `billing:write`. Creates a Stripe Checkout session (subscription
        mode, quantity 1) for one of your app's plans. `plan` and `cadence`
        must be offered by your app; `successUrl` and `cancelUrl` must be on
        one of your app's registered origins (exact scheme, host and port).
        The first call for an `externalUserId` creates its Stripe customer
        (`email` is used only then); later calls reuse it. The subscription
        carries metadata `equos_app`, `external_user_id`, `plan` and
        `cadence`, and the session's `client_reference_id` is the
        `externalUserId`. Redirect the user to `url`.

        A user who already has an `active`, `trialing` or `past_due`
        subscription gets 409 `subscription_exists` with `usePortal: true`:
        send them to `POST /e9v3/billing/portal-sessions` to change plans.
        A trial (when your app has one) is only given to a user who has never
        had one.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppCheckoutSessionRequest" }
            example:
              plan: writer
              cadence: monthly
              externalUserId: 6b0f3c52-1d2e-4c8a-9a51-3f7e2d1c0b9a
              email: writer@example.com
              successUrl: "https://app.example.com/billing/done?session={CHECKOUT_SESSION_ID}"
              cancelUrl: https://app.example.com/billing
      responses:
        "200":
          description: Checkout session created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppCheckoutSessionResponse" }
              example:
                url: https://checkout.stripe.com/c/pay/cs_test_a1b2c3
                sessionId: cs_test_a1b2c3
                customerId: cus_Q1w2e3r4t5y6
        "400": { $ref: "#/components/responses/AppBillingBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AppBillingForbidden" }
        "409":
          description: The user already has a live subscription; use the portal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppBillingError" }
              example:
                error: customer already has a subscription; use the billing portal to change it
                code: subscription_exists
                usePortal: true
        "502": { $ref: "#/components/responses/AppBillingProviderError" }
        "503": { $ref: "#/components/responses/AppBillingNotConfigured" }

  /e9v3/billing/portal-sessions:
    servers:
      - url: https://equospayments.equosnine.com
        description: Payments host
    post:
      tags: [Billing]
      operationId: createAppBillingPortalSession
      summary: Open the Stripe Customer Portal for an app user
      description: |
        Scope `billing:write`. `returnUrl` must be on one of your app's
        registered origins. 404 `no_customer` when the user has never started
        a Checkout with your app.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppPortalSessionRequest" }
            example:
              externalUserId: 6b0f3c52-1d2e-4c8a-9a51-3f7e2d1c0b9a
              returnUrl: https://app.example.com/account
      responses:
        "200":
          description: Portal session created; redirect the user to `url`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppPortalSessionResponse" }
              example:
                url: https://billing.stripe.com/p/session/test_YWNjdF8x
        "400": { $ref: "#/components/responses/AppBillingBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AppBillingForbidden" }
        "404": { $ref: "#/components/responses/AppBillingNoCustomer" }
        "502": { $ref: "#/components/responses/AppBillingProviderError" }
        "503": { $ref: "#/components/responses/AppBillingNotConfigured" }

  /e9v3/billing/subscriptions/cancel:
    servers:
      - url: https://equospayments.equosnine.com
        description: Payments host
    post:
      tags: [Billing]
      operationId: cancelAppBillingSubscription
      summary: Cancel an app user's subscription at period end
      description: |
        Scope `billing:write`. Sets `cancel_at_period_end` on the user's
        `active` or `trialing` subscription; access continues to
        `accessUntil` (trial end while trialing, otherwise the current period
        end). Already canceling is a no-op: 200 with `changed: false`.

        409 codes: `no_subscription` (never subscribed), `period_ended`
        (already ended), `not_cancelable` with `usePortal: true` (more than
        one live subscription, `past_due` / `unpaid` / `paused`, a schedule,
        a dated cancel or paused collection). Changes are limited to one per
        10 seconds and 10 per hour per user (429 `rate_limited` with
        `Retry-After`); a no-op is never limited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppSubscriptionChangeRequest" }
            example:
              externalUserId: 6b0f3c52-1d2e-4c8a-9a51-3f7e2d1c0b9a
      responses:
        "200":
          description: Subscription state after the call.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppSubscriptionChangeResponse" }
              example:
                changed: true
                status: active
                cancelAtPeriodEnd: true
                accessUntil: "2026-11-30T09:12:44Z"
        "400": { $ref: "#/components/responses/AppBillingBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AppBillingForbidden" }
        "404": { $ref: "#/components/responses/AppBillingNoCustomer" }
        "409": { $ref: "#/components/responses/AppBillingChangeConflict" }
        "429": { $ref: "#/components/responses/AppBillingRateLimited" }
        "502": { $ref: "#/components/responses/AppBillingProviderError" }
        "503": { $ref: "#/components/responses/AppBillingNotConfigured" }

  /e9v3/billing/subscriptions/resume:
    servers:
      - url: https://equospayments.equosnine.com
        description: Payments host
    post:
      tags: [Billing]
      operationId: resumeAppBillingSubscription
      summary: Undo a pending cancel at period end
      description: |
        Scope `billing:write`. Clears `cancel_at_period_end` while the period
        has not ended. Not canceling is a no-op: 200 with `changed: false`.
        409 codes: `no_subscription`, `not_resumable` (ended, or a scheduled
        change; `usePortal: true` for the latter), `period_ended`,
        `not_cancelable` (more than one live subscription). Same rate limit as
        cancel.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppSubscriptionChangeRequest" }
            example:
              externalUserId: 6b0f3c52-1d2e-4c8a-9a51-3f7e2d1c0b9a
      responses:
        "200":
          description: Subscription state after the call.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppSubscriptionChangeResponse" }
              example:
                changed: true
                status: active
                cancelAtPeriodEnd: false
                accessUntil: "2026-11-30T09:12:44Z"
        "400": { $ref: "#/components/responses/AppBillingBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AppBillingForbidden" }
        "404": { $ref: "#/components/responses/AppBillingNoCustomer" }
        "409": { $ref: "#/components/responses/AppBillingChangeConflict" }
        "429": { $ref: "#/components/responses/AppBillingRateLimited" }
        "502": { $ref: "#/components/responses/AppBillingProviderError" }
        "503": { $ref: "#/components/responses/AppBillingNotConfigured" }

  /e9v3/billing/subscriptions:
    servers:
      - url: https://equospayments.equosnine.com
        description: Payments host
    get:
      tags: [Billing]
      operationId: getAppBillingSubscription
      summary: Read an app user's subscription (live from Stripe)
      description: |
        Scope `billing:write`. Returns the user's newest live subscription,
        or the newest one of any status (e.g. `canceled`) when none is live.
        `status` is the Stripe status, except `paused` while collection is
        paused. `plan` and `cadence` come from the subscription's current
        price. 404 `no_customer` (never started a Checkout) or
        `no_subscription` (no subscription yet, e.g. Checkout not completed).
        Use it to reconcile or poll after Checkout.
      parameters:
        - name: externalUserId
          in: query
          required: true
          schema: { type: string, maxLength: 200 }
      responses:
        "200":
          description: The user's subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppSubscription" }
              example:
                appId: swift-axis
                externalUserId: 6b0f3c52-1d2e-4c8a-9a51-3f7e2d1c0b9a
                customerId: cus_Q1w2e3r4t5y6
                subscriptionId: sub_1Q2w3E4r5T6y
                status: active
                plan: writer
                cadence: annual
                currentPeriodEnd: "2027-10-31T09:12:44Z"
                cancelAtPeriodEnd: false
        "400": { $ref: "#/components/responses/AppBillingBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AppBillingForbidden" }
        "404":
          description: No customer or no subscription for this user in your app.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppBillingError" }
              example:
                error: no subscription for this externalUserId
                code: no_subscription
        "502": { $ref: "#/components/responses/AppBillingProviderError" }
        "503": { $ref: "#/components/responses/AppBillingNotConfigured" }

webhooks:
  order.status_changed:
    post:
      tags: [Webhooks]
      operationId: orderStatusChangedWebhook
      summary: Send order stage changed
      description: |
        Sent to API-key subscriptions (payload version 2) for `order.status_changed`.
        Subscribing needs `webhooks:manage`, `orders:read` or `orders:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderStatusChangedWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  shipment.created:
    post:
      tags: [Webhooks]
      operationId: shipmentCreatedWebhook
      summary: Consignment got a tracking number
      description: |
        Sent to API-key subscriptions (payload version 2) for `shipment.created`.
        Subscribing needs `webhooks:manage`, `orders:read` or `orders:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentWebhook" }
            example:
              event: shipment.created
              timestamp: "2026-07-11T02:14:10Z"
              deliveryId: 5cef751a-467f-8f21-afd7-d93fd44831ec
              data:
                businessId: biz_4f1c
                orderId: ord_7c1e9a3f5b
                orderType: send
                orderNumber: WO-0428
                source: shopify
                sourceRecordId: "shopify:acme.myshopify.com:order:5523411009"
                reference1: "#1428"
                stage: Fulfilled
                shipFromLocationId: loc_mel_dc1
                shipment:
                  shipmentId: csg_8f2a61d0
                  carrierId: aramex
                  carrierName: Aramex
                  trackingNumber: "3DF00012345"
                  trackingUrl: https://freight.equos.one/track/3DF00012345
                  status: booked
                  despatchDate: "2026-07-11"
                  deliveredAt: null
                  parcelCount: 1
                  lines:
                    - lineId: "13790429610214"
                      orderLineId: line_1
                      sku: SKU-COFFEE-250
                      quantity: 12
                  linesKnown: true
                  linesSource: consignment
                shipments:
                  - shipmentId: csg_8f2a61d0
                    carrierId: aramex
                    carrierName: Aramex
                    trackingNumber: "3DF00012345"
                    trackingUrl: https://freight.equos.one/track/3DF00012345
                    status: booked
                    despatchDate: "2026-07-11"
                    deliveredAt: null
                    parcelCount: 1
                    lines:
                      - lineId: "13790429610214"
                        orderLineId: line_1
                        sku: SKU-COFFEE-250
                        quantity: 12
                    linesKnown: true
                    linesSource: consignment
                  - shipmentId: csg_91bb07c4
                    carrierId: aramex
                    carrierName: Aramex
                    trackingNumber: ""
                    trackingUrl: null
                    status: draft
                    despatchDate: null
                    deliveredAt: null
                    parcelCount: 1
                    lines: []
                    linesKnown: false
                    linesSource: unknown
                updatedAt: "2026-07-11T02:14:09.512Z"
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  shipment.status_changed:
    post:
      tags: [Webhooks]
      operationId: shipmentStatusChangedWebhook
      summary: Consignment status or tracking number changed
      description: |
        Sent to API-key subscriptions (payload version 2) for `shipment.status_changed`.
        Subscribing needs `webhooks:manage`, `orders:read` or `orders:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  shipment.delivered:
    post:
      tags: [Webhooks]
      operationId: shipmentDeliveredWebhook
      summary: Consignment delivered
      description: |
        Sent to API-key subscriptions (payload version 2) for `shipment.delivered`.
        Subscribing needs `webhooks:manage`, `orders:read` or `orders:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  job.completed:
    post:
      tags: [Webhooks]
      operationId: jobCompletedWebhook
      summary: Queued write job completed
      description: |
        Sent when a job enqueued by an API key (any write that returned a
        `jobId`) settles `completed` or `partial` (order created, an advance
        step failed; see `data.result.steps`). `data` carries the same fields
        as `GET /e9v3/jobs/{jobId}` plus `sourceRecordId` and the order
        identity, so no follow-up poll is needed. Subscribing needs
        `webhooks:manage`, `orders:read` or `orders:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  job.failed:
    post:
      tags: [Webhooks]
      operationId: jobFailedWebhook
      summary: Queued write job failed
      description: |
        Sent when a job enqueued by an API key settles `failed`
        (`data.errorMessage` says why). Today this covers
        `POST /e9v3/orders/send-matched` and `POST /e9v3/orders/receive`
        failures, and also read-only module denials, failed bulk fulfils and
        trigger-doc failures of API-key jobs; other failures are still
        reported by `GET /e9v3/jobs/{jobId}` only. A partial bulk fulfil
        sends `job.completed` with `status` `partial`, not `job.failed`.
        Subscribing needs `webhooks:manage`, `orders:read` or `orders:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  subscription.activated:
    post:
      tags: [Webhooks]
      operationId: subscriptionActivatedWebhook
      summary: A subscription became active or started its trial
      description: |
        Sent for app-client subscriptions only (Equos console subscriptions never
        emit it). Source: Stripe `customer.subscription.created` reaching `active` or `trialing`, or `checkout.session.completed` for a subscription. Sent once per subscription. Each Stripe event id is delivered once;
        `data.stripeEventId` lets you de-duplicate. Subscribing needs
        `webhooks:manage` or `billing:write`. Verify `X-Equos-Signature` as
        described on that header.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  subscription.renewed:
    post:
      tags: [Webhooks]
      operationId: subscriptionRenewedWebhook
      summary: A billing cycle was paid
      description: |
        Sent for app-client subscriptions only (Equos console subscriptions never
        emit it). Source: Stripe `invoice.paid` with `billing_reason` `subscription_cycle`. Each Stripe event id is delivered once;
        `data.stripeEventId` lets you de-duplicate. Subscribing needs
        `webhooks:manage` or `billing:write`. Verify `X-Equos-Signature` as
        described on that header.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  subscription.updated:
    post:
      tags: [Webhooks]
      operationId: subscriptionUpdatedWebhook
      summary: Subscription status, plan, cadence or cancel-at-period-end changed
      description: |
        Sent for app-client subscriptions only (Equos console subscriptions never
        emit it). Source: Stripe `customer.subscription.updated`. Each Stripe event id is delivered once;
        `data.stripeEventId` lets you de-duplicate. Subscribing needs
        `webhooks:manage` or `billing:write`. Verify `X-Equos-Signature` as
        described on that header.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  subscription.canceled:
    post:
      tags: [Webhooks]
      operationId: subscriptionCanceledWebhook
      summary: Subscription ended
      description: |
        Sent for app-client subscriptions only (Equos console subscriptions never
        emit it). Source: Stripe `customer.subscription.deleted`. Each Stripe event id is delivered once;
        `data.stripeEventId` lets you de-duplicate. Subscribing needs
        `webhooks:manage` or `billing:write`. Verify `X-Equos-Signature` as
        described on that header.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  subscription.payment_failed:
    post:
      tags: [Webhooks]
      operationId: subscriptionPaymentFailedWebhook
      summary: A subscription payment failed
      description: |
        Sent for app-client subscriptions only (Equos console subscriptions never
        emit it). Source: Stripe `invoice.payment_failed`. Each Stripe event id is delivered once;
        `data.stripeEventId` lets you de-duplicate. Subscribing needs
        `webhooks:manage` or `billing:write`. Verify `X-Equos-Signature` as
        described on that header.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

  inventory.level_changed:
    post:
      tags: [Webhooks]
      operationId: inventoryLevelChangedWebhook
      summary: Per-warehouse stock level changed
      description: |
        Sent when an item's level at one warehouse changes (the same values
        as `GET /e9v3/inventory/levels`, with the same conservative rule).
        One delivery per changed location; a reservation changes `reserved`
        and `available` at every location of the item. Nothing is sent when
        a recompute leaves a level unchanged.
        Subscribing needs `webhooks:manage`, `inventory:read` or
        `inventory:write`. Delivery is best-effort; `GET
        /e9v3/inventory/changes` carries the same rows with the same `seq` and
        is the safety net. De-duplicate and order on `seq`; `deliveryId` is
        random per delivery job.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InventoryLevelChangedWebhook" }
      responses:
        "200":
          description: Any 2xx acknowledges the delivery. Anything else (or no answer within 10 seconds) is retried.

components:
  securitySchemes:
    clientCredentials:
      type: oauth2
      description: |
        OAuth 2.0 client-credentials flow against the EQUOS ID token
        endpoint. `client_id`/`client_secret` come from an API key created in
        the subscriber console; secrets are rotatable and revocable there.
        Include `resource=https://api.equos.one` in the token request.
      flows:
        clientCredentials:
          tokenUrl: https://id.equos.one/oidc/token
          scopes:
            orders:read: Read orders, jobs and manufacturing tasks; also manage the key's own order.status_changed / shipment.* / job.* webhooks
            orders:write: Create orders; also read send orders (get, list, changes feed) and submission status, and manage the key's own order.status_changed / shipment.* / job.* webhooks
            orders:fulfil: Advance orders past draft (finalize, fulfil, commit) — `advance` on create and the per-order action routes
            inventory:read: Read inventory and stock levels; also manage the key's own inventory.* webhook subscriptions
            inventory:write: Declare on-hand quantities and upsert/archive catalog items; also read GET /e9v3/inventory/levels and /changes, and manage the key's own inventory.level_changed webhooks
            contacts:read: Read contacts and search addresses; export shopper personal data (POST /e9v3/contacts/export)
            contacts:write: Erase shopper personal data (POST /e9v3/contacts/erase and /erase/shop). Export (POST /e9v3/contacts/export) needs only contacts:read
            warehouse:read: Reserved
            warehouse:write: Reserved
            webhooks:manage: Manage all of the business's webhook subscriptions on /e9v3/webhooks, for every event (inventory:read, inventory:write, orders:read and orders:write manage only the key's own subscriptions for their events)
            billing:write: App-client subscription billing on https://equospayments.equosnine.com (/e9v3/billing — Checkout, portal, cancel / resume, status) for the app registered to the key's business

  parameters:
    WebhookSignature:
      name: X-Equos-Signature
      in: header
      required: true
      description: |
        `sha256=` + lowercase hex HMAC-SHA256 of the raw request body, keyed
        with the subscription `secret`. Recompute over the exact bytes received
        and compare in constant time.
      schema: { type: string, example: "sha256=5d2c…" }
    WebhookEvent:
      name: X-Equos-Event
      in: header
      required: true
      description: The event name (same as the envelope `event`).
      schema: { type: string }
    WebhookDelivery:
      name: X-Equos-Delivery
      in: header
      required: true
      description: The envelope `deliveryId`.
      schema: { type: string, format: uuid }
    ClientTimeZone:
      name: X-Client-Timezone
      in: header
      required: false
      description: |
        IANA time zone of the user or system the integration acts for (e.g.
        `Australia/Perth`). Used for date-based numbers the job generates
        (warehouse INB/OUT task numbers, clone dates, manufacture year). Stored
        on the job as `clientTimeZone`. When absent the platform uses
        `Australia/Brisbane`. An invalid name (not in the IANA database, or
        `Local`) returns 400 `Validation failed` with
        `details[0].field = "X-Client-Timezone"`.
      schema: { type: string, maxLength: 64, example: Australia/Perth }

  responses:
    JobAccepted:
      description: |
        Write queued. Returns HTTP 200 with a jobId; poll
        GET /e9v3/jobs/{jobId} for the outcome.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean }
              jobId:
                type: string
                description: Poll via GET /e9v3/jobs/{jobId}.
              duplicate:
                type: boolean
                description: |
                  True when this request matched an earlier one (same
                  `sourceRecordId` on /orders/send or /orders/receive, or an
                  order action already in flight). `jobId` is then the
                  EXISTING job and nothing new was created.
              status:
                type: string
                description: Existing job status (only when `duplicate` is true).
              orderId:
                type: string
                description: Existing order id (only when `duplicate` is true and the order exists).
              orderNumber:
                type: string
                description: Existing order number (only when `duplicate` is true and the order exists).
              error:
                type: string
                description: |
                  Failure message from the existing job or marker (only when
                  `duplicate` is true and the attempt failed or partially failed).
            required: [success, jobId]
          example:
            success: true
            jobId: inbound_1720664103218500000_a1b2c3d4e5f6a7b8
    ValidationFailed:
      description: Request failed validation.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
              details:
                type: array
                items: { $ref: "#/components/schemas/ValidationError" }
          example:
            success: false
            error: Validation failed
            details:
              - field: contact
                message: exactly one of customerId, supplierId, locationId, or agentId must be provided
              - field: items[0].quantity
                message: must be greater than 0
                value: 0
    Unauthorized:
      description: Missing/expired token, wrong audience, or missing scope.
    Forbidden:
      description: |
        The key lacks a required scope — e.g. `advance` or an order action
        without `orders:fulfil` — or is not bound to the requested business.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
          example:
            success: false
            error: "insufficient scope: orders:fulfil required"
    AdvanceNotEnabled:
      description: |
        Order advance (finalize / fulfil / commit) is not enabled on this
        server yet. Returned for any `advance` flag set to true on create and
        for the order action routes until worker-side support ships.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
          example:
            success: false
            error: order advance (finalize/fulfil/commit) is not enabled on this server yet
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
          example:
            success: false
            error: Order not found
    RateLimited:
      description: |
        Rate limit exceeded. Writes are limited per API key (default 1
        request/second, burst 10, shared by the order/inventory create routes).
        The order action routes (`/orders/{orderType}/{orderId}/{finalize,fulfil,commit}`)
        have their own separate budget (default 1/second, burst 5). Retry after
        `Retry-After` seconds.
    ShopperValidationFailed:
      description: Invalid shopper data request (no input, bad externalRef, email, phone, shop, limit or cursor).
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
              details:
                type: array
                items: { $ref: "#/components/schemas/ValidationError" }
          example:
            success: false
            error: Validation failed
            details:
              - field: externalRef
                message: one of externalRef, email or phone is required
    ShopperTooManyMatches:
      description: The inputs match more than 25 contacts; nothing was changed.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
              auditId: { type: string }
          example:
            success: false
            error: inputs match more than 25 contacts; narrow the request (e.g. externalRef only)
    ShopperAuditUnavailable:
      description: The audit log could not be written; nothing was read or changed. Retry later.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error: { type: string }
          example:
            success: false
            error: audit log unavailable
    NotModified:
      description: |
        The requested batch is unchanged since the ETag supplied in
        If-None-Match. No response body.
      headers:
        ETag:
          description: Strong validator for the current batch.
          schema: { type: string }
    AppBillingBadRequest:
      description: |
        Invalid request. `code` is `invalid_request` (body, `externalUserId`
        or `email`), `invalid_plan` (plan / cadence not offered by your app)
        or `invalid_redirect` (URL not on one of your app's origins).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: successUrl and cancelUrl must be on an allowed origin
            code: invalid_redirect
    AppBillingForbidden:
      description: |
        `insufficient_scope` (the token lacks `billing:write`), `not_app_client`
        (the key's business has no billing app) or `forbidden` (key revoked or
        expired).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: this API key's business has no billing app
            code: not_app_client
    AppBillingNoCustomer:
      description: The user has no billing customer in your app (`no_customer`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: no billing customer for this externalUserId
            code: no_customer
    AppBillingChangeConflict:
      description: The subscription cannot be changed here; see the operation description for codes.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: more than one subscription; use the billing portal
            code: not_cancelable
            usePortal: true
    AppBillingRateLimited:
      description: Too many changes for this user; retry after `Retry-After` seconds.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: too many attempts; try again shortly
            code: rate_limited
            retryAfterSeconds: 9
    AppBillingProviderError:
      description: Stripe was unavailable or rejected the call (`stripe_error`). Safe to retry.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: billing provider unavailable
            code: stripe_error
    AppBillingNotConfigured:
      description: App billing is not configured on this server (`not_configured`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AppBillingError" }
          example:
            error: app billing not configured
            code: not_configured

  schemas:
    ValidationError:
      type: object
      properties:
        field: { type: string }
        message: { type: string }
        value: {}

    InboundLineItem:
      type: object
      required: [sku, productName, quantity]
      properties:
        sku: { type: string, minLength: 1 }
        productName:
          type: string
          minLength: 1
          description: |
            Required. A blank or whitespace-only value is rejected with
            `items[i].productName: required field`.
        quantity: { type: number, exclusiveMinimum: 0 }
        metadata: { type: object, additionalProperties: true }
        lineId:
          type: string
          maxLength: 255
          description: |
            Optional. The integration's own id for this line (e.g. the
            Shopify line item id). Honoured by `POST /e9v3/orders/send-matched`
            only: it is stored on the order line (`externalLineId` on order
            reads) and reported as `lineId` on shipment lines. Must be unique
            within the request. Other endpoints ignore it.

    OrderAdvance:
      type: object
      description: |
        Optional. How far to take the order after it is created. Each flag is
        a separate choice and all default to false (draft only). `fulfil` and
        `commit` require `finalize: true`. Any true flag requires the
        `orders:fulfil` scope and a `sourceRecordId`. Steps run in the order
        finalize → fulfil → commit; see `JobStatusResponse.result.steps`.
      properties:
        finalize: { type: boolean, default: false, description: Finalize (reserve stock). }
        fulfil: { type: boolean, default: false, description: Create the warehouse task (send to warehouse). Requires finalize. }
        commit: { type: boolean, default: false, description: Commit stock movements. Requires finalize. }

    OrderAdvanceStepResult:
      type: object
      description: One entry of `result.steps[]` on a job that requested advance steps.
      required: [step, status]
      properties:
        step: { type: string, enum: [finalize, fulfil, commit] }
        status: { type: string, enum: [completed, failed, skipped] }
        errorMessage: { type: string }

    KeyInfoLocation:
      type: object
      required: [locationId, name, locationType, isDefault, active, link]
      properties:
        locationId: { type: string }
        name: { type: string }
        locationType: { type: string }
        isDefault: { type: boolean }
        active: { type: boolean }
        link:
          type: string
          enum: [owner, enabled, default]
          description: |
            How this warehouse is linked to the business (owner uid match,
            enabledBusinessIds, or portal defaultLocationId).
        address:
          $ref: "#/components/schemas/KeyInfoAddress"
    KeyInfoAddress:
      type: object
      description: |
        Warehouse address from the structured warehousedata fields. Empty fields
        are omitted. The whole object is omitted when the warehouse has no
        structured address (including a portal default with no warehouse record).
      properties:
        line1: { type: string, description: addressLine1 }
        line2: { type: string, description: addressLine2 }
        suburb: { type: string, description: Suburb or city }
        state: { type: string }
        postcode: { type: string }
        country: { type: string }
    KeyInfoBusiness:
      type: object
      required: [businessId, name, isHome, locations]
      properties:
        businessId: { type: string }
        name: { type: string }
        isHome: { type: boolean }
        defaultLocationId:
          type: [string, "null"]
          description: clientPortals default ship-from; null when the portal doc is missing.
        locations:
          type: array
          items: { $ref: "#/components/schemas/KeyInfoLocation" }
    KeyInfoResponse:
      type: object
      required: [success, keyId, clientId, name, scopes, multiBusiness, truncated, skipped, businesses]
      properties:
        success: { type: boolean }
        keyId: { type: string, description: integrations.api_keys.id }
        clientId: { type: string, description: EQUOS ID M2M client id (logto_app_id) }
        name: { type: string, description: Subscriber-visible API key name }
        scopes:
          type: array
          items: { type: string }
        multiBusiness:
          type: boolean
          description: True when the key covers more than one distinct business (home plus deduped extra_business_ids).
        truncated:
          type: boolean
          description: True when business or location caps were applied.
        skipped:
          type: integer
          minimum: 0
          description: Count of bound businesses omitted because the Firestore businessdata doc was missing.
        businesses:
          type: array
          items: { $ref: "#/components/schemas/KeyInfoBusiness" }

    OrderSyncRequest:
      type: object
      required: [orderType, items]
      description: |
        Exactly one contact field is required: `customerId`, `supplierId`,
        `locationId`, or `agentId`. Contact `locationId` is not the ship-from
        warehouse — use `shipFromLocationId` (or the portal default) for that.
      properties:
        orderType:
          type: string
          enum: [send, receive, returns, return, transfer]
          description: |
            Case-insensitive. Both `returns` and its alias `return` are accepted
            and canonicalized to `returns`, matching the read side
            (`GET /e9v3/orders`).
        customerId: { type: string, description: Use for send orders. }
        supplierId: { type: string, description: Use for receive orders. }
        locationId:
          type: string
          description: Contact identifier (not ship-from warehouse).
        agentId: { type: string }
        reference1:
          type: string
          description: External/PO reference only — not the platform order number (WO/PO/TO/RO).
        reference2: { type: string }
        reference3: { type: string }
        shipFromLocationId:
          type: string
          description: |
            Warehouse / ship-from location id. If omitted, the platform uses
            `clientPortals/{businessId}.defaultLocationId`. Required via one of
            those paths for DirectOrderCreate.
        orderDate: { type: string, format: date-time }
        dispatchDate: { type: string, format: date-time }
        items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/InboundLineItem" }
        notes: { type: string }
        sourceRecordId:
          type: string
          description: Your system's record id; retries deduplicate on it.
        metadata: { type: object, additionalProperties: true }

    SendOrderRequest:
      type: object
      required: [items]
      description: |
        Request body for `POST /e9v3/orders/send-matched`. Besides `items`,
        a contact must be identifiable: `customerId`, or at least one of
        `customerEmail` / `customerPhone` (plus `customerName` when there is
        no `customerId`). Otherwise the call fails with HTTP 400, error
        `Validation failed`, `details[].field` `contact` or `customerName`
        (see the send-matched operation for the exact bodies). This
        conditional rule is not expressible as `required`, so it is enforced
        server-side only.
      properties:
        customerId:
          type: string
          description: |
            Existing contact id. Optional on /orders/send-matched, where the
            customer fields below drive match-or-create. If omitted, at least
            one of `customerEmail` / `customerPhone` is required.
        customerEmail:
          type: string
          description: Customer email. When `customerId` is absent, at least one of email or phone is required.
        customerPhone:
          type: string
          description: Customer phone. When `customerId` is absent, at least one of email or phone is required.
        customerName:
          type: string
          description: Required when `customerId` is absent and an email or phone is given; used to create the contact if no match is found.
        customerBusinessName: { type: string }
        customerAddressLine1:
          type: string
          description: Street address (Flutter addressLine1). Recommended with suburb + postcode.
        customerAddressLine2:
          type: string
          description: Apt, suite, unit (optional; Flutter addressLine2).
        customerSuburb:
          type: string
          description: Suburb / city (Flutter suburb).
        customerState:
          type: string
          description: State / territory e.g. QLD, NSW (Flutter state).
        customerPostcode:
          type: string
          description: Postcode (Flutter postcode).
        externalCustomerId:
          type: string
          description: |
            External customer reference. Non-namespaced values use site match policy
            (business name + exact line1/suburb/postcode). Namespaced integration
            refs use `{provider}:{store}:customer:{externalId}` — Shopify:
            `shopify:{shop}.myshopify.com:customer:{numericId}`. Matching order
            for namespaced refs: external ref owner doc, then unique
            `emailNormalized`, else create (never auto-merge duplicate emails).
        reference1:
          type: string
          description: External/PO reference only — not the platform WO number.
        reference2: { type: string }
        reference3: { type: string }
        shipFromLocationId:
          type: string
          description: |
            Warehouse / ship-from location id. If omitted, uses the business
            portal default location. Create fails if neither is available.
        orderDate: { type: string, format: date-time }
        dispatchDate: { type: string, format: date-time }
        items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/InboundLineItem" }
        notes: { type: string }
        sourceRecordId:
          type: string
          description: |
            Your system's record id. Resubmitting the same value returns the
            existing job/order (`duplicate: true`). Required when `advance`
            requests any step.
        metadata: { type: object, additionalProperties: true }
        advance: { $ref: "#/components/schemas/OrderAdvance" }

    SendOrderByContactRequest:
      type: object
      required: [items]
      description: |
        Request body for `POST /e9v3/orders/send`. Exactly one contact field is
        required: `customerId`, `supplierId`, `locationId` or `agentId`
        (HTTP 400 otherwise, `details[].field` `contact`). That rule is not
        expressible as `required`, so it is enforced server-side only. Unlike
        `/orders/send-matched` (see `SendOrderRequest`) there are no inline
        customer fields and no match-or-create. `orderType` is not accepted:
        it is always `send`. Contact `locationId` is not the ship-from
        warehouse; use `shipFromLocationId` (or the portal default).
      properties:
        customerId: { type: string, description: Existing customer contact id. }
        supplierId: { type: string }
        locationId:
          type: string
          description: Contact identifier (not ship-from warehouse).
        agentId: { type: string }
        reference1:
          type: string
          description: External/PO reference only, not the platform WO number.
        reference2: { type: string }
        reference3: { type: string }
        shipFromLocationId:
          type: string
          description: |
            Warehouse / ship-from location id. If omitted, uses the business
            portal default location. The job fails if neither is available.
        orderDate: { type: string, format: date-time }
        dispatchDate: { type: string, format: date-time }
        items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/InboundLineItem" }
        notes: { type: string }
        sourceRecordId:
          type: string
          description: |
            Your system's record id. Resubmitting the same value returns the
            existing job/order (`duplicate: true`). Required when `advance`
            requests any step.
        metadata: { type: object, additionalProperties: true }
        advance: { $ref: "#/components/schemas/OrderAdvance" }

    ReceiveOrderRequest:
      type: object
      required: [items]
      properties:
        supplierId: { type: string }
        supplierEmail: { type: string }
        supplierPhone: { type: string }
        supplierName: { type: string }
        supplierBusinessName: { type: string }
        supplierAddressLine1:
          type: string
          description: Street address (Flutter addressLine1).
        supplierAddressLine2:
          type: string
          description: Apt, suite, unit (optional).
        supplierSuburb:
          type: string
          description: Suburb / city.
        supplierState:
          type: string
          description: State / territory e.g. QLD, NSW.
        supplierPostcode:
          type: string
          description: Postcode.
        externalSupplierId: { type: string }
        reference1:
          type: string
          description: External/PO reference only — not the platform PO number.
        reference2: { type: string }
        reference3: { type: string }
        shipFromLocationId:
          type: string
          description: |
            Warehouse location id. If omitted, uses the business portal default.
        orderDate: { type: string, format: date-time }
        dispatchDate: { type: string, format: date-time }
        items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/InboundLineItem" }
        notes: { type: string }
        sourceRecordId:
          type: string
          description: |
            Your system's record id. Resubmitting the same value returns the
            existing job/order (`duplicate: true`). Required when `advance`
            requests any step.
        metadata: { type: object, additionalProperties: true }
        advance: { $ref: "#/components/schemas/OrderAdvance" }

    InventorySyncRequest:
      type: object
      required: [sku, quantity]
      properties:
        sku: { type: string }
        quantity:
          type: integer
          description: Absolute on-hand quantity (not a delta).
        sourceRecordId: { type: string }
        metadata: { type: object, additionalProperties: true }

    CatalogItemUpsertRequest:
      type: object
      required: [sku]
      properties:
        sku:
          type: string
          maxLength: 256
        name: { type: string }
        variantOptions: { type: object, additionalProperties: true }
        barcode: { type: string }
        weightGrams: { type: number, minimum: 0 }
        lengthMm: { type: number, minimum: 0 }
        widthMm: { type: number, minimum: 0 }
        heightMm: { type: number, minimum: 0 }
        price: { type: number, minimum: 0 }
        currency:
          type: string
          minLength: 3
          maxLength: 3
        imageUrl: { type: string }
        active: { type: boolean }
        sourceRecordId:
          type: string
          description: |
            External record id (e.g. `shopify:{shop}:variant:{id}`). Upserts
            with the same `sourceRecordId` and SKU are idempotent.
        metadata: { type: object, additionalProperties: true }

    CatalogItemUpsertResponse:
      type: object
      properties:
        success: { type: boolean }
        itemId: { type: string }
        action:
          type: string
          enum: [created, updated]
        error: { type: string }

    CatalogItemBatchRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          maxItems: 50
          items: { $ref: "#/components/schemas/CatalogItemUpsertRequest" }

    CatalogItemBatchResult:
      type: object
      properties:
        sku: { type: string }
        success: { type: boolean }
        itemId: { type: string }
        action:
          type: string
          enum: [created, updated]
        error: { type: string }
        status: { type: integer }

    CatalogItemBatchResponse:
      type: object
      properties:
        success: { type: boolean }
        results:
          type: array
          items: { $ref: "#/components/schemas/CatalogItemBatchResult" }
        error: { type: string }

    CatalogItemArchiveResponse:
      type: object
      properties:
        success: { type: boolean }
        itemId: { type: string }
        error: { type: string }

    OrderSummary:
      type: object
      properties:
        orderId: { type: string }
        orderNumber: { type: string }
        orderType: { type: string }
        status: { type: string }
        contactName: { type: string }
        contactEmail: { type: string }
        locationName: { type: string }
        lineCount: { type: integer }
        totalItems: { type: integer }
        reference1: { type: string }
        reference2: { type: string }
        notes: { type: string }
        created: {}
        modified: {}
        orderDate: {}
        dueDate: {}
        metadata: { type: object, additionalProperties: true }
        stage:
          type: string
          enum: [Draft, Finalized, Committed, Processing, Fulfilled, Voided]
          description: |
            Workflow stage, from the order's flags (first match wins):
            `isOrderVoid` → Voided, `isFulfilled` → Fulfilled,
            `isProcessing` → Processing (a server job is running on the
            order), `isCommitSelected` → Committed, `isOrderFinalized` →
            Finalized, otherwise Draft. Same precedence as `status`, which
            keeps its lowercase values.
        shipFromLocationId:
          type: string
          description: Warehouse location the order ships from. Send orders only; omitted when unknown.
        shipments:
          type: array
          description: Freight consignments on the order. Always present; empty when none is linked.
          items: { $ref: "#/components/schemas/Shipment" }
        updatedAt:
          type: string
          format: date-time
          description: |
            Last change, RFC 3339 UTC: the order's `modified` timestamp, else
            the document update time. This is the changes-feed ordering key.

    Shipment:
      type: object
      description: One freight consignment on an order. All fields are always present.
      required: [shipmentId, carrierId, carrierName, trackingNumber, trackingUrl, status, despatchDate, deliveredAt, parcelCount, lines, linesKnown, linesSource]
      properties:
        shipmentId:
          type: string
          description: |
            Stable id of the consignment on its order, identical on order
            reads, the changes feed and webhooks: its consignmentId, else
            carrierConsignmentId, else the tracking number, else `idx:{n}`
            (array position). Repeated ids get a `#n` suffix (2, 3, ... in
            array order).
        carrierId: { type: string }
        carrierName: { type: string }
        trackingNumber: { type: string, description: Empty until the consignment is booked. }
        trackingUrl:
          type: [string, "null"]
          description: Equos public tracking page, `https://freight.equos.one/track/{trackingNumber}` (URL-escaped). Null without a tracking number.
        status:
          type: string
          description: |
            `cancelled` for a cancelled consignment, else the latest carrier
            tracking status (e.g. `in_transit`, `delivered`), else the
            booking status (`draft`, `booked`, `error`).
        despatchDate: { type: [string, "null"], format: date, description: "yyyy-MM-dd" }
        deliveredAt: { type: [string, "null"], format: date-time, description: Carrier-reported delivery time. }
        parcelCount: { type: integer }
        lines:
          type: array
          description: |
            Order lines in this consignment with their quantities. Always
            present. Filled only from per-line data recorded on the
            consignment (`linesKnown: true`); otherwise `[]`. An empty array
            with `linesKnown: false` means "not recorded", not "nothing
            shipped". Lines are never inferred from the order (not even for a
            single shipment on a fulfilled order), and a consignment whose
            line data cannot all be matched to order lines reports
            `linesKnown: false` rather than a partial list.
          items: { $ref: "#/components/schemas/ShipmentLine" }
        linesKnown:
          type: boolean
          description: True when `lines` comes from the consignment's per-line data. Always present.
        linesSource:
          type: string
          enum: [consignment, unknown]
          description: |
            Where `lines` came from: `consignment` (the consignment's own
            per-line data; `linesKnown` true) or `unknown` (none recorded;
            `lines` is empty). More values may be added; treat unrecognised
            values as `unknown`.

    ShipmentLine:
      type: object
      description: One order line in a shipment.
      required: [lineId, orderLineId, sku, quantity]
      properties:
        lineId:
          type: string
          description: |
            The line id the integration submitted for this order line
            (`items[].lineId` on `POST /e9v3/orders/send-matched`, e.g. the
            Shopify line item id). Empty string when the order line has none:
            orders created before line ids were stored, lines submitted
            without one, or lines added in the app. Use `orderLineId` then.
        orderLineId:
          type: string
          description: Equos order line id, the `lineId` of the order's `lines[]` on `GET /e9v3/orders/send/{orderId}`. Always set.
        sku: { type: string, description: The order line's SKU. }
        quantity: { type: number, description: Quantity of this order line in the shipment (greater than 0). }

    OrderLine:
      type: object
      properties:
        lineId: { type: string, description: Equos order line id. }
        externalLineId:
          type: string
          description: The line id the integration submitted (`items[].lineId` on send-matched). Omitted when the line has none. Shipment lines report it as `lineId`.
        sku: { type: string }
        productName: { type: string }
        quantity: { type: integer }
        availableQty: { type: integer }
        unitOfMeasure: { type: string }
        unitWeight: { type: number }
        notes: { type: string }

    OrdersListResponse:
      type: object
      properties:
        success: { type: boolean }
        orders:
          type: array
          items: { $ref: "#/components/schemas/OrderSummary" }
        count: { type: integer }
        error: { type: string }

    OrderChangesResponse:
      type: object
      properties:
        success: { type: boolean }
        orders:
          type: array
          items: { $ref: "#/components/schemas/OrderSummary" }
        count: { type: integer }
        nextCursor:
          type: string
          description: Opaque. Pass as `cursor` on the next call. Present on every successful page.
        hasMore: { type: boolean, description: True when more changes are available right now. }

    OrderDetailResponse:
      type: object
      properties:
        success: { type: boolean }
        order: { $ref: "#/components/schemas/OrderSummary" }
        lines:
          type: array
          items: { $ref: "#/components/schemas/OrderLine" }
        error: { type: string }

    OrderSubmissionStatusResponse:
      type: object
      properties:
        success: { type: boolean }
        jobId: { type: string }
        status:
          type: string
          enum: [pending, processing, completed, failed, partial, not_found]
        orderId: { type: string }
        orderNumber: { type: string }
        error: { type: string }
        updatedAt: {}

    OrderSubmissionLookupRequest:
      type: object
      required: [sourceRecordIds]
      properties:
        sourceRecordIds:
          type: array
          maxItems: 50
          items: { type: string }

    OrderSubmissionLookupResponse:
      type: object
      properties:
        success: { type: boolean }
        results:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/OrderSubmissionStatusResponse"
        error: { type: string }

    JobStatusResponse:
      type: object
      properties:
        success: { type: boolean }
        jobId: { type: string }
        status:
          type: string
          enum: [pending, processing, completed, partial, failed]
          description: |
            `partial`: the order was created but a requested advance step
            failed; the order stays at its last good state (see
            `result.steps`).
        processType: { type: string }
        errorMessage:
          type: string
          description: |
            Worker failure text from `error` or legacy `errorMessage` on the job
            doc, or from the submission marker when the job doc was purged.
        error:
          type: string
          description: |
            Request error text on `success: false` replies (missing jobId,
            invalid `wait`, load failure, not found). Worker failure text is
            `errorMessage`.
        createdAt: {}
        completedAt: {}
        result:
          type: object
          additionalProperties: true
          description: |
            Create jobs: `orderId`, `orderNumber`. Send-matched jobs also
            include `customerId` (and legacy `supplierId` with the same
            value). When advance steps were requested (on create or via an
            order action route) also `steps[]`, one `OrderAdvanceStepResult`
            per requested step in the order finalize → fulfil → commit.
          properties:
            orderId: { type: string }
            orderNumber: { type: string }
            customerId:
              type: string
              description: |
                Resolved customer contact id for send-matched
                (`InboundCustomerOrderCreate`) jobs. Optional — present after
                the worker completes contact match-or-create.
            matchedBy:
              type: string
              description: |
                send-matched only: `externalRef`, `email`, `created`, or
                `created_email_ambiguous` (namespaced externalCustomerId), or
                legacy site-policy values (`externalId`, `address`, `email`, …).
            isNewContact:
              type: boolean
              description: send-matched only; true when a new contact was created.
            steps:
              type: array
              items: { $ref: "#/components/schemas/OrderAdvanceStepResult" }

    Contact:
      type: object
      properties:
        contactId: { type: string }
        name: { type: string }
        businessName: { type: string }
        email: { type: string }
        phone: { type: string }
        contactType: { type: string }
        status: { type: string }
        addressStatus:
          type: string
          enum: [validated, postcode_only, invalid, unverified]
      additionalProperties: true

    ContactsListResponse:
      type: object
      properties:
        success: { type: boolean }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
        count: { type: integer }
        hasMore:
          type: boolean
          description: |
            True when another page is available. Additive — older clients
            may ignore it. Omitted on GET /e9v3/contacts/{contactId}.
        nextCursor:
          type: string
          description: |
            Pass as `cursor` on the next request. Omitted when hasMore is
            false and on GET /e9v3/contacts/{contactId}.
        error: { type: string }

    ContactDetailResponse:
      type: object
      description: |
        Single-contact lookup returns the same shape as the list: the contact is
        wrapped in a `contacts` array with `count: 1`. 404 body is
        `{success: false, error: "Contact not found", count: 0}`.
      properties:
        success: { type: boolean }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
        count: { type: integer }
        error: { type: string }

    ShopperDataRequest:
      type: object
      description: At least one of `externalRef`, `email` or `phone`.
      properties:
        externalRef:
          type: string
          maxLength: 512
          description: "`{provider}:{store}:customer:{id}`, e.g. `shopify:acme.myshopify.com:customer:7012345`."
        email: { type: string, maxLength: 320 }
        phone: { type: string, maxLength: 64 }
        requestId:
          type: string
          maxLength: 200
          description: Your id for the request (e.g. the Shopify webhook id). Stored in the audit log and echoed.
        reason:
          type: string
          maxLength: 100
          description: Short label for the audit log, e.g. `customers/redact`.
        dryRun:
          type: boolean
          default: false
          description: Erase only. Report what would change without writing.

    ShopperEraseShopRequest:
      type: object
      required: [shop]
      properties:
        shop:
          type: string
          description: The `{name}.myshopify.com` domain (case-insensitive).
        limit: { type: integer, minimum: 1, maximum: 100, default: 25 }
        cursor:
          type: string
          description: The previous call's `nextCursor`. Omit on the first call.
        requestId: { type: string, maxLength: 200 }
        reason: { type: string, maxLength: 100 }
        dryRun: { type: boolean, default: false }

    ShopperContactResult:
      type: object
      properties:
        contactId: { type: string }
        matchedBy:
          type: array
          items: { type: string, enum: [externalRef, email, phone] }
        status:
          type: string
          enum: [erased, already_erased, would_erase, unlinked, partial, failed]
          description: |
            `unlinked` (shop erase only): the contact is also linked to another
            shop, so only this shop's refs and orders were erased. `partial`:
            more orders remain — repeat the call. `failed`: see `error`, repeat
            the call.
        ordersMatched: { type: integer }
        ordersErased: { type: integer, description: Orders changed by this call. }
        ordersRemaining: { type: boolean }
        jobsScrubbed: { type: integer }
        jobsInFlight: { type: integer, description: Pending/processing jobs left untouched. }
        refsRemoved: { type: integer }
        error: { type: string }

    ShopperEraseTotals:
      type: object
      properties:
        contacts: { type: integer }
        ordersErased: { type: integer }
        jobsScrubbed: { type: integer }
        webhookLogsRedacted: { type: integer }
        shopifyDeliveriesRedacted: { type: integer }

    ShopperEraseResponse:
      type: object
      properties:
        success: { type: boolean }
        outcome:
          type: string
          enum: [erased, already_erased, no_match, would_erase, partial]
          description: "`partial`: some contact failed or has orders left, or a payload scrub failed — repeat the call."
        requestId: { type: string }
        auditId: { type: string }
        dryRun: { type: boolean }
        matched: { type: integer }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/ShopperContactResult" }
        totals: { $ref: "#/components/schemas/ShopperEraseTotals" }
        warnings:
          type: array
          items: { type: string }
        error: { type: string }

    ShopperEraseShopResponse:
      type: object
      properties:
        success: { type: boolean }
        outcome:
          type: string
          enum: [erased, no_match, would_erase, partial]
        shop: { type: string }
        requestId: { type: string }
        auditId: { type: string }
        dryRun: { type: boolean }
        processed: { type: integer }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/ShopperContactResult" }
        totals: { $ref: "#/components/schemas/ShopperEraseTotals" }
        complete:
          type: boolean
          description: True when no contact of the shop is left. Otherwise call again with `nextCursor`.
        nextCursor: { type: string }
        warnings:
          type: array
          items: { type: string }
        error: { type: string }

    ShopperExportOrderLine:
      type: object
      properties:
        lineId: { type: string }
        sku: { type: string }
        productName: { type: string }
        quantity: {}
        unitPrice: {}

    ShopperExportOrder:
      type: object
      properties:
        orderId: { type: string }
        orderType: { type: string, enum: [send, receive, transfer, returns] }
        archived: { type: boolean }
        role: { type: string, enum: [customer, supplier] }
        orderNumber: { type: string }
        status: { type: string }
        stage: { type: string }
        reference1: { type: string }
        created: {}
        orderDate: {}
        lineCount: { type: integer }
        totalItems: { type: integer }
        personalData:
          type: object
          additionalProperties: true
          description: |
            The party's personal fields as stored on the order (e.g.
            `customerName`, `customerDetails`, `deliveryAddress`, receiver
            details of `freightConsignments`, `importedNotes`). Empty after
            erasure.
        lines:
          type: array
          items: { $ref: "#/components/schemas/ShopperExportOrderLine" }
        erasedAt: {}

    ShopperExportContact:
      type: object
      properties:
        contactId: { type: string }
        matchedBy:
          type: array
          items: { type: string, enum: [externalRef, email, phone] }
        status: { type: string, enum: [active, archived, erased] }
        contact: { $ref: "#/components/schemas/Contact" }
        externalRefs:
          type: array
          items: { type: string }
        erasedAt: {}
        ordersIncluded:
          type: boolean
          description: False when the key has neither `orders:read` nor `orders:write`.
        orderTypes:
          type: array
          items: { type: string }
        orders:
          type: array
          items: { $ref: "#/components/schemas/ShopperExportOrder" }
        ordersTruncated:
          type: boolean
          description: More than 200 orders; the first 200 are returned.

    ShopperExportResponse:
      type: object
      properties:
        success: { type: boolean }
        requestId: { type: string }
        auditId: { type: string }
        generatedAt: { type: string, format: date-time }
        count: { type: integer }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/ShopperExportContact" }
        warnings:
          type: array
          items: { type: string }
        error: { type: string }

    InventoryItem:
      type: object
      properties:
        itemId: { type: string }
        businessId: { type: string }
        sku: { type: string }
        productName: { type: string }
        description: { type: string }
        category: { type: string }
        brand: { type: string }
        barcode: { type: string }
        imageUrl: { type: string }
        availableQuantity: { type: integer }
        totalQuantity: { type: integer }
        reservedQuantity: { type: integer }
        orderedQuantity: { type: integer }
        returnsQuantity: { type: integer }
        unitOfMeasure: { type: string }
        isActive: { type: boolean }
        isComposite: { type: boolean }
        stockStatus:
          type: string
          description: |
            Derived from availableQuantity vs totalQuantity: inStock,
            lowStock (<=25%), critical (<=10%), outOfStock.
          enum: [inStock, lowStock, critical, outOfStock]
        stockPending:
          type: boolean
          description: |
            List only. True when the business has a StockIndex but this
            product has no entry yet; quantities are then the catalog
            values. Omitted otherwise.
        metadata: { type: object, additionalProperties: true }
      additionalProperties: true

    InventoryListResponse:
      type: object
      properties:
        success: { type: boolean }
        inventory:
          type: array
          items: { $ref: "#/components/schemas/InventoryItem" }
        count: { type: integer }
        hasMore:
          type: boolean
          description: |
            True when another page is available. Additive — older clients
            may ignore it. Omitted on GET /e9v3/inventory/{itemId}.
        nextCursor:
          type: string
          description: |
            Pass as `cursor` on the next request. Omitted when hasMore is
            false and on GET /e9v3/inventory/{itemId}.
        error: { type: string }

    InventoryItemResponse:
      type: object
      description: |
        Single-item lookup returns the same shape as the list: the item is
        wrapped in an `inventory` array with `count: 1`.
      properties:
        success: { type: boolean }
        inventory:
          type: array
          items: { $ref: "#/components/schemas/InventoryItem" }
        count: { type: integer }
        error: { type: string }

    InventoryLevel:
      type: object
      properties:
        sku: { type: string }
        itemId: { type: string }
        locationId: { type: string }
        available: { type: integer }
        onHand: { type: integer }
        reserved: { type: integer }
        updatedAt: { type: string, format: date-time }

    InventoryLevelsResponse:
      type: object
      properties:
        success: { type: boolean }
        levels:
          type: array
          items: { $ref: "#/components/schemas/InventoryLevel" }
        nextCursor: { type: string }
        asOf: { type: string, format: date-time }
        error: { type: string }

    InventoryLevelChange:
      type: object
      properties:
        sku: { type: string }
        locationId: { type: string }
        available: { type: integer }
        onHand: { type: integer }
        updatedAt: { type: string, format: date-time }
        seq: { type: integer, format: int64 }

    InventoryChangesResponse:
      type: object
      properties:
        success: { type: boolean }
        changes:
          type: array
          items: { $ref: "#/components/schemas/InventoryLevelChange" }
        nextCursor: { type: string }
        error: { type: string }

    E9v3WebhookCreateRequest:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri, description: Must be https. }
        events:
          type: array
          items:
            type: string
            enum:
              - order.created
              - order.updated
              - order.status_changed
              - order.deleted
              - inventory.adjusted
              - inventory.low_stock
              - inventory.transfer
              - inventory.level_changed
              - contact.created
              - contact.updated
              - shipment.created
              - shipment.status_changed
              - shipment.delivered
              - payment.status_changed
              - catalog.published
              - prices.published
              - job.completed
              - job.failed
              - subscription.activated
              - subscription.renewed
              - subscription.updated
              - subscription.canceled
              - subscription.payment_failed
        description: { type: string }

    E9v3WebhookSubscription:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events:
          type: array
          items: { type: string }
        isActive: { type: boolean }
        description: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        payloadVersion:
          type: integer
          enum: [1, 2]
          description: |
            2 for subscriptions created on /e9v3/webhooks: order.status_changed
            and shipment.* carry the payloads in the top-level `webhooks`
            section. 1 for console-created subscriptions, which keep the legacy
            job-based payload for those event names.

    E9v3WebhookListResponse:
      type: object
      properties:
        success: { type: boolean }
        webhooks:
          type: array
          items: { $ref: "#/components/schemas/E9v3WebhookSubscription" }

    E9v3WebhookCreateResponse:
      allOf:
        - $ref: "#/components/schemas/E9v3WebhookSubscription"
        - type: object
          properties:
            secret: { type: string }

    E9v3WebhookResponse:
      type: object
      properties:
        success: { type: boolean }
        webhook: { $ref: "#/components/schemas/E9v3WebhookSubscription" }

    E9v3WebhookUpdateRequest:
      type: object
      description: Omitted fields keep their value. At least one field is required.
      properties:
        url: { type: string, format: uri, description: Must be https. }
        events:
          type: array
          description: Replaces the event list; same values and scope rules as on create.
          items: { type: string }
        description: { type: string }
        isActive: { type: boolean }

    InventoryLevelChangedWebhook:
      type: object
      description: Delivery envelope of `inventory.level_changed`.
      required: [event, data, timestamp, deliveryId]
      properties:
        event: { type: string, enum: [inventory.level_changed] }
        data: { $ref: "#/components/schemas/InventoryLevelChangedWebhookData" }
        timestamp: { type: string, format: date-time, description: Send time of this attempt. }
        deliveryId: { type: string, format: uuid, description: Random per delivery job. De-duplicate on `data.seq`. }

    InventoryLevelChangedWebhookData:
      type: object
      required: [businessId, locationId, itemId, sku, available, onHand, reserved, updatedAt, seq]
      properties:
        businessId: { type: string }
        locationId: { type: string, description: Warehouse id (as in `GET /e9v3/key-info`). }
        itemId: { type: string, description: Inventory item id (parent item; variants roll up). }
        sku: { type: string }
        available: { type: integer, description: "max(0, onHand - reserved). Push this to sales channels." }
        onHand: { type: integer }
        reserved: { type: integer, description: "Business-wide reservations, charged to every location." }
        updatedAt: { type: string, format: date-time }
        seq: { type: integer, format: int64, description: Same `seq` as the `/e9v3/inventory/changes` row. }

    WebhookShipment:
      type: object
      description: |
        A freight consignment in an order/shipment webhook. Same fields and
        values as `Shipment` on the order reads (same `shipmentId`, same
        `lines`). All fields are always present.
      required: [shipmentId, carrierId, carrierName, trackingNumber, trackingUrl, status, despatchDate, deliveredAt, parcelCount, lines, linesKnown, linesSource]
      properties:
        shipmentId:
          type: string
          description: |
            Stable id of the consignment on its order, identical on order
            reads, the changes feed and webhooks: its consignmentId, else
            carrierConsignmentId, else the tracking number, else `idx:{n}`
            (array position). Repeated ids get a `#n` suffix (2, 3, ... in
            array order).
        carrierId: { type: string }
        carrierName: { type: string }
        trackingNumber: { type: string, description: Empty until the consignment is booked. }
        trackingUrl:
          type: [string, "null"]
          description: Equos public tracking page, `https://freight.equos.one/track/{trackingNumber}` (URL-escaped). Null without a tracking number.
        status:
          type: string
          description: |
            `cancelled` for a cancelled consignment, else the latest carrier
            tracking status (e.g. `in_transit`, `delivered`), else the
            booking status (`draft`, `booked`, `error`).
        despatchDate: { type: [string, "null"], format: date, description: "yyyy-MM-dd" }
        deliveredAt: { type: [string, "null"], format: date-time, description: Carrier-reported delivery time. }
        parcelCount: { type: integer }
        lines:
          type: array
          description: |
            Order lines in this consignment with their quantities. Always
            present. Filled only from per-line data recorded on the
            consignment (`linesKnown: true`); otherwise `[]`. An empty array
            with `linesKnown: false` means "not recorded", not "nothing
            shipped". Lines are never inferred from the order (not even for a
            single shipment on a fulfilled order), and a consignment whose
            line data cannot all be matched to order lines reports
            `linesKnown: false` rather than a partial list.
          items: { $ref: "#/components/schemas/ShipmentLine" }
        linesKnown:
          type: boolean
          description: True when `lines` comes from the consignment's per-line data. Always present.
        linesSource:
          type: string
          enum: [consignment, unknown]
          description: |
            Where `lines` came from: `consignment` (the consignment's own
            per-line data; `linesKnown` true) or `unknown` (none recorded;
            `lines` is empty). More values may be added; treat unrecognised
            values as `unknown`.

    OrderStatusChangedWebhookData:
      type: object
      description: |
        `data` of `order.status_changed` (payload version 2). Fired when a send
        order's `stage` changes. Voiding the order is the transition to
        `Voided`.
      required: [businessId, orderId, orderType, orderNumber, source, sourceRecordId, reference1, previousStage, stage, shipFromLocationId, shipments, updatedAt]
      properties:
        businessId: { type: string }
        orderId: { type: string }
        orderType: { type: string, enum: [send] }
        orderNumber: { type: string }
        source: { type: [string, "null"], description: "The order's source, e.g. `shopify` or `api`." }
        sourceRecordId: { type: [string, "null"], description: "The integration's record id the order was created with (`sourceRecordId` on create)." }
        reference1: { type: [string, "null"], description: "The integration's order name/reference (`reference1` on create)." }
        previousStage:
          type: [string, "null"]
          enum: [Draft, Finalized, Committed, Processing, Fulfilled, Voided, null]
          description: Null when the order was first seen already past Draft (created within the last 48 hours).
        stage:
          type: string
          enum: [Draft, Finalized, Committed, Processing, Fulfilled, Voided]
          description: Same mapping as `OrderSummary.stage`.
        shipFromLocationId: { type: [string, "null"] }
        shipments:
          type: array
          description: All current consignments on the order. Empty when none.
          items: { $ref: "#/components/schemas/WebhookShipment" }
        updatedAt:
          type: string
          format: date-time
          description: Same as `OrderSummary.updatedAt` (the order's `modified`, else the document update time).

    ShipmentWebhookData:
      type: object
      description: |
        `data` of `shipment.created`, `shipment.status_changed` and
        `shipment.delivered` (payload version 2).

        - `shipment.created`: a consignment first has a tracking number and is
          not cancelled.
        - `shipment.status_changed`: a created shipment's `status` or
          `trackingNumber` changed, including to `cancelled`. Carries
          `previousStatus` and `previousTrackingNumber`.
        - `shipment.delivered`: a created shipment's status first becomes
          `delivered`. Fires once per shipment and is sent INSTEAD of
          `shipment.status_changed` for that transition. A shipment that is
          already delivered when created sends `shipment.created` then
          `shipment.delivered`.

        A change to a shipment's `lines` alone (including `linesKnown`
        turning true) sends no event; the next event, or the order reads,
        carry the current lines.
      required: [businessId, orderId, orderType, orderNumber, source, sourceRecordId, reference1, stage, shipFromLocationId, shipment, shipments, updatedAt]
      properties:
        businessId: { type: string }
        orderId: { type: string }
        orderType: { type: string, enum: [send] }
        orderNumber: { type: string }
        source: { type: [string, "null"] }
        sourceRecordId: { type: [string, "null"] }
        reference1: { type: [string, "null"] }
        stage:
          type: string
          enum: [Draft, Finalized, Committed, Processing, Fulfilled, Voided]
        shipFromLocationId: { type: [string, "null"] }
        shipment: { $ref: "#/components/schemas/WebhookShipment" }
        shipments:
          type: array
          description: All current consignments on the order, including this one.
          items: { $ref: "#/components/schemas/WebhookShipment" }
        previousStatus: { type: string, description: "`shipment.status_changed` only." }
        previousTrackingNumber: { type: string, description: "`shipment.status_changed` only." }
        updatedAt: { type: string, format: date-time }

    OrderStatusChangedWebhook:
      type: object
      description: Delivery envelope of `order.status_changed`.
      required: [event, data, timestamp, deliveryId]
      properties:
        event: { type: string, enum: [order.status_changed] }
        data: { $ref: "#/components/schemas/OrderStatusChangedWebhookData" }
        timestamp: { type: string, format: date-time, description: Send time of this attempt. }
        deliveryId:
          type: string
          format: uuid
          description: |
            Deterministic per logical event (order, shipment, transition and
            the order's change counter). A re-sent event keeps the same id;
            the same transition happening again later gets a new one.
            De-duplicate on it.

    ShipmentWebhook:
      type: object
      description: Delivery envelope of `shipment.created`, `shipment.status_changed` and `shipment.delivered`.
      required: [event, data, timestamp, deliveryId]
      properties:
        event: { type: string, enum: [shipment.created, shipment.status_changed, shipment.delivered] }
        data: { $ref: "#/components/schemas/ShipmentWebhookData" }
        timestamp: { type: string, format: date-time }
        deliveryId: { type: string, format: uuid, description: Deterministic per logical event; see `OrderStatusChangedWebhook.deliveryId`. }

    SubscriptionWebhook:
      type: object
      description: Delivery envelope of the `subscription.*` events.
      required: [event, data, timestamp, deliveryId]
      properties:
        event: { type: string, enum: [subscription.activated, subscription.renewed, subscription.updated, subscription.canceled, subscription.payment_failed] }
        data: { $ref: "#/components/schemas/SubscriptionWebhookData" }
        timestamp: { type: string, format: date-time }
        deliveryId: { type: string, format: uuid }

    SubscriptionWebhookData:
      type: object
      required: [appId, externalUserId, customerId, subscriptionId, status, plan, cadence, currentPeriodEnd, cancelAtPeriodEnd, stripeEventId, eventCreated]
      properties:
        appId: { type: string }
        externalUserId: { type: string }
        customerId: { type: string }
        subscriptionId: { type: string }
        status: { type: string, description: "Stripe subscription status, or `paused` while collection is paused." }
        plan: { type: string }
        cadence: { type: string, description: monthly or annual. }
        currentPeriodEnd: { type: [string, "null"], format: date-time }
        cancelAtPeriodEnd: { type: boolean }
        invoiceId: { type: string, description: Invoice events only. }
        amount: { type: integer, description: "Invoice events only. Amount in the smallest currency unit (cents)." }
        currency: { type: string, description: Invoice events only. Lowercase ISO code. }
        hostedInvoiceUrl: { type: string, description: Invoice events only. }
        stripeEventId: { type: string, description: The Stripe event this delivery came from; de-duplicate on it. }
        eventCreated: { type: string, format: date-time, description: Stripe event creation time. }

    JobWebhook:
      type: object
      description: Delivery envelope of `job.completed` and `job.failed`.
      required: [event, data, timestamp, deliveryId]
      properties:
        event: { type: string, enum: [job.completed, job.failed] }
        data: { $ref: "#/components/schemas/JobWebhookData" }
        timestamp: { type: string, format: date-time }
        deliveryId:
          type: string
          format: uuid
          description: |
            Deterministic per settlement (job, status, settle time): a re-sent
            event keeps the same id; the same `jobId` settling again later
            (a retried submission) gets a new one. De-duplicate on it.

    JobWebhookData:
      type: object
      description: |
        `data` of `job.completed` / `job.failed`. Nullable fields are null,
        never omitted.
      required: [jobId, businessId, processType, status, errorMessage, sourceRecordId, orderId, orderNumber, orderType, result, completedAt]
      properties:
        jobId: { type: string, description: The `jobId` the write returned. }
        businessId: { type: string }
        processType: { type: string, example: InboundCustomerOrderCreate }
        status:
          type: string
          enum: [completed, partial, failed]
          description: "`completed` / `partial` on `job.completed`, `failed` on `job.failed`."
        errorMessage: { type: [string, "null"] }
        sourceRecordId: { type: [string, "null"], description: "The `sourceRecordId` submitted with the write." }
        orderId: { type: [string, "null"] }
        orderNumber: { type: [string, "null"] }
        orderType: { type: [string, "null"] }
        result:
          type: [object, "null"]
          additionalProperties: true
          description: "The job's `result`, as `GET /e9v3/jobs/{jobId}` returns it."
        completedAt: { type: [string, "null"], format: date-time, description: When the job settled. }
      example:
        jobId: send_matched_5f2c9e0d4b7a1c3e
        businessId: biz_123
        processType: InboundCustomerOrderCreate
        status: completed
        errorMessage: null
        sourceRecordId: 27-15313-03998
        orderId: ord_7c1e9a3f5b
        orderNumber: null
        orderType: send
        result: { orderId: ord_7c1e9a3f5b, customerId: cus_42, supplierId: cus_42 }
        completedAt: "2026-10-27T01:02:03Z"

    CatalogVersion:
      type: object
      description: Identifies the published catalog snapshot this batch belongs to.
      properties:
        publishedAt: { type: string, format: date-time }
        itemCount: { type: integer }
        batchCount: { type: integer }

    CatalogPricing:
      type: object
      description: |
        Present only when a published feed price tier is merged into the feed.
      properties:
        tierId: { type: string }
        version: { type: integer }
        currency: { type: string, description: Always "AUD". }

    CatalogItem:
      type: object
      properties:
        productId: { type: string }
        sku: { type: string, description: Parent SKU. }
        variantSku:
          type: string
          description: Orderable variant SKU. Omitted when the row has no variant.
        name: { type: string, description: Parent product name. }
        variantName:
          type: string
          description: Variant name. Omitted when the row has no variant.
        barcode: { type: string }
        category: { type: string }
        uom: { type: string }
        stockStatus:
          type: string
          description: |
            Catalog feed stock status. Note these differ from the Inventory
            API's stockStatus values.
          enum: [in_stock, low, out]
        price:
          type: number
          description: |
            Ex-GST unit price for the effective SKU (variantSku ?? sku). Present
            only when a feed price tier is merged and prices this SKU.
        gst:
          type: boolean
          description: Whether GST applies. Present only alongside price.

    CatalogResponse:
      type: object
      properties:
        success: { type: boolean }
        version: { $ref: "#/components/schemas/CatalogVersion" }
        batch: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/CatalogItem" }
        pricing: { $ref: "#/components/schemas/CatalogPricing" }
        error: { type: string }

    ManufacturingTask:
      type: object
      additionalProperties: true

    ManufacturingListResponse:
      type: object
      properties:
        success: { type: boolean }
        tasks:
          type: array
          items: { $ref: "#/components/schemas/ManufacturingTask" }
        count: { type: integer }
        error: { type: string }

    ManufacturingTaskResponse:
      type: object
      description: |
        Single-task lookup returns the same shape as the list: the task is
        wrapped in a `tasks` array with `count: 1`.
      properties:
        success: { type: boolean }
        tasks:
          type: array
          items: { $ref: "#/components/schemas/ManufacturingTask" }
        count: { type: integer }
        error: { type: string }

    AppCheckoutSessionRequest:
      type: object
      required: [plan, cadence, externalUserId, successUrl, cancelUrl]
      properties:
        plan:
          type: string
          description: A plan your app offers (e.g. `writer`).
        cadence:
          type: string
          enum: [monthly, annual]
        externalUserId:
          type: string
          maxLength: 200
          description: Your app's stable id for the user (no control characters).
        email:
          type: string
          format: email
          description: Used only when the user's Stripe customer is created.
        successUrl:
          type: string
          format: uri
          description: Must be on one of your app's origins. Stripe's `{CHECKOUT_SESSION_ID}` placeholder is allowed.
        cancelUrl:
          type: string
          format: uri
          description: Must be on one of your app's origins.

    AppCheckoutSessionResponse:
      type: object
      required: [url, sessionId, customerId]
      properties:
        url: { type: string, format: uri, description: Stripe Checkout URL. }
        sessionId: { type: string }
        customerId: { type: string, description: The user's Stripe customer. }

    AppPortalSessionRequest:
      type: object
      required: [externalUserId, returnUrl]
      properties:
        externalUserId: { type: string, maxLength: 200 }
        returnUrl: { type: string, format: uri, description: Must be on one of your app's origins. }

    AppPortalSessionResponse:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }

    AppSubscriptionChangeRequest:
      type: object
      required: [externalUserId]
      properties:
        externalUserId: { type: string, maxLength: 200 }

    AppSubscriptionChangeResponse:
      type: object
      required: [changed, status, cancelAtPeriodEnd, accessUntil]
      properties:
        changed: { type: boolean, description: False when the call was a no-op. }
        status:
          type: string
          description: Stripe subscription status (e.g. active, trialing).
        cancelAtPeriodEnd: { type: boolean }
        accessUntil:
          type: [string, "null"]
          format: date-time
          description: Trial end while trialing, otherwise the current period end.

    AppSubscription:
      type: object
      required: [appId, externalUserId, customerId, subscriptionId, status, plan, cadence, currentPeriodEnd, cancelAtPeriodEnd]
      properties:
        appId: { type: string }
        externalUserId: { type: string }
        customerId: { type: string }
        subscriptionId: { type: string }
        status:
          type: string
          description: Stripe subscription status (active, trialing, past_due, unpaid, incomplete, canceled, …), or `paused` while collection is paused.
        plan: { type: string }
        cadence: { type: string, description: monthly or annual. }
        currentPeriodEnd: { type: [string, "null"], format: date-time }
        cancelAtPeriodEnd: { type: boolean }

    AppBillingError:
      type: object
      required: [error, code]
      properties:
        error: { type: string }
        code: { type: string }
        usePortal: { type: boolean, description: Present and true when the change must be made in the Customer Portal. }
        retryAfterSeconds: { type: integer }
