# =============================================================================
# Generated from PT-core-app-server — do not edit downstream copies.
# This OpenAPI spec is the source of truth and is maintained in the
# pt-core-app-server repository (docs/channel-api-openapi.yaml). Any copy
# distributed to channel partners or sibling apps is downstream; edit it here.
# =============================================================================
openapi: 3.0.3
info:
  title: Print Trail Channel API
  version: 1.0.0
  description: |
    REST API for third-party sales channels to manage products, orders, and
    fulfilments on the Print Trail platform. Each channel authenticates via a
    Sanctum bearer token and operates within a single tenant context.

    > **Source of truth:** Generated from PT-core-app-server — do not edit
    > downstream copies. This spec lives at `docs/channel-api-openapi.yaml`
    > in the pt-core-app-server repository.

    > **Note:** Print-on-demand (POD) production is not yet supported in this
    > release and is intentionally omitted from this API surface. Products in
    > the supported catalogue are warehouse-inventory items (for example,
    > `type: book`).

servers:
  - url: https://api.printtrail.com/v1/channels/{channelId}
    description: Production
    variables:
      channelId:
        description: The channel's tenant-channel ID
        default: "cnsl-std"
  - url: https://api.printtrail-core.consilstaging.com/v1/channels/{channelId}
    description: Staging (with api subdomain)
    variables:
      channelId:
        description: The channel's tenant-channel ID
        default: "cnsl-std"
  - url: https://printtrail-core.consilstaging.com/v1/channels/{channelId}
    description: Staging (without api subdomain)
    variables:
      channelId:
        description: The channel's tenant-channel ID
        default: "cnsl-std"
  - url: https://print-trail-core-app-laravel12.test/v1/channels/{channelId}
    description: Local (Herd)
    variables:
      channelId:
        description: The channel's tenant-channel ID
        default: "cnsl-std"

security:
  - bearerAuth: []

paths:
  /:
    get:
      operationId: getChannel
      summary: Get channel details
      tags: [Channel]
      responses:
        "200":
          description: Channel details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChannelResource"

  /test-connection:
    post:
      operationId: testConnection
      summary: Health / credentials check
      description: |
        Lightweight connectivity check. Reaching this endpoint at all means the
        request passed the `channel` and `auth:sanctum` middleware, so the
        supplied bearer token is valid for this channel. The response is a flat
        (unwrapped) `{ message, timestamp }` body — it is not wrapped in a
        `data` envelope.
      tags: [Channel]
      responses:
        "200":
          description: Connection OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Connection OK
                  timestamp:
                    type: string
                    format: date-time
                    description: Server time the check ran, ISO 8601.
                    example: "2026-06-22T10:15:30+00:00"
        "401":
          description: Missing or invalid Sanctum token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"

  /team:
    get:
      operationId: getTeam
      summary: Get the team (tenant) this channel belongs to
      tags: [Channel]
      responses:
        "200":
          description: Team summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClientSummaryResource"

  /webhooks:
    post:
      operationId: registerWebhook
      summary: Register or update a webhook URL
      description: |
        The URL may contain a `{notificationType}` placeholder which is
        substituted with the event type string at dispatch time.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  example: https://mystore.com/webhooks/printtrail/{notificationType}
            examples:
              registerWebhook:
                summary: Register a templated webhook URL
                value:
                  url: "https://mystore.com/webhooks/printtrail/{notificationType}"
      responses:
        "200":
          description: Webhook registered; returns updated channel
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChannelResource"

  /webhooks/resend:
    post:
      operationId: resendWebhook
      summary: Re-emit a webhook event for an order
      description: |
        Re-dispatches a previously fired webhook for an order, so the channel's
        registered webhook URL is called again. Useful when the receiver missed
        or failed to process the original delivery.

        Only **order-level** events are resendable, because they can be
        reconstructed from the order alone. Fulfilment-level events
        (`fulfilment-status-updated`, `fulfilment-tracking-created`) are
        intentionally **not** resendable here.

        The `eventType` is case-sensitive and must be one of the supported
        identifiers — either the notification-type string or the event class
        short name:

        - `order-status-updated` / `OrderStatusWasUpdated`
        - `order-created` / `OrderWasCreated`
        - `order-fulfillable-status-updated` / `OrderFulfillableStatusWasUpdated`

        The `orderUuid` must reference an order belonging to the authenticated
        channel; otherwise a 422 is returned.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orderUuid, eventType]
              properties:
                orderUuid:
                  type: string
                  format: uuid
                  description: UUID of an order owned by this channel.
                  example: "c0ffee00-1234-4abc-8def-0123456789ab"
                eventType:
                  type: string
                  description: The (case-sensitive) event type to re-emit.
                  example: order-status-updated
                  enum:
                    - order-status-updated
                    - OrderStatusWasUpdated
                    - order-created
                    - OrderWasCreated
                    - order-fulfillable-status-updated
                    - OrderFulfillableStatusWasUpdated
      responses:
        "202":
          description: Webhook event queued for resend
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Webhook event queued for resend
                  orderUuid:
                    type: string
                    format: uuid
                    example: "c0ffee00-1234-4abc-8def-0123456789ab"
                  eventType:
                    type: string
                    example: order-status-updated
        "401":
          description: Missing or invalid Sanctum token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"
        "422":
          description: |
            Validation error — a missing or unsupported `eventType`, a missing
            or unknown `orderUuid`, or an order that does not belong to the
            authenticated channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"

  /products:
    get:
      operationId: searchProducts
      summary: Search products
      description: Returns a paginated list of products matching the given filters.
      tags: [Products]
      parameters:
        - name: uuid
          in: query
          description: Comma-separated product UUIDs
          schema:
            type: string
        - name: sku
          in: query
          description: SKU wildcard match
          schema:
            type: string
        - name: isbn
          in: query
          description: ISBN lookup
          schema:
            type: string
        - name: name
          in: query
          description: Product name wildcard match
          schema:
            type: string
        - name: active-state
          in: query
          description: "Comma-separated statuses: ACTIVE, INACTIVE, PRE_ORDER"
          schema:
            type: string
        - name: page
          in: query
          description: Page number
          schema:
            type: integer
            default: 1
      responses:
        "200":
          description: Paginated product list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ProductResource"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"
              examples:
                twoBooks:
                  summary: Two book titles
                  value:
                    data:
                      - uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                        name: "Moby-Dick: Or, The Whale"
                        sku: "9780143105954"
                        gtin: "9780143105954"
                        isbn: "978-0-14-310595-4"
                        isbn10: "0-14-310595-7"
                        ean: "9780143105954"
                        upc: null
                        type: "book"
                        description: "Penguin Classics Deluxe Edition."
                        additionalDetails:
                          publisher: "Penguin Classics"
                          publishedDate: "2010-01-01"
                          pageCount: 672
                          language: "English"
                        inventoryEnabled: true
                        activeState: "ACTIVE"
                        stockLevel: 128
                        images: []
                        dimensions: []
                        price:
                          - currencyCode: "GBP"
                            minorUnits: 1400
                          - currencyCode: "USD"
                            minorUnits: 1700
                      - uuid: "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
                        name: "Harry Potter and the Prisoner of Azkaban"
                        sku: "9781408855676"
                        gtin: "9781408855676"
                        isbn: "978-1-4088-5567-6"
                        isbn10: "1-4088-5567-4"
                        ean: "9781408855676"
                        upc: null
                        type: "book"
                        description: "J.K. Rowling. Bloomsbury Children's Books edition."
                        additionalDetails:
                          publisher: "Bloomsbury Children's Books"
                          publishedDate: "2014-09-01"
                          language: "English"
                        inventoryEnabled: true
                        activeState: "ACTIVE"
                        stockLevel: 64
                        images: []
                        dimensions: []
                        price:
                          - currencyCode: "GBP"
                            minorUnits: 375
                          - currencyCode: "USD"
                            minorUnits: 550
                    meta:
                      current_page: 1
                      last_page: 1
                      per_page: 50
                      total: 2

  /stock-levels:
    get:
      operationId: getStockLevels
      summary: Bulk per-SKU stock levels
      description: |
        Compact bulk stock figure for every published product in the channel,
        for SKU validation and stock sync in a single call. The response is a
        flat, **unpaginated** array of `{ sku, stock }` objects — it is not
        wrapped in a `data` envelope and has no pagination metadata.

        `stock` is the same aggregate figure exposed as `stockLevel` on the
        product endpoints: the sum of enabled-warehouse stock for the SKU.
      tags: [Products]
      responses:
        "200":
          description: Flat array of per-SKU stock levels
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/StockLevelResource"
              examples:
                bulkStockLevels:
                  summary: Per-SKU stock for two book titles
                  value:
                    - sku: "9780143105954"
                      stock: 128
                    - sku: "9781408855676"
                      stock: 64
        "401":
          description: Missing or invalid Sanctum token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"

  /products/validate-skus:
    post:
      operationId: validateSkus
      summary: Bulk-validate external SKUs
      description: |
        Validates a list of external (shop-side) SKUs against Print Trail
        products. Matching uses the same rule order lines use: a
        case-insensitive exact match on the public `sku` column. The endpoint
        is media-free (no images, dimensions, or pricing) and does not read
        stock — it reports only whether each SKU maps to a PT product and, if
        so, that product's status.

        Input order is preserved and duplicates are echoed back. The response
        is flat — not wrapped in a `data` envelope and unpaginated. Treat
        `state == "unknown"` as the unmatched set.
      tags: [Products]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ValidateSkusRequest"
            examples:
              mixedSkus:
                summary: Two matches (one a case variant) and one unknown
                value:
                  skus: ["ABC-123", "abc-123", "NOPE"]
      responses:
        "200":
          description: Validation results, in submitted order
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidateSkusResponse"
              examples:
                mixedResults:
                  summary: Case-insensitive match, non-active match, unknown
                  value:
                    results:
                      - sku: "ABC-123"
                        state: "matched"
                        matchedSku: "ABC-123"
                        uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                        status: "active"
                      - sku: "abc-123"
                        state: "matched"
                        matchedSku: "ABC-123"
                        uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                        status: "inactive"
                      - sku: "NOPE"
                        state: "unknown"
                    summary:
                      matched: 2
                      unknown: 1
        "401":
          description: Missing or invalid Sanctum token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"
        "422":
          description: |
            Validation error — `skus` empty, more than 250 items, or an entry
            that is not a string of 1–100 characters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"

  /products/{productUuid}:
    get:
      operationId: getProduct
      summary: Get a single product
      tags: [Products]
      parameters:
        - name: productUuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Product details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResource"
              examples:
                mobyDick:
                  summary: Moby-Dick (Penguin Classics Deluxe Edition)
                  value:
                    uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                    name: "Moby-Dick: Or, The Whale"
                    sku: "9780143105954"
                    gtin: "9780143105954"
                    isbn: "978-0-14-310595-4"
                    isbn10: "0-14-310595-7"
                    ean: "9780143105954"
                    upc: null
                    type: "book"
                    description: "Penguin Classics Deluxe Edition. Herman Melville's masterpiece."
                    additionalDetails:
                      publisher: "Penguin Classics"
                      publishedDate: "2010-01-01"
                      pageCount: 672
                      language: "English"
                    inventoryEnabled: true
                    activeState: "ACTIVE"
                    stockLevel: 128
                    images: []
                    dimensions: []
                    price:
                      - currencyCode: "GBP"
                        minorUnits: 1400
                      - currencyCode: "USD"
                        minorUnits: 1700
        "404":
          description: Product not found

  /orders:
    get:
      operationId: searchOrders
      summary: Search orders
      description: Returns a paginated list of orders for this channel.
      tags: [Orders]
      parameters:
        - name: uuid
          in: query
          description: Comma-separated order UUIDs
          schema:
            type: string
        - name: order-number
          in: query
          description: External order number
          schema:
            type: string
        - name: purchase-order
          in: query
          description: Purchase order number
          schema:
            type: string
        - name: status
          in: query
          description: Comma-separated order statuses
          schema:
            type: string
        - name: created-since
          in: query
          description: ISO 8601 date
          schema:
            type: string
            format: date
        - name: updated-since
          in: query
          description: ISO 8601 date
          schema:
            type: string
            format: date
        - name: page
          in: query
          schema:
            type: integer
            default: 1
      responses:
        "200":
          description: Paginated order list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/OrderResource"
                  meta:
                    $ref: "#/components/schemas/PaginationMeta"

    post:
      operationId: createOrder
      summary: Create or update a draft order
      description: |
        Submits a complete order payload. If a UUID is provided and matches an
        existing draft, the order is updated (the full payload replaces the
        previous state — do not send partial updates).

        The `reserve` flag controls whether stock is held:

        - `reserve: false` (exploratory) — compute the fulfilment breakdown and
          pricing, do not hold stock. Use this while the customer is still
          browsing the cart. Any existing reservations on the draft are
          released.
        - `reserve: true` (firm) — compute the fulfilment breakdown AND hold
          stock against the order, returning a `reservations_expire_at`
          timestamp. Use this when the customer commits to checkout.

        Validation failure returns 422 with an RFC-7807 API Problem body.
        Stock insufficient for the reservation returns 409 with the SKU list.
      tags: [Orders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
            examples:
              twoBookOrder:
                summary: Firm order for two book titles (reserve stock)
                value:
                  reserve: true
                  orderNumber: "ORD-10432"
                  purchaseOrder: "PO-2026-0098"
                  shippingDetails:
                    postalAddress:
                      companyName: "Riverside Books Ltd"
                      address1: "12 Harbour Street"
                      address2: "Unit 4"
                      city: "Brighton"
                      postalCode: "BN1 1AA"
                      state:
                        name: "East Sussex"
                      country:
                        code: "GB"
                    deliveryContact:
                      firstName: "Ada"
                      lastName: "Cole"
                      mobile: "+447700900123"
                      email: "ada.cole@example.com"
                    note: "Leave with reception if no answer."
                  orderLines:
                    - quantity: 1
                      lineNumber: "1"
                      product:
                        isbn: "9780143105954"
                    - quantity: 2
                      lineNumber: "2"
                      product:
                        sku: "9781408855676"
      responses:
        "201":
          description: Order created or updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
              examples:
                createdOrder:
                  summary: Created draft order with reserved stock
                  value:
                    uuid: "c0ffee00-1234-4abc-8def-0123456789ab"
                    purchaseOrder: "PO-2026-0098"
                    orderNumber: "ORD-10432"
                    printTrailOrderNumber: "riverside-10432"
                    status: "draft"
                    fulfillableStatus: "fulfillable"
                    createdAt: "2026-06-22T10:15:30+00:00"
                    updatedAt: "2026-06-22T10:15:30+00:00"
                    reservationsExpireAt: "2026-06-22T10:45:30+00:00"
                    shippingDetails:
                      postalAddress:
                        companyName: "Riverside Books Ltd"
                        address1: "12 Harbour Street"
                        address2: "Unit 4"
                        city: "Brighton"
                        postalCode: "BN1 1AA"
                        state:
                          code: "ESX"
                          name: "East Sussex"
                        country:
                          code: "GB"
                          name: "United Kingdom"
                      deliveryContact:
                        firstName: "Ada"
                        lastName: "Cole"
                        mobile: "+447700900123"
                        phone: null
                        email: "ada.cole@example.com"
                      note: "Leave with reception if no answer."
                    orderLines:
                      - uuid: "11111111-2222-4333-8444-555555555555"
                        quantity: 1
                        lineNumber: "1"
                        fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
                        product:
                          uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                          name: "Moby-Dick: Or, The Whale"
                          sku: "9780143105954"
                          isbn: "978-0-14-310595-4"
                          type: "book"
                      - uuid: "66666666-7777-4888-8999-aaaaaaaaaaaa"
                        quantity: 2
                        lineNumber: "2"
                        fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
                        product:
                          uuid: "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
                          name: "Harry Potter and the Prisoner of Azkaban"
                          sku: "9781408855676"
                          isbn: "978-1-4088-5567-6"
                          type: "book"
                    fulfilments: []
                    errors: []
        "409":
          description: |
            Stock unavailable — one or more SKUs could not be reserved. The
            whole operation was rolled back; no fulfilments exist, no
            reservations were created. Only returned when `reserve: true`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StockUnavailableError"
        "422":
          description: |
            Validation error. Either a standard RFC-7807 API Problem body (field
            validation), or — when `strictSkus: true` and a line references an
            unknown SKU — an `UnknownSkusError` body listing the offending SKUs
            (the whole order is rejected and nothing is created).
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/ApiProblem"
                  - $ref: "#/components/schemas/UnknownSkusError"
              examples:
                fieldValidation:
                  summary: Standard field validation failure
                  value:
                    type: "https://httpstatuses.com/422"
                    title: "Unprocessable Entity"
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      orderLines.0.quantity: ["The quantity must be at least 1."]
                unknownSkus:
                  summary: strictSkus rejection (unknown SKUs)
                  value:
                    message: "One or more order lines reference unknown SKUs"
                    unmatchedSkus: ["NOPE", "TYPO-1"]

  /orders/{orderUuid}:
    get:
      operationId: getOrder
      summary: Get a single order with fulfilments
      tags: [Orders]
      parameters:
        - name: orderUuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Order details with fulfilments and tracking
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
        "404":
          description: Order not found

    delete:
      operationId: deleteOrder
      summary: Cancel an order (verb alias for PUT /cancel)
      description: |
        Cancels the order. This is a verb alias for `PUT /orders/{orderUuid}/cancel`
        with identical behaviour: synchronous cancellation (200) when no
        fulfilments have been initiated, or queued cancellation (202) otherwise.

        Like the `PUT` variant, this returns **428 Precondition Required** when
        the order cannot be cancelled — see that response below.
      tags: [Orders]
      parameters:
        - name: orderUuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Order cancelled immediately
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
        "202":
          description: Cancellation queued (fulfilments in progress)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
        "400":
          description: Cancellation failed
        "404":
          description: Order not found
        "428":
          description: |
            Precondition Required — the order cannot be cancelled. Returned when
            the order's status is a finished/terminal state (`complete`,
            `cancelled`, `returned`, `failed`) OR at least one of its fulfilments
            has already been dispatched. (Breaking change, issue #1474.)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"

  /orders/{orderUuid}/confirm:
    put:
      operationId: confirmOrder
      summary: Confirm a draft order for processing
      description: |
        Transitions the order from draft to processing. Optionally sets
        shipping methods on individual fulfilments.

        If the order's stock reservations have expired, Core will attempt to
        regenerate them automatically before confirming. If the re-reservation
        fails (stock has been taken by another order), a 409 is returned and
        the order stays in DRAFT.

        Returns 428 if the order is not in a confirmable state.
        Returns 202 if the order is already processing.
      tags: [Orders]
      parameters:
        - name: orderUuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConfirmOrderRequest"
      responses:
        "202":
          description: Order accepted for processing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
        "409":
          description: |
            Reservations expired and could not be refreshed — stock has been
            taken by another order. The order stays in DRAFT; the shop may
            amend the cart and retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StockUnavailableError"
        "428":
          description: Order not in a confirmable state (Precondition Required)

  /orders/{orderUuid}/cancel:
    put:
      operationId: cancelOrder
      summary: Cancel an order
      description: |
        Cancels the order. If no fulfilments have been initiated, cancellation
        is synchronous (200). If fulfilments are in progress, cancellation is
        queued (202).

        `DELETE /orders/{orderUuid}` is an exact verb alias for this operation.
      tags: [Orders]
      parameters:
        - name: orderUuid
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Order cancelled immediately
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
        "202":
          description: Cancellation queued (fulfilments in progress)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResource"
        "400":
          description: Cancellation failed
        "404":
          description: Order not found
        "428":
          description: |
            Precondition Required — the order cannot be cancelled. Returned when
            the order's status is a finished/terminal state (`complete`,
            `cancelled`, `returned`, `failed`) OR at least one of its fulfilments
            has already been dispatched. (Breaking change, issue #1474.)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiProblem"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Sanctum API token

  schemas:
    CreateOrderRequest:
      type: object
      required: [orderNumber, orderLines]
      properties:
        uuid:
          type: string
          format: uuid
          description: Optional; if provided and matches an existing draft, the order is updated.
        reserve:
          type: boolean
          default: false
          description: |
            If true, stock is held against the order and `reservations_expire_at`
            is populated in the response. If false (default), the order is a
            projection only — fulfilments are computed but no stock is held.

            Setting `reserve: true` on an order that already has live reservations
            releases the old ones and creates fresh ones with a new expiry.
            Setting `reserve: false` on an order with live reservations releases
            them.
        strictSkus:
          type: boolean
          default: false
          description: |
            Controls how order lines with an unmatched SKU are handled.

            - `false` (default) — a line whose SKU matches no PT product is
              dropped and reported non-blockingly in the order's `errors[]`
              array (key `product-not-found`); the order is still created from
              the remaining lines.
            - `true` — if any line references a SKU that matches no PT product,
              the whole order is rejected with 422 and nothing is created (no
              order, fulfilments, or reservations). The response is an
              `UnknownSkusError` body listing the offending SKUs.
        orderNumber:
          type: string
          description: The channel's own order reference.
        printTrailOrderNumber:
          type: string
          pattern: "^[a-z0-9][a-z0-9-]*$"
          description: Optional custom PT order number (must be unique within the tenant).
        purchaseOrder:
          type: string
          maxLength: 50
        requestedShippingMethod:
          type: string
          pattern: "^[a-z0-9][a-z0-9-]*$"
          maxLength: 60
        shippingDetails:
          $ref: "#/components/schemas/ShippingDetailsInput"
        orderLines:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/OrderLineInput"

    ShippingDetailsInput:
      type: object
      properties:
        postalAddress:
          type: object
          properties:
            companyName:
              type: string
              maxLength: 100
            address1:
              type: string
              maxLength: 100
            address2:
              type: string
              maxLength: 100
            address3:
              type: string
              maxLength: 100
            address4:
              type: string
              maxLength: 100
            locality:
              type: string
              maxLength: 100
            city:
              type: string
              maxLength: 100
            postalCode:
              type: string
              maxLength: 20
            state:
              type: object
              properties:
                name:
                  type: string
                code:
                  type: string
            country:
              type: object
              properties:
                name:
                  type: string
                code:
                  type: string
                  maxLength: 3
        deliveryContact:
          type: object
          properties:
            firstName:
              type: string
              maxLength: 100
            lastName:
              type: string
              maxLength: 100
            mobile:
              type: string
              maxLength: 50
            phone:
              type: string
              maxLength: 50
            email:
              type: string
              format: email
        note:
          type: string
          maxLength: 500

    OrderLineInput:
      type: object
      required: [quantity, lineNumber]
      properties:
        quantity:
          type: integer
          minimum: 1
        lineNumber:
          type: string
          maxLength: 100
          description: The channel's own line item reference.
        product:
          type: object
          description: |
            At least one identifier should be provided. SKU matching is a
            case-insensitive exact match. A line whose product cannot be
            matched is skipped (mixed baskets are allowed) and surfaced as a
            `product-not-found` entry in the order's `errors[]` array.
          properties:
            sku:
              type: string
              description: Channel-side SKU
            uuid:
              type: string
              format: uuid
              description: Print Trail product UUID
            isbn:
              type: string
              description: ISBN-10 or ISBN-13

    ConfirmOrderRequest:
      type: object
      example:
        defaultShippingMethod: "tracked-48"
        fulfilmentShippingMethods:
          - fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
            shippingMethod: "tracked-24"
      properties:
        defaultShippingMethod:
          type: string
          pattern: "^[a-z0-9][a-z0-9-]*$"
          maxLength: 60
          description: Applied to all fulfilments unless overridden.
        fulfilmentShippingMethods:
          type: array
          items:
            type: object
            required: [fulfilmentUuid, shippingMethod]
            properties:
              fulfilmentUuid:
                type: string
                format: uuid
              shippingMethod:
                type: string
                pattern: "^[a-z0-9][a-z0-9-]*$"
                maxLength: 60

    OrderResource:
      type: object
      example:
        uuid: "c0ffee00-1234-4abc-8def-0123456789ab"
        purchaseOrder: "PO-2026-0098"
        orderNumber: "ORD-10432"
        printTrailOrderNumber: "riverside-10432"
        status: "processing"
        fulfillableStatus: "fulfillable"
        createdAt: "2026-06-22T10:15:30+00:00"
        updatedAt: "2026-06-22T11:02:14+00:00"
        reservationsExpireAt: null
        shippingDetails:
          postalAddress:
            companyName: "Riverside Books Ltd"
            address1: "12 Harbour Street"
            address2: "Unit 4"
            city: "Brighton"
            postalCode: "BN1 1AA"
            state:
              code: "ESX"
              name: "East Sussex"
            country:
              code: "GB"
              name: "United Kingdom"
          deliveryContact:
            firstName: "Ada"
            lastName: "Cole"
            mobile: "+447700900123"
            phone: null
            email: "ada.cole@example.com"
          note: "Leave with reception if no answer."
        orderLines:
          - uuid: "11111111-2222-4333-8444-555555555555"
            quantity: 1
            lineNumber: "1"
            fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
            product:
              uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
              name: "Moby-Dick: Or, The Whale"
              sku: "9780143105954"
              isbn: "978-0-14-310595-4"
              type: "book"
          - uuid: "66666666-7777-4888-8999-aaaaaaaaaaaa"
            quantity: 2
            lineNumber: "2"
            fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
            product:
              uuid: "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
              name: "Harry Potter and the Prisoner of Azkaban"
              sku: "9781408855676"
              isbn: "978-1-4088-5567-6"
              type: "book"
        fulfilments:
          - uuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
            fulfilmentNumber: "riverside-10432-1"
            productionMethod: "warehouse"
            supplierLocation:
              code: "GB-OGD"
              name: "Ogden"
            status: "dispatched"
            orderLines:
              - uuid: "11111111-2222-4333-8444-555555555555"
                quantity: 1
                lineNumber: "1"
                fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
                product:
                  uuid: "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                  name: "Moby-Dick: Or, The Whale"
                  sku: "9780143105954"
                  isbn: "978-0-14-310595-4"
                  type: "book"
              - uuid: "66666666-7777-4888-8999-aaaaaaaaaaaa"
                quantity: 2
                lineNumber: "2"
                fulfilmentUuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
                product:
                  uuid: "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
                  name: "Harry Potter and the Prisoner of Azkaban"
                  sku: "9781408855676"
                  isbn: "978-1-4088-5567-6"
                  type: "book"
            availableShippingMethods:
              - code: "tracked-48"
                name: "Royal Mail Tracked 48"
            requestedShippingMethod: "tracked-48"
            trackingDetails:
              - number: "JD0002270060000123456"
                url: "https://www.royalmail.com/track-your-item#/tracking-results/JD0002270060000123456"
                courier: "Royal Mail"
                methodName: "Royal Mail Tracked 48"
                shippedAt: "2026-06-22T11:02:14+00:00"
                carrier: "Royal Mail"
                trackingNumber: "JD0002270060000123456"
                trackingUrl: "https://www.royalmail.com/track-your-item#/tracking-results/JD0002270060000123456"
        errors: []
      properties:
        uuid:
          type: string
          format: uuid
        purchaseOrder:
          type: string
          nullable: true
        orderNumber:
          type: string
          nullable: true
        printTrailOrderNumber:
          type: string
          nullable: true
        status:
          type: string
          enum: [draft, queued, processing, dispatched, cancelled, invalid, scheduled]
        fulfillableStatus:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        reservationsExpireAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Earliest expiry across the order's live stock reservations, or
            null when no reservations are held (exploratory mode or already
            confirmed/cancelled). The shop may use this to display a
            countdown to the customer during checkout.
        shippingDetails:
          $ref: "#/components/schemas/ShippingDetailsResource"
        orderLines:
          type: array
          items:
            $ref: "#/components/schemas/OrderLineResource"
        fulfilments:
          type: array
          items:
            $ref: "#/components/schemas/FulfilmentResource"
        errors:
          type: array
          items:
            $ref: "#/components/schemas/OrderErrorResource"

    OrderLineResource:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
          nullable: true
        quantity:
          type: integer
        lineNumber:
          type: string
          nullable: true
        fulfilmentUuid:
          type: string
          format: uuid
          nullable: true
        product:
          $ref: "#/components/schemas/ProductResource"

    FulfilmentResource:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
        fulfilmentNumber:
          type: string
          nullable: true
        productionMethod:
          type: string
          nullable: true
        supplierLocation:
          type: object
          properties:
            code:
              type: string
            name:
              type: string
        status:
          type: string
          enum: [draft, holding, processing, dispatched, cancelled, error]
        orderLines:
          type: array
          items:
            $ref: "#/components/schemas/OrderLineResource"
        availableShippingMethods:
          type: array
          items:
            $ref: "#/components/schemas/ShippingMethodResource"
        requestedShippingMethod:
          type: string
          nullable: true
        trackingDetails:
          type: array
          items:
            $ref: "#/components/schemas/TrackingDetailResource"

    ProductResource:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
        name:
          type: string
        sku:
          type: string
          description: |
            The public SKU. For books this is conventionally the ISBN-13.
        gtin:
          type: string
          nullable: true
          description: Global Trade Item Number. For books this is the ISBN-13.
        isbn:
          type: string
          nullable: true
          description: ISBN-13, returned in formatted (hyphenated) form.
          example: "978-0-14-310595-4"
        isbn10:
          type: string
          nullable: true
          description: ISBN-10, returned in formatted (hyphenated) form.
          example: "0-14-310595-7"
        ean:
          type: string
          nullable: true
        upc:
          type: string
          nullable: true
        type:
          type: string
          description: |
            Product catalogue type. Supported values include `book`,
            `calendar`, `poster`, `leaflet`, `brochure`, `dust-jacket`, `cd`,
            `dvd`, `other`.
          example: book
        description:
          type: string
          nullable: true
        additionalDetails:
          nullable: true
        inventoryEnabled:
          type: boolean
          description: Whether the product is stocked from warehouse inventory.
        activeState:
          type: string
          enum: [ACTIVE, INACTIVE, PRE_ORDER]
        stockLevel:
          type: integer
          description: |
            On-hand warehouse stock level for the product. Does not yet
            subtract reservations (planned feature).
        images:
          type: array
          items:
            type: object
            properties:
              uuid:
                type: string
                format: uuid
              url:
                type: string
                format: uri
              name:
                type: string
              fileName:
                type: string
              mimeType:
                type: string
              size:
                type: integer
        dimensions:
          type: array
          items:
            type: object
        price:
          type: array
          items:
            type: object
            properties:
              currencyCode:
                type: string
              minorUnits:
                type: integer

    ShippingDetailsResource:
      type: object
      properties:
        postalAddress:
          $ref: "#/components/schemas/PostalAddressResource"
        deliveryContact:
          $ref: "#/components/schemas/DeliveryContactResource"
        note:
          type: string
          nullable: true

    PostalAddressResource:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
          nullable: true
        companyName:
          type: string
          nullable: true
        address1:
          type: string
          nullable: true
        address2:
          type: string
          nullable: true
        address3:
          type: string
          nullable: true
        address4:
          type: string
          nullable: true
        locality:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        postalCode:
          type: string
          nullable: true
        state:
          type: object
          properties:
            code:
              type: string
              nullable: true
            name:
              type: string
              nullable: true
        country:
          type: object
          properties:
            code:
              type: string
              nullable: true
            name:
              type: string
              nullable: true

    DeliveryContactResource:
      type: object
      properties:
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        mobile:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true

    ShippingMethodResource:
      type: object
      properties:
        code:
          type: string
          nullable: true
        name:
          type: string
          nullable: true

    TrackingDetailResource:
      type: object
      description: |
        A single tracking detail. As of issue #1474 each object carries three
        additional keys — `carrier`, `trackingNumber`, `trackingUrl` —
        alongside the legacy `courier`, `number`, `url`. The new names are the
        recommended ones going forward; the legacy keys are retained for
        backwards compatibility and are exact duplicates of the new ones. This
        same shape is used inside the `fulfilment-tracking-created` webhook
        payload's `currentTrackingDetails` array.
      properties:
        number:
          type: string
          nullable: true
          deprecated: true
          description: Legacy key. Use `trackingNumber` instead.
        url:
          type: string
          format: uri
          nullable: true
          deprecated: true
          description: Legacy key. Use `trackingUrl` instead.
        courier:
          type: string
          nullable: true
          deprecated: true
          description: Legacy key. Use `carrier` instead.
        methodName:
          type: string
          nullable: true
        shippedAt:
          type: string
          format: date-time
          nullable: true
        carrier:
          type: string
          nullable: true
          description: Carrier / courier name. Recommended key (alias of `courier`).
        trackingNumber:
          type: string
          nullable: true
          description: Tracking number. Recommended key (alias of `number`).
        trackingUrl:
          type: string
          format: uri
          nullable: true
          description: Tracking URL. Recommended key (alias of `url`).

    ChannelResource:
      type: object
      example:
        id: 42
        name: "Riverside Books Storefront"
        state: "ACTIVE"
        mode: "live"
        lastSyncedAt: "2026-06-22T10:15:30+00:00"
        webhookDetail:
          uri: "https://mystore.com/webhooks/printtrail/{notificationType}"
        code: "cnsl-std"
        team:
          name: "Riverside Books Ltd"
          code: "riverside"
          returnsPolicyUrl: "https://mystore.com/returns-policy"
      properties:
        id:
          type: integer
        name:
          type: string
        state:
          type: string
        mode:
          type: string
        lastSyncedAt:
          type: string
          format: date-time
          nullable: true
        webhookDetail:
          type: object
          properties:
            uri:
              type: string
              nullable: true
        code:
          type: string
        team:
          $ref: "#/components/schemas/ClientSummaryResource"

    ClientSummaryResource:
      type: object
      example:
        name: "Riverside Books Ltd"
        code: "riverside"
        returnsPolicyUrl: "https://mystore.com/returns-policy"
      properties:
        name:
          type: string
        code:
          type: string
        returnsPolicyUrl:
          type: string
          nullable: true

    OrderErrorResource:
      type: object
      properties:
        code:
          type: string
          nullable: true
        key:
          type: string
          nullable: true
        message:
          type: string
          nullable: true

    StockLevelResource:
      type: object
      description: |
        Compact per-SKU stock figure returned by the bulk stock-levels
        endpoint.
      properties:
        sku:
          type: string
          nullable: true
        stock:
          type: integer
          description: |
            Aggregate stock: the sum of enabled-warehouse stock for the SKU.
            Same value as the `stockLevel` field on the product endpoints.

    ValidateSkusRequest:
      type: object
      required: [skus]
      properties:
        skus:
          type: array
          minItems: 1
          maxItems: 250
          description: External (shop-side) SKUs to validate, 1–250 items.
          items:
            type: string
            minLength: 1
            maxLength: 100

    ValidateSkusResult:
      type: object
      description: |
        One result row per submitted SKU, in submitted order (duplicates are
        echoed). `matchedSku`, `uuid`, and `status` are present only when
        `state` is `matched`.
      required: [sku, state]
      properties:
        sku:
          type: string
          description: The SKU as submitted (echoed verbatim).
        state:
          type: string
          enum: [matched, unknown]
        matchedSku:
          type: string
          description: |
            Canonical PT casing of the SKU. May differ in case from the
            submitted value. Present only when `state` is `matched`.
        uuid:
          type: string
          format: uuid
          description: PT product UUID. Present only when `state` is `matched`.
        status:
          type: string
          enum: [active, inactive, pre-order]
          description: |
            Product status. Present only when `state` is `matched`. There is
            no separate "unpublished" state — a matched-but-non-active product
            is still `matched` and this field distinguishes it.

    ValidateSkusResponse:
      type: object
      description: |
        Flat (unwrapped) bulk SKU-validation response. `results` preserves the
        submitted order; `summary` totals the two states.
      required: [results, summary]
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/ValidateSkusResult"
        summary:
          type: object
          properties:
            matched:
              type: integer
            unknown:
              type: integer

    PaginationMeta:
      type: object
      description: Laravel's standard pagination metadata
      properties:
        current_page:
          type: integer
        last_page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer
      example:
        current_page: 1
        last_page: 1
        per_page: 25
        total: 2

    StockUnavailableError:
      type: object
      description: |
        Returned when `POST /orders` is called with `reserve: true` but one or
        more SKUs cannot be held. The whole operation is rolled back
        atomically — no fulfilments or reservations exist after this
        response. The shop should show the customer which items are short
        and offer an amended cart.
      properties:
        message:
          type: string
          example: "Stock unavailable for one or more items"
        errors:
          type: array
          items:
            type: object
            properties:
              sku:
                type: string
              requested:
                type: integer
              available:
                type: integer

    UnknownSkusError:
      type: object
      description: |
        Returned from `POST /orders` when `strictSkus: true` and one or more
        order lines reference a SKU that matches no PT product. The whole order
        is rejected and nothing is created — no order, fulfilments, or
        reservations. This is a flat body, not the RFC-7807 API Problem shape.
      properties:
        message:
          type: string
          example: "One or more order lines reference unknown SKUs"
        unmatchedSkus:
          type: array
          description: The submitted SKUs that could not be matched.
          items:
            type: string
          example: ["NOPE", "TYPO-1"]

    WebhookPayload:
      type: object
      description: |
        Outbound webhook body POSTed to the channel's registered URL. The
        `{notificationType}` placeholder in the registered URL is substituted
        with the `notificationType` value. The request is signed with
        HMAC-SHA256 over the raw body using the channel's `webhook_secret`,
        with the signature in the `Signature` header. Event-specific fields
        are added alongside the base payload:
          - `order-status-updated` adds `originalOrderStatus` and
            `currentOrderStatus`.
          - `order-fulfillable-status-updated` adds `originalFulfillableStatus`
            and `currentFulfillableStatus` (hyphenated fulfillable-status
            values, e.g. `partially-fulfillable`, `not-fulfillable`).
      properties:
        notificationType:
          type: string
          enum:
            - order-created
            - order-status-updated
            - order-fulfillable-status-updated
            - fulfilment-status-updated
            - fulfilment-tracking-created
          example: order-status-updated
        orderNumber:
          type: string
          nullable: true
          description: The channel's own order reference.
        orderUuid:
          type: string
          format: uuid
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        tenantCode:
          type: string
        channelCode:
          type: string
        originalOrderStatus:
          type: string
          description: Present only on `order-status-updated`.
        currentOrderStatus:
          type: string
          description: Present only on `order-status-updated`.
        originalFulfillableStatus:
          type: string
          description: |
            Present only on `order-fulfillable-status-updated`. Hyphenated
            fulfillable-status value (e.g. `draft`, `fulfillable`,
            `partially-fulfillable`, `not-fulfillable`).
        currentFulfillableStatus:
          type: string
          description: |
            Present only on `order-fulfillable-status-updated`. Hyphenated
            fulfillable-status value (e.g. `draft`, `fulfillable`,
            `partially-fulfillable`, `not-fulfillable`).
        message:
          type: string
      example:
        notificationType: "order-status-updated"
        orderNumber: "ORD-10432"
        orderUuid: "c0ffee00-1234-4abc-8def-0123456789ab"
        createdAt: "2026-06-22T10:15:30+00:00"
        updatedAt: "2026-06-22T10:20:05+00:00"
        tenantCode: "riverside"
        channelCode: "shopify-main"
        originalOrderStatus: "draft"
        currentOrderStatus: "processing"
        message: "Order \"ORD-10432\" status has changed from \"draft\" to \"processing\""

    ApiProblem:
      type: object
      description: RFC 7807 API Problem response for validation errors
      properties:
        type:
          type: string
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string

tags:
  - name: Channel
    description: Channel configuration and team info
  - name: Webhooks
    description: |
      Webhook registration and management. Outbound webhook requests are
      signed with HMAC-SHA256 using the channel's `webhook_secret` and the
      signature is placed in the `Signature` request header. Receivers
      should verify `hash_equals(hash_hmac('sha256', $rawBody, $secret),
      $header)` before trusting the payload.

      The outbound webhook body sent to the registered URL is described by the
      `WebhookPayload` schema (see Schemas / `#/components/schemas/WebhookPayload`).
  - name: Products
    description: Product catalogue and stock levels
  - name: Orders
    description: Order lifecycle management