openapi: "3.1.0"

info:
  title: NetXL Customer API
  version: "1.0"
  description: |
    The NetXL customer API provides programmatic access to the NetXL product catalogue,
    ordering, quoting and account management.

    ## Authentication
    Every endpoint (except `/check-health`, `/oauth/token` and the OpenAPI documents) requires
    either a 64-character API key, supplied in the `X-Api-Key` header (preferred) or the `api-key`
    query parameter, or a bearer token in the `Authorization` header. Requests with a missing or
    invalid credential receive `401` with an empty JSON body.

    An API key is the customer themselves, and is the right choice for a customer's own integration.
    A bearer token is an application acting for a customer who authorised it at www.netxl.com — the
    NetXL MCP server, for instance — and is obtained from `POST /oauth/token`. The two confer the
    same access; nothing in this API can tell them apart.

    Repeatedly presenting invalid credentials (10 within 60 seconds) blocks the source IP address
    for 5 minutes; blocked requests receive `403`. If the authentication backend is
    unavailable, requests receive `503`.

    ## Dates
    All timestamps are UK local time in the format `yyyy-MM-dd'T'HH:mm:ss`
    (e.g. `2026-01-15T10:30:00`). No timezone offset is included.

    ## Content negotiation
    `GET /product` and `GET /order` can return JSON or CSV. The format is chosen from the
    `Accept` header, or the `accept` query parameter as an override. If no `Accept` header is
    sent, the response defaults to **CSV**; `Accept: text/html` is also treated as CSV.
    Send `Accept: application/json` for JSON.
  contact:
    name: NetXL
    url: https://www.netxl.com

servers:
  - url: https://api.netxl.com

security:
  - ApiKeyHeader: []
  - ApiKeyQuery: []
  - BearerToken: []

tags:
  - name: Products
    description: Product catalogue with customer-specific pricing and stock
  - name: Orders
    description: Order creation, confirmation and retrieval
  - name: Quotes
    description: Quote creation
  - name: Customer
    description: Account details, credit and address book
  - name: Reference Data
    description: Categories, manufacturers and product attributes
  - name: Authentication
    description: Token exchange for applications acting on a customer's behalf
  - name: Health
    description: Service health
  - name: Documentation
    description: This specification

paths:

  /:
    get:
      tags: [Documentation]
      operationId: getApiIndex
      summary: API index
      description: Returns links to this specification.
      security: []
      responses:
        "200":
          description: Links to the API documentation.
          headers:
            Link:
              description: RFC 8631 `service-desc` links to the OpenAPI documents.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                  documentation:
                    type: object
                    properties:
                      openapi_json:
                        type: string
                      openapi_yaml:
                        type: string

  /product:
    get:
      tags: [Products]
      operationId: listProducts
      summary: List products
      description: |
        Returns active products with pricing and stock specific to the authenticated
        customer. Returns CSV by default when no `Accept` header is sent — use
        `Accept: application/json` (or `?accept=application/json`) for JSON.

        JSON results are paginated: without a `limit` you get the first 100 products, and the
        `X-Total-*` and `Link` response headers say where in the catalogue you are. Pass
        `limit=0` to fetch everything at once.

        CSV is the exception. It is the catalogue feed, and it returns the **whole catalogue**
        when no `limit` is sent, exactly as it did before paging existed. Send a `limit` to page
        it.

        Prices and stock are cached per customer for up to 5 minutes, so a stock level may
        lag a sale by that much.
      parameters:
        - name: manufacturer
          in: query
          description: Comma-separated list of manufacturer keys to filter by.
          schema:
            type: string
          example: yealink,poly
        - name: category
          in: query
          description: Comma-separated list of category names to filter by.
          schema:
            type: string
        - name: query
          in: query
          description: Keyword filter matched against product details.
          schema:
            type: string
        - name: attribute
          in: query
          description: |
            Comma-separated list of attribute filters in the form `name<operator>value`,
            where the operator is one of `=`, `<`, `>`, `<=`, `>=` (e.g. `ports>=4`).
          schema:
            type: string
          example: ports>=4
        - name: reduced
          in: query
          description: Set to `true` to return only reduced (refurbished) products, `false` to exclude them.
          schema:
            type: string
            enum: ["true", "false"]
        - name: inStock
          in: query
          description: Set to `true` to return only products that are currently in stock.
          schema:
            type: string
            enum: ["true"]
        - name: sort
          in: query
          description: Sort order for the results. Any other value returns `400`.
          schema:
            type: string
            enum: [price, availability, sku, name]
        - name: extra
          in: query
          description: |
            Comma-separated list of additional fields to include, e.g. `description`,
            `related`, `similar`.
          schema:
            type: string
          example: description,related
        - name: accept
          in: query
          description: Overrides content negotiation, e.g. `text/csv` or `application/json`.
          schema:
            type: string
            enum: [application/json, text/csv]
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of the matching products.
          headers:
            X-Total-Count:
              $ref: "#/components/headers/XTotalCount"
            X-Page:
              $ref: "#/components/headers/XPage"
            X-Limit:
              $ref: "#/components/headers/XLimit"
            X-Total-Pages:
              $ref: "#/components/headers/XTotalPages"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Product"
            text/csv:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /product/{sku}:
    get:
      tags: [Products]
      operationId: getProduct
      summary: Get a product
      description: |
        Returns a single product by SKU. The `description` and `short_description` fields
        are always populated on this endpoint. Always returns JSON.
      parameters:
        - name: sku
          in: path
          required: true
          description: The product SKU (case-insensitive).
          schema:
            type: string
        - name: extra
          in: query
          description: Comma-separated list of additional fields to include, e.g. `related`, `similar`.
          schema:
            type: string
      responses:
        "200":
          description: The product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /order:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      description: |
        Returns the authenticated customer's orders (open and completed order statuses only),
        newest first. Returns CSV by default when no `Accept` header is sent — use
        `Accept: application/json` (or `?accept=application/json`) for JSON.

        Results are paginated: without a `limit` you get the 100 most recent orders, and the
        `X-Total-*` and `Link` response headers say where in the history you are. Pass `limit=0`
        to fetch every order at once.

        This applies to **CSV as well as JSON** — unlike `GET /product`, there is no unpaginated
        CSV feed here, so a CSV client that wants the whole history must send `limit=0`.

        **Breaking change.** Orders were previously returned in no particular order (in practice,
        oldest first) and are now explicitly newest first, which paging requires. A client that
        reads the last row as the most recent order must be updated.
      parameters:
        - name: accept
          in: query
          description: Overrides content negotiation, e.g. `text/csv` or `application/json`.
          schema:
            type: string
            enum: [application/json, text/csv]
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of the customer's orders, newest first.
          headers:
            X-Total-Count:
              $ref: "#/components/headers/XTotalCount"
            X-Page:
              $ref: "#/components/headers/XPage"
            X-Limit:
              $ref: "#/components/headers/XLimit"
            X-Total-Pages:
              $ref: "#/components/headers/XTotalPages"
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Order"
            text/csv:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create an order
      description: |
        Validates and prices an order, returning the available shipping options and a
        `confirmation_code`. The order is held as *pending* until confirmed with
        `PUT /order/{uuid}` (using the `confirmation_code` as the UUID), or immediately
        confirmed when `auto_confirm` is `true`. Pending orders expire after 24 hours.

        When `auto_confirm` is `true`, either `shipping_method` must be supplied or the
        account must have a default shipping method, otherwise `412` is returned.

        Send an `Idempotency-Key` header to make retries safe — see the header description below.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
      responses:
        "201":
          description: The validated order, enriched with pricing, shipping options and a confirmation code.
          headers:
            Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateOrderRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "500":
          $ref: "#/components/responses/ServerError"

  /order/{reference}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Get an order
      description: Returns a single order, including line items, shipping and tracking events.
      parameters:
        - name: reference
          in: path
          required: true
          description: The numeric order id or the order reference.
          schema:
            type: string
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Orders]
      operationId: confirmOrder
      summary: Confirm a pending order
      description: |
        Confirms a pending order created with `POST /order`. The path parameter is the
        `confirmation_code` returned when the order was created. Only the
        `shipping_method` field of the request body is used — pass the chosen option from
        the `shipping_options` returned at creation. If omitted, the account's default
        shipping method is used where one exists (`412` otherwise).
      parameters:
        - name: reference
          in: path
          required: true
          description: The `confirmation_code` returned by `POST /order`.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                shipping_method:
                  $ref: "#/components/schemas/CreateOrderShipping"
      responses:
        "200":
          description: The confirmed order, including its `order_id` and `order_reference`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateOrderRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Gone"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /order/{reference}/item/{orderItemId}/configuration:
    post:
      tags: [Orders]
      operationId: addItemConfiguration
      summary: Add provisioning configuration to an order item
      description: |
        Attaches broadband (ADSL) provisioning configurations to a configurable order line
        item. The number of configurations must not exceed the item's remaining
        unconfigured quantity.
      parameters:
        - name: reference
          in: path
          required: true
          description: The numeric order id or the order reference.
          schema:
            type: string
        - name: orderItemId
          in: path
          required: true
          description: The order item id.
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateConfigRequest"
      responses:
        "200":
          description: The updated order item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderItem"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /order/{reference}/video:
    get:
      tags: [Orders]
      operationId: getOrderVideo
      summary: Get the packing video for an order
      description: |
        Returns links to the packing video recorded while the order was dispatched, where
        one is available.
      parameters:
        - name: reference
          in: path
          required: true
          description: The order reference.
          schema:
            type: string
      responses:
        "200":
          description: The packing video links.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderVideoResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /order/{reference}/invoice:
    get:
      tags: [Orders]
      operationId: getOrderInvoice
      summary: Get the invoice for an order
      description: |
        Returns the invoice for the order as a PDF.

        An order only has an invoice once it has been invoiced, which is reflected in its
        `status`. Asking any earlier returns a `412` — the invoice does not exist yet, rather
        than being withheld, so it is worth asking again later.
      parameters:
        - name: reference
          in: path
          required: true
          description: The order reference.
          schema:
            type: string
      responses:
        "200":
          description: The invoice.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"

  /quote:
    post:
      tags: [Quotes]
      operationId: createQuote
      summary: Create a quote
      description: |
        Validates and prices an order without placing it, creating a quote instead.
        Raises a ticket with NetXL support who will adjust pricing on the quote before getting
        back to you with the final pricing. The confirmation code can then be used to confirm the order
        at the updated quoted pricing
        The request body is the same as `POST /order`, except `auto_confirm` must not be
        set (`400` otherwise).

        Send an `Idempotency-Key` header to make retries safe — see the header description below.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
      responses:
        "201":
          description: The priced quote.
          headers:
            Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateOrderRequest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "500":
          $ref: "#/components/responses/ServerError"

  /customer:
    get:
      tags: [Customer]
      operationId: getCustomer
      summary: Get account details
      responses:
        "200":
          description: The authenticated customer's account details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Customer"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /customer/credit:
    get:
      tags: [Customer]
      operationId: getCustomerCredit
      summary: Get credit overview
      description: Returns the customer's credit limit, outstanding balance and outstanding credit orders.
      responses:
        "200":
          description: The credit overview.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditOverview"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /customer/address:
    get:
      tags: [Customer]
      operationId: listAddresses
      summary: List saved addresses
      responses:
        "200":
          description: The customer's saved addresses.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/CustomerAddress"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Customer]
      operationId: createAddress
      summary: Create an address
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CustomerAddress"
      responses:
        "201":
          description: The created address, including its new `id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerAddress"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /customer/address/{id}:
    delete:
      tags: [Customer]
      operationId: deleteAddress
      summary: Delete an address
      parameters:
        - name: id
          in: path
          required: true
          description: The address id.
          schema:
            type: integer
      responses:
        "204":
          description: The address was deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /category:
    get:
      tags: [Reference Data]
      operationId: listCategories
      summary: List categories
      description: Returns the category tree, two levels deep.
      responses:
        "200":
          description: The top-level categories with their children.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Category"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /manufacturer:
    get:
      tags: [Reference Data]
      operationId: listManufacturers
      summary: List manufacturers
      responses:
        "200":
          description: All manufacturers, ordered by name.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Manufacturer"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /attribute:
    get:
      tags: [Reference Data]
      operationId: listAttributes
      summary: List product attributes
      description: Returns the product attributes that can be used with the `attribute` filter on `GET /product`.
      responses:
        "200":
          description: All product attributes and their possible values.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Attribute"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /oauth/token:
    post:
      tags: [Authentication]
      operationId: exchangeToken
      summary: Exchange a token (RFC 8693)
      description: |
        Exchanges a JWT issued by `https://www.netxl.com`, carrying a customer's consent for an
        application to act for them, for a short-lived access token for this API.

        This endpoint is for the confidential clients NetXL has registered — currently the NetXL MCP
        server. It is not part of the customer-facing API, and a customer with an API key has no need
        of it. The client authenticates with HTTP Basic (preferred) or with `client_id` and
        `client_secret` in the body.

        The `subject_token` is the JWT the authorization server issued to the client. It is audienced
        for the client, not for this API, and is never accepted as authentication in its own right —
        only exchanged here for a token that is. Present the result as `Authorization: Bearer`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, subject_token, subject_token_type]
              properties:
                grant_type:
                  type: string
                  const: "urn:ietf:params:oauth:grant-type:token-exchange"
                subject_token:
                  type: string
                  description: The JWT issued by `https://www.netxl.com`.
                subject_token_type:
                  type: string
                  const: "urn:ietf:params:oauth:token-type:jwt"
                requested_token_type:
                  type: string
                  const: "urn:ietf:params:oauth:token-type:access_token"
                  description: Optional. If given, must be an access token.
                resource:
                  type: string
                  const: "https://api.netxl.com"
                  description: Optional. If given, must be this API.
                client_id:
                  type: string
                  description: Required unless the client authenticates with HTTP Basic.
                client_secret:
                  type: string
                  description: Required unless the client authenticates with HTTP Basic.
      responses:
        "200":
          description: An access token for the customer named by the subject token.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [access_token, issued_token_type, token_type, expires_in]
                properties:
                  access_token:
                    type: string
                    description: "A 64-character opaque token. Send as `Authorization: Bearer`."
                  issued_token_type:
                    type: string
                    const: "urn:ietf:params:oauth:token-type:access_token"
                  token_type:
                    type: string
                    const: "Bearer"
                  expires_in:
                    type: integer
                    description: Seconds until the token expires.
                    examples: [900]
        "400":
          description: |
            The request, or the subject token, was not acceptable. `invalid_grant` means the subject
            token was refused — no further detail is given.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "401":
          description: The client did not authenticate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "403":
          description: The source IP address is blocked (see Authentication).
        "503":
          description: The authentication backend is unavailable.

  /check-health:
    get:
      tags: [Health]
      operationId: checkHealth
      summary: Health check
      security: []
      responses:
        "200":
          description: The service is healthy.
          content:
            application/json:
              schema:
                type: object

  /openapi.json:
    get:
      tags: [Documentation]
      operationId: getOpenApiJson
      summary: This specification as JSON
      security: []
      responses:
        "200":
          description: The OpenAPI document.
          content:
            application/json:
              schema:
                type: object

  /openapi.yaml:
    get:
      tags: [Documentation]
      operationId: getOpenApiYaml
      summary: This specification as YAML
      security: []
      responses:
        "200":
          description: The OpenAPI document.
          content:
            application/yaml:
              schema:
                type: string

components:

  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 64-character customer API key.
    ApiKeyQuery:
      type: apiKey
      in: query
      name: api-key
      description: 64-character customer API key (alternative to the `X-Api-Key` header).
    BearerToken:
      type: http
      scheme: bearer
      description: |
        An access token from `POST /oauth/token`, held by an application a customer has authorised to
        act for them. It confers exactly what that customer's own API key would, and expires in
        minutes.

        A request that carries a bearer token is authenticated on that token alone. It does not fall
        back to the API key, so an expired token is a `401` rather than a request that quietly
        succeeds as somebody else.

  parameters:
    Page:
      name: page
      in: query
      required: false
      description: |
        Which page of results to return, counting from 1. A page beyond the end of the results is
        not an error — it returns an empty list.
      schema:
        type: integer
        minimum: 1
        default: 1
      example: 2
    Limit:
      name: limit
      in: query
      required: false
      description: |
        How many results to return per page, to a maximum of 500. Use `limit=0` to return
        everything in one response, which for the full catalogue is a large document.

        Results are paginated by default: a request that does not ask for a `limit` gets the first
        100. The one exception is the CSV catalogue feed — `GET /product` rendered as CSV, which is
        what you get with no `Accept` header — where a missing `limit` still means the whole
        catalogue, so that feeds written before paging existed are not silently truncated. Naming a
        `page` opts even that request back into paging. The exception does **not** extend to
        `GET /order`, which is paginated as CSV just as it is as JSON.

        Which page you are looking at is described by the `X-Total-Count`, `X-Page`, `X-Limit` and
        `X-Total-Pages` response headers, and the `Link` header carries ready-made URLs for the
        first, previous, next and last pages. The body itself is always a plain array, so that the
        CSV representation stays a plain table.
      schema:
        type: integer
        minimum: 0
        maximum: 500
      example: 100
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        A unique, client-generated value (a UUID is ideal) identifying this request, so that an
        accidental replay — a retried timeout, a double-clicked button — does not create a second
        order or quote.

        The first request using a given key is processed normally and its response is recorded.
        Any later request using the same key returns that recorded response verbatim, with an
        `Idempotent-Replay: true` header, and creates nothing.

        Keys are remembered permanently, so a key that created an order will replay that order's
        response however long ago it was used.

        Keys are scoped to your account and to the request itself. Re-using a key with a different
        method, path or body returns `400`. If an earlier request with the same key is still being
        processed, `409` is returned — wait and retry. A request that fails leaves the key unused,
        so you are free to retry it with the same key.
      schema:
        type: string
        maxLength: 255
      example: 7f1e0f6c-2a5e-4a9b-9c3d-1b2c3d4e5f60

  headers:
    XTotalCount:
      description: How many results there are in total, across every page, after filtering.
      schema:
        type: integer
      example: 4312
    XPage:
      description: Which page this is, counting from 1.
      schema:
        type: integer
      example: 2
    XLimit:
      description: How many results a page holds. `0` means the results were not paginated.
      schema:
        type: integer
      example: 100
    XTotalPages:
      description: How many pages there are at this `limit`.
      schema:
        type: integer
      example: 44
    Link:
      description: |
        RFC 8288 links to the `first`, `prev`, `next` and `last` pages, whichever of them exist.
        Each link carries the rest of the query across, so following `next` keeps your filters.
      schema:
        type: string
      example: '</product?page=3&limit=100>; rel="next", </product?page=44&limit=100>; rel="last"'
    IdempotentReplay:
      description: |
        Present and `true` when this response was replayed from a previous request with the same
        `Idempotency-Key`, meaning nothing was created.
      schema:
        type: string
        enum: ["true"]

  responses:
    IdempotencyConflict:
      description: An earlier request with the same `Idempotency-Key` is still being processed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadRequest:
      description: The request was invalid.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: The API key is missing or invalid.
      content:
        application/json:
          schema:
            type: object
    PaymentRequired:
      description: The account has no credit terms or insufficient available credit.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: The resource was not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Gone:
      description: The pending order has expired (older than 24 hours).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PreconditionFailed:
      description: A shipping method is required and no account default exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ServerError:
      description: An unexpected error occurred.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:

    OAuthError:
      type: object
      description: An OAuth 2.0 error response (RFC 6749 5.2).
      required: [error]
      properties:
        error:
          type: string
          enum:
            - invalid_request
            - invalid_client
            - invalid_grant
            - invalid_target
            - unsupported_grant_type

    LocalDateTime:
      type: string
      description: UK local time in the format `yyyy-MM-dd'T'HH:mm:ss`, with no timezone offset.
      pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$"
      examples: ["2026-01-15T10:30:00"]

    Error:
      type: object
      properties:
        message:
          type: string
          description: A description of the error.

    Product:
      type: object
      properties:
        sku:
          type: string
        ean:
          type: ["string", "null"]
        name:
          type: string
        manufacturer:
          type: string
          description: The manufacturer key.
        weight_kg:
          type: number
        box_width_mm:
          type: number
        box_height_mm:
          type: number
        box_length_mm:
          type: number
        images:
          type: array
          items:
            $ref: "#/components/schemas/ProductImage"
        availability:
          $ref: "#/components/schemas/ProductAvailability"
        single_unit_cost:
          type: number
        five_plus_unit_cost:
          type: number
        ten_plus_unit_cost:
          type: number
        consumer_unit_cost:
          type: number
          description: The recommended retail price.
        description:
          type: string
          description: Only included when requested via `extra=description` (always included on `GET /product/{sku}`).
        description_html:
          type: string
          description: Only included when requested via `extra=description_html`.
        short_description:
          type: string
          description: Only included when requested via `extra=short_description` (always included on `GET /product/{sku}`).
        store_url:
          type: string
        categories:
          type: array
          items:
            type: string
          description: The category keys the product belongs to.
        attributes:
          type: object
          additionalProperties:
            type: string
          description: Attribute name to value map.
        related_products:
          type: array
          items:
            type: string
          description: SKUs of related products. Only included when requested via `extra=related`.
        similar_products:
          type: array
          items:
            type: string
          description: SKUs of similar products. Only included when requested via `extra=similar`.

    ProductImage:
      type: object
      properties:
        is_primary:
          type: boolean
        url:
          type: string

    ProductAvailability:
      type: object
      properties:
        available:
          type: integer
          description: Units currently in stock.
        status:
          type: string
        incoming:
          type: array
          items:
            $ref: "#/components/schemas/ProductIncomingStock"

    ProductIncomingStock:
      type: object
      properties:
        incoming:
          type: integer
          description: Units on an incoming delivery.
        expected_on:
          $ref: "#/components/schemas/LocalDateTime"

    Order:
      type: object
      properties:
        id:
          type: integer
          readOnly: true
        order_reference:
          type: string
        customer_reference:
          type: string
        cost_net:
          type: number
        cost_tax:
          type: number
        cost_gross:
          type: number
        created_on:
          $ref: "#/components/schemas/LocalDateTime"
        status:
          type: string
          readOnly: true
          description: Where the order has got to. New values may be added over time, so treat this as a label to show rather than a fixed set to switch on.
          example: Awaiting Despatch
        items:
          type: array
          items:
            $ref: "#/components/schemas/OrderItem"
        shipping:
          type: array
          items:
            $ref: "#/components/schemas/OrderShipping"

    OrderItem:
      type: object
      properties:
        id:
          type: integer
        product:
          $ref: "#/components/schemas/Product"
        quantity:
          type: integer
        cost_net:
          type: number
        cost_tax:
          type: number
        related_items:
          type: array
          items:
            $ref: "#/components/schemas/OrderItem"
        configurations:
          type: array
          items:
            $ref: "#/components/schemas/OrderItemConfiguration"
        serial_numbers:
          type: array
          items:
            $ref: "#/components/schemas/ProductSerialNumber"

    OrderItemConfiguration:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/ProvisioningData"

    ProvisioningData:
      type: object
      properties:
        product:
          type: string
        adsl_username:
          type: string
        adsl_password:
          type: string
        wifi_ssid:
          type: string
        wifi_password:
          type: string

    ProductSerialNumber:
      type: object
      properties:
        mac_address:
          type: string
        serial_number:
          type: string
        provisioning_sku:
          type: string

    OrderShipping:
      type: object
      properties:
        method_id:
          type: integer
        contact_email:
          type: string
        contact_phone:
          type: string
        tracking_number:
          type: string
        cost_net:
          type: number
        cost_tax:
          type: number
        last_tracking_status:
          type: string
        tracking_events:
          type: array
          items:
            $ref: "#/components/schemas/TrackingEvent"

    TrackingEvent:
      type: object
      properties:
        date:
          type: string
        type:
          type: string
        description:
          type: string
        locality:
          type: string

    CreateOrderRequest:
      type: object
      required: [customer_reference, items, billing_address, shipping_address]
      properties:
        customer_reference:
          type: string
          description: Your own reference for the order.
        items:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/CreateOrderItem"
        billing_address:
          $ref: "#/components/schemas/CustomerAddress"
        shipping_address:
          $ref: "#/components/schemas/CustomerAddress"
        shipping_method:
          $ref: "#/components/schemas/CreateOrderShipping"
        shipping_region:
          type: integer
          description: The shipping region. Defaults to 1 (UK mainland).
        external_customer_id:
          type: string
          description: An optional identifier for your own end customer.
        order_notifications:
          $ref: "#/components/schemas/NotificationDetails"
        courier_notifications:
          $ref: "#/components/schemas/NotificationDetails"
        auto_confirm:
          type: boolean
          description: |
            When `true`, the order is confirmed immediately without a separate
            `PUT /order/{uuid}` call. Requires a `shipping_method` or an account default
            shipping method. Not permitted on `POST /quote`.
        confirmation_code:
          type: string
          readOnly: true
          description: Populated on creation; use as the `{uuid}` in `PUT /order/{uuid}` to confirm the order.
        order_id:
          type: integer
          readOnly: true
        order_reference:
          type: string
          readOnly: true
        shipping_options:
          type: array
          readOnly: true
          items:
            $ref: "#/components/schemas/CreateOrderShipping"
          description: The shipping methods available for this order.
        shipping_weight:
          type: number
          readOnly: true
        cost_net:
          type: number
          readOnly: true
        cost_tax:
          type: number
          readOnly: true
        cost_gross:
          type: number
          readOnly: true

    CreateOrderItem:
      type: object
      required: [sku, quantity]
      properties:
        sku:
          type: string
        quantity:
          type: integer
          minimum: 1
        cost_net:
          type: number
        id:
          type: integer
          readOnly: true

    CreateOrderShipping:
      type: object
      properties:
        id:
          type: integer
          description: The shipping method id. The only field required when selecting a method.
        provider:
          type: string
        description:
          type: string
        cost_net:
          type: number
        expected_dispatch_date:
          $ref: "#/components/schemas/LocalDateTime"
        expected_arrival_date:
          $ref: "#/components/schemas/LocalDateTime"

    NotificationDetails:
      type: object
      properties:
        email_address:
          type: string
        phone_number:
          type: string

    CreateConfigRequest:
      type: object
      required: [configurations]
      properties:
        configurations:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/ConfigItem"

    ConfigItem:
      type: object
      required: [adsl_username, adsl_password]
      properties:
        adsl_username:
          type: string
        adsl_password:
          type: string

    Customer:
      type: object
      properties:
        first_name:
          type: string
        last_name:
          type: string
        email_address:
          type: string
        accounts_email:
          type: string
        trade_account:
          type: boolean
        credit_terms:
          oneOf:
            - $ref: "#/components/schemas/CustomerCreditTerms"
            - type: "null"

    CustomerCreditTerms:
      type: object
      properties:
        credit_limit:
          type: number
        term_days:
          type: integer

    CreditOverview:
      type: object
      properties:
        credit_limit:
          type: number
        total_outstanding:
          type: number
        available_credit:
          type: number
        outstanding_orders:
          type: array
          items:
            $ref: "#/components/schemas/CreditOrderStatus"

    CreditOrderStatus:
      type: object
      properties:
        total_cost:
          type: number
        total_paid:
          type: number
        total_due:
          type: number
        created_on:
          $ref: "#/components/schemas/LocalDateTime"
        due_on:
          $ref: "#/components/schemas/LocalDateTime"
        is_overdue:
          type: boolean
        customer_reference:
          type: string
        order_reference:
          type: string

    CustomerAddress:
      type: object
      required: [contact_name, street_one, city, zip, country_code]
      properties:
        id:
          type: integer
          readOnly: true
        contact_name:
          type: string
        company_name:
          type: string
        street_one:
          type: string
        street_two:
          type: string
        street_three:
          type: string
        city:
          type: string
        state:
          type: string
        zip:
          type: string
        country_code:
          type: string
        contact_number:
          type: string
        tax_number:
          type: string

    Category:
      type: object
      properties:
        key:
          type: string
        name:
          type: string
        children:
          type: ["array", "null"]
          items:
            $ref: "#/components/schemas/Category"
          description: Child categories, or null when there are none. Populated at most two levels deep.

    Manufacturer:
      type: object
      properties:
        key:
          type: string
        name:
          type: string

    Attribute:
      type: object
      properties:
        key:
          type: string
        name:
          type: string
        type:
          type: string
        possible_values:
          type: array
          items:
            $ref: "#/components/schemas/AttributeValue"

    AttributeValue:
      type: object
      properties:
        value:
          type: string

    OrderVideoResponse:
      type: object
      properties:
        video_file:
          type: string
          description: URL of the packing video file.
        streaming_playlist:
          type: string
          description: URL of the HLS (m3u8) streaming playlist.
