openapi: 3.1.0

# ============================================================================
# Load Optimizer API: OpenAPI 3.1 definition.
# Async load-optimization: create a job, poll it, collect the result.
# ============================================================================

info:
  title: Load Optimizer API
  version: 2.0.0
  summary: Asynchronous box-packing / load-optimization API.
  description: |-
    Submitting work is **asynchronous**: every solve is a *job* you create, poll, then collect.

    This is the documentation of the **Load Optimizer API v2**: it describes what `/v2` supports;
    what it does not describe is not supported.

    ## Job lifecycle
    `queued → running → succeeded | failed | cancelled` (last three terminal). Poll `GET /jobs/{id}` until terminal.

    ## Conventions
    Values from a fixed set (enum values, in a request body or in list filters such as `status`, `saved` and
    `sort`) are matched without regard to case, and come back in the spelling published here: `"MM"` reads
    back as `"mm"`. Names and codes keep their case: group names, a SKU's code (`externalId`, also as a
    filter) and system-catalogue slugs are case-sensitive. The idempotency fingerprint compares the body as you
    sent it: an enum value in another case makes a different body, so while the key is kept (see `Idempotency-Key`)
    the same key gets the `409`.
  contact:
    email: contact@loadoptimizer.ai

servers:
  - url: https://api.loadoptimizer.ai/v2
    description: Production environment of the Load Optimizer API.
    x-internal: false

security:
  - ApiKey: []

tags:
  - name: Jobs
    description: Submit, poll, fetch and cancel optimization jobs; loading-instruction PDFs and share links.
  - name: Saved loads
    description: Keep the runs that matter under a name, within your plan's limit per problem type.
  - name: Libraries
    description: Store your own carriers and cargo units (SKUs) once and reference them from a job by id.
  - name: Health
    description: Check that the API is up. Needs no API key and does not count against the rate limit.

paths:
  /health:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Service liveness echo
      description: >-
        Anonymous liveness check. Always answers `status: "ok"` if the API is up at all; it deliberately
        runs no readiness checks of its own. Sending your key is harmless but pointless: this operation sits
        outside the authenticated surface, so it answers the same whether you send one, send a bad one, or
        send none, and it never reports on your plan.
      security: []
      responses:
        '200':
          description: The API is up and answering requests.
          content:
            application/json:
              schema:
                type: object
                required: [status, version, apiVersion]
                properties:
                  status: { type: string, enum: [ok] }
                  version: { type: string, description: 'Opaque build identifier, e.g. `1a2b3c4d`. Compare it for equality to spot a deploy; do not parse it.' }
                  apiVersion:
                    type: string
                    pattern: '^\d+\.\d+\.\d+$'
                    description: >-
                      The version of the contract this API serves, the contract's `info.version`, such as `2.0.0`. The
                      Changelog names each release by it, so compare the two to see which changes the API you call
                      already has.
              examples:
                live:
                  summary: The API is up
                  value: { status: ok, version: '1a2b3c4d', apiVersion: '2.0.0' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs:
    get:
      tags: [Jobs]
      operationId: listJobs
      summary: List orders
      description: >-
        The orders your account submitted, a page at a time, newest first unless you sort otherwise. A
        loading-instructions job is not listed; reach it through the id `POST /jobs/{id}/instructions` returned.
        Each item is the same `JobStatus` that `GET /jobs/{id}` returns. Follow `nextCursor` until it is null,
        passing the same filters, `sort` and `direction` on every page. There is no total count. Every invalid
        parameter is reported at once, in one `400` with one `errors[]` entry per parameter, whose `pointer` is
        the parameter's name.
      parameters:
        - name: status
          in: query
          required: false
          style: form
          explode: true
          description: 'Only orders in one of these statuses. Repeat the parameter for several: `?status=succeeded&status=cancelled`.'
          schema: { type: array, items: { type: string, enum: [queued, running, succeeded, failed, cancelled] } }
        - name: problemType
          in: query
          required: false
          style: form
          explode: true
          description: 'Only orders of one of these problem types. Repeatable, like `status`; different filters combine.'
          schema: { type: array, items: { type: string, enum: [LoadPlanning, MixedPalletizing] } }
        - name: q
          in: query
          required: false
          description: 'Only orders whose `name` contains this text, ignoring case. Matched literally: `%` and `_` are ordinary characters. Leading and trailing whitespace is ignored. At most 256 characters.'
          schema: { type: string, maxLength: 256 }
        - name: createdFrom
          in: query
          required: false
          description: 'Only orders submitted at or after this moment (inclusive). An RFC 3339 date-time with an offset, e.g. `2026-09-01T00:00:00Z`; in a URL, send a `+` offset as `%2B`.'
          schema: { type: string, format: date-time, maxLength: 33 }
        - name: createdTo
          in: query
          required: false
          description: 'Only orders submitted before this moment (exclusive), so consecutive windows neither overlap nor leave a gap. Same format as `createdFrom`, and not earlier than it.'
          schema: { type: string, format: date-time, maxLength: 33 }
        - name: saved
          in: query
          required: false
          description: 'Only orders kept as a saved load (`true`), or only those that are not (`false`). Omit it for both. One value; anything else, a repeat or an empty value included, is rejected with `400` on `saved`.'
          schema: { type: boolean }
        - name: sort
          in: query
          required: false
          description: 'The order of the list. Within equal names or problem types the newest order comes first, and orders created at the same moment follow their id, in the direction you ask for under `sort=createdAt`. Under `sort=name`, orders without a name come last in both directions, and an order renamed while you page through the list can move.'
          schema: { type: string, enum: [createdAt, name, problemType], default: createdAt }
        - name: direction
          in: query
          required: false
          description: 'The sort direction: `desc` by default for `createdAt` (newest first), `asc` for `name` and `problemType`.'
          schema: { type: string, enum: [asc, desc] }
        - name: limit
          in: query
          required: false
          description: 'Orders per page, 1 to 50. Default 50.'
          schema: { type: integer, format: int32, minimum: 1, maximum: 50, default: 50 }
        - name: cursor
          in: query
          required: false
          description: 'The `nextCursor` of the previous page, passed back unchanged with the same `sort` and `direction`. A value this API did not issue, one issued by the other list, or one issued for another sort or direction, is rejected with `400`. At most 4,096 characters.'
          schema: { type: string, maxLength: 4096 }
      responses:
        '200':
          description: One page of the orders your account submitted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobPage' }
              examples:
                firstPage:
                  summary: A page with more to follow
                  value:
                    items:
                      - jobId: 01975cbc-1f40-7d21-9b3e-5a7c2e8f0d19
                        status: succeeded
                        percentage: 100
                        queuePosition: null
                        errorCategory: null
                        error: null
                        name: Rotterdam week 40
                        createdAt: '2026-09-28T08:12:33.412+00:00'
                        problemType: LoadPlanning
                        saved: true
                    nextCursor: djE6WyJjcmVhdGVkQXQiLCJkZXNjIixudWxsLCIyMDI2LTA5LTI4VDA4OjEyOjMzLjQxMiswMDowMCIsIjAxOTc1Y2JjLTFmNDAtN2QyMS05YjNlLTVhN2MyZThmMGQxOSJd
                lastPage:
                  summary: The last page
                  value:
                    items:
                      - jobId: 01975cbd-3a52-7e8f-8c14-6d0b9f3e7a52
                        status: failed
                        percentage: 50
                        queuePosition: null
                        errorCategory: solver_error
                        error: { code: solver_error, message: The solve did not complete. Please retry. }
                        name: null
                        createdAt: '2026-08-18T15:47:02.861+00:00'
                        problemType: LoadPlanning
                        saved: false
                    nextCursor: null
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

    post:
      tags: [Jobs]
      operationId: createJob
      summary: Submit an optimization job
      description: |-
        Validates the request (`400` on failure), then queues it for solving. Returns `202` with the job id
        and its status: `queued` for a new job, its current status for a replay.
        This surface has no synchronous fast path; poll `GET /jobs/{id}`
        for progress. An optional `Idempotency-Key` header (`<=200` chars) makes a repeat submit with the
        same body return the original job instead of creating a duplicate, for at least 24 hours after it
        was submitted, and for as long as it is queued or running. In that window the same key with a
        different body is rejected with `409` (empty body); after it, the same key can start a new job.
        The whole submitted body is the fingerprint, values as sent (an enum value in another case is a different body), `units`
        included: retrying the same key with different units is a different body, and gets the `409`, not
        a replay of the original job.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JobRequest' }
            examples:
              onePalletTwoBoxes:
                $ref: '#/components/examples/OnePalletTwoBoxes'
              mixedPalletsForOneStore:
                $ref: '#/components/examples/MixedPalletsForOneStore'
              cargoFromSkuLibrary:
                $ref: '#/components/examples/CargoFromSkuLibrary'
      responses:
        '202':
          description: Accepted. A new job is queued; a replay of an earlier submit carries the original job in the status it has now.
          headers:
            Location:
              description: URL of the created job.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobAccepted' }
              examples:
                queued:
                  summary: Job accepted, nothing solved yet
                  value: { jobId: 01975cba-67b9-7b7c-a2f1-3d29e4a01c44, status: queued }
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: >-
            Either the account's plan does not include customer-API access at all (`code:
            "customer_api_not_entitled"`), or it does not include the job's `problemType`: `code:
            "mixed_palletizing_not_entitled"` for a `MixedPalletizing` job, `code:
            "load_planning_not_entitled"` for `LoadPlanning`. A carrier or cargo line referencing a company-library
            entry (a UUID `libraryId`) answers `code: "libraries_not_entitled"`, before any lookup, when that type of
            library is not available on the plan: a type is available when the plan includes company libraries
            and its own size limit is not 0. Distinct from the `422` below, which means the order itself is
            too big, not that the capability is missing; also distinct from the `429` quota, which means the
            capability is there but used up for the month.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                notEntitled:
                  $ref: '#/components/examples/NotEntitled'
                mixedPalletizingNotEntitled:
                  summary: The plan does not include mixed palletizing
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4'
                    title: Forbidden
                    status: 403
                    code: mixed_palletizing_not_entitled
                    detail: This problem type is not available on your plan.
                librariesNotEntitled:
                  summary: A carrier or cargo line references a company-library entry of a type the plan does not include
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4'
                    title: Company libraries are not available on your plan.
                    status: 403
                    code: libraries_not_entitled
                    detail: Your plan does not include company libraries.
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/EntitlementProblem' }
        '429': { $ref: '#/components/responses/TooManyActiveJobs' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/validate:
    post:
      tags: [Jobs]
      operationId: validateJob
      summary: Dry-run validation of a request without creating a job
      description: |-
        Runs the same validation as `POST /jobs`, library references included, but creates no job, triggers no
        solve, and runs no plan-limit, problem-type or quota check of its own. The one access check it shares with
        `POST /jobs` is the company-library gate: a carrier or cargo line referencing a company-library entry (a
        UUID `libraryId`) of a type the plan does not include answers `403 libraries_not_entitled` here too, before
        any lookup. A body that validates here can still be rejected by `POST /jobs` with `403`/`422`/`429`. Every
        validation failure comes back in the same code-bearing `problem+json` shape here as on `POST /jobs`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JobRequest' }
            examples:
              onePalletTwoBoxes:
                $ref: '#/components/examples/OnePalletTwoBoxes'
              mixedPalletsForOneStore:
                $ref: '#/components/examples/MixedPalletsForOneStore'
              cargoFromSkuLibrary:
                $ref: '#/components/examples/CargoFromSkuLibrary'
      responses:
        '200':
          description: 'Valid. `warnings` is reserved and always empty today.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidateResult' }
              examples:
                valid:
                  summary: The request validates
                  value: { valid: true, warnings: [] }
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibrariesForbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Poll job status
      description: >-
        The job's status and progress, for an order or a loading-instructions job alike. Poll it until `status`
        is `succeeded`, `failed` or `cancelled`; those three are final. After `succeeded`, fetch the result with
        `GET /jobs/{id}/result` (an order) or the PDFs with `GET /jobs/{id}/instructions` (an instructions job).
      responses:
        '200':
          description: >-
            Current status. A job that fails during solving still answers `200` here: the failure is in the
            body (`status: "failed"`, `errorCategory`, `error`), not an HTTP error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobStatus' }
              examples:
                running:
                  summary: Mid-solve
                  value:
                    jobId: 01975cba-67b9-7b7c-a2f1-3d29e4a01c44
                    status: running
                    percentage: 50
                    queuePosition: null
                    errorCategory: null
                    error: null
                    name: null
                    createdAt: '2026-08-19T08:12:33.412+00:00'
                    problemType: LoadPlanning
                    saved: false
                succeeded:
                  summary: Terminal, the result can be collected
                  value:
                    jobId: 01975cba-67b9-7b7c-a2f1-3d29e4a01c44
                    status: succeeded
                    percentage: 100
                    queuePosition: null
                    errorCategory: null
                    error: null
                    name: Rotterdam morning run
                    createdAt: '2026-08-19T08:12:33.412+00:00'
                    problemType: LoadPlanning
                    saved: false
                failed:
                  summary: Terminal, and the failure is in the body rather than the status code
                  value:
                    jobId: 01975cbd-3a52-7e8f-8c14-6d0b9f3e7a52
                    status: failed
                    percentage: 50
                    queuePosition: null
                    errorCategory: solver_error
                    error: { code: solver_error, message: The solve did not complete. Please retry. }
                    name: null
                    createdAt: '2026-08-18T15:47:02.861+00:00'
                    problemType: LoadPlanning
                    saved: false
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFoundProblem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}/result:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    get:
      tags: [Jobs]
      operationId: getJobResult
      summary: Fetch the packing result (once succeeded)
      description: >-
        The packing result of an order, once `GET /jobs/{id}` reports `succeeded`: each carrier with its
        placements and KPIs, the cargo that did not fit, and the totals, in the units the job was submitted in.
        Before that it answers `404 result_not_ready`.
      responses:
        '200':
          description: The packing result, in the units the job was submitted in.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobResult' }
              examples:
                onePallet:
                  summary: Both units of one cargo line on a single pallet
                  value:
                    jobId: 01975cba-67b9-7b7c-a2f1-3d29e4a01c44
                    status: succeeded
                    units: { length: m, weight: kg, volume: m3, area: m2, ldm: m }
                    summary: { carriersUsed: 1, itemsPlaced: 2, itemsUnplaced: 0, totalWeight: 10.0 }
                    carriers:
                      - externalId: EUR-PALLET
                        name: null
                        instance: 1
                        geometry: { type: box, dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.5, box: { dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.5 } }
                        kpis: { volumeUtilization: 3.3, loadedWeight: 10.0, loadedVolume: 0.048, itemCount: 2 }
                        placements:
                          - x: 0.0
                            y: 0.0
                            z: 0.0
                            sizeX: 0.3
                            sizeY: 0.4
                            sizeZ: 0.2
                            orientationRx: 0
                            orientationRy: 0
                            orientationRz: 0
                            weight: 5.0
                            geometry: { type: box, dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2, box: { dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2 } }
                            description: Box A
                            color: null
                            externalId: SKU-123
                            category: box
                            group: []
                            groups: []
                            itemId: 01975cba-6a1f-7c3d-9e04-5b71c8f2a613
                            libraryId: null
                          - x: 0.3
                            y: 0.0
                            z: 0.0
                            sizeX: 0.4
                            sizeY: 0.3
                            sizeZ: 0.2
                            orientationRx: 0
                            orientationRy: 0
                            orientationRz: 90
                            weight: 5.0
                            geometry: { type: box, dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2, box: { dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2 } }
                            description: Box A
                            color: null
                            externalId: SKU-123
                            category: box
                            group: []
                            groups: []
                            itemId: 01975cba-6a1f-7c3d-9e04-5b71c8f2a613
                            libraryId: null
                        category: container
                        libraryId: null
                    unplacedItems: []
                    name: Rotterdam morning run
                    createdAt: '2026-08-19T08:12:33.412+00:00'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: >-
            The job exists but has not succeeded yet (`code: "result_not_ready"`), or the id is unknown or
            belongs to another account (`code: "job_not_found"`), both answer identically, on purpose.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                notReadyYet:
                  summary: The job exists but has not succeeded
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.5'
                    title: Result not ready.
                    status: 404
                    code: result_not_ready
                unknownJob:
                  $ref: '#/components/examples/JobNotFound'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}/input:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    get:
      tags: [Jobs]
      operationId: getJobInput
      summary: Fetch the request of an order, with library references resolved
      description: >-
        The request of this order, with every library reference resolved to the values the solver used: the
        `JobRequest` shape under `request`, with any name trimmed and members the API does not know left out.
        Every line comes back inline. A carrier or cargo line that referenced a library entry carries the entry's
        definition as frozen into the job at submit: the sizes and weights the solver used, overrides included, in the
        job's units, and on a cargo line the effective `constraints`. A carrier with no label on the line or the entry
        comes back with the entry's id as its `externalId`, because an inline carrier needs one; `/result` shows
        `null` for it. `sources` lists each entry with the `revision` used and the `lines` of `request` it resolved.
        Enum values come back in the spelling listed here, whatever case you sent them in. A `direction` on
        `CenterLoadMass`, which the API ignores, is left out. A cargo line whose stored category the closed set does
        not know (a job from before that set was closed) reads back as `box`. Use it for what the result does
        not repeat: the size and rules of a cargo line that was not placed, or the carriers you asked for. A later
        rename changes the job's `name`, not this request, and a later change to a library entry changes neither this
        request nor its `sources`. `resolvedAt` is the order's `createdAt`. Orders only, in any status.
      responses:
        '200':
          description: The request, every line inline.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobInput' }
              examples:
                onePalletTwoBoxes:
                  summary: The request of the "one pallet type, two units" order
                  value:
                    resolvedAt: '2026-08-19T08:12:33.412+00:00'
                    request:
                      units: { length: m, weight: kg }
                      carriers:
                        - externalId: EUR-PALLET
                          geometry: { type: box, dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.5 }
                      cargo:
                        - externalId: SKU-123
                          description: Box A
                          amount: 2
                          weight: 5.0
                          geometry: { type: box, dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2 }
                    sources: []
                skuReferences:
                  summary: 'The request of the order that referenced SKUs: every line inline, `sources` naming the SKU and the lines it resolved'
                  value:
                    resolvedAt: '2026-09-30T10:02:11.508+00:00'
                    request:
                      units: { length: m, weight: kg }
                      carriers:
                        - externalId: TRAILER
                          geometry: { type: box, dimensionX: 13.6, dimensionY: 2.48, dimensionZ: 2.7 }
                      cargo:
                        # Sent as a reference with an allowStackingOnTop override: the SKU's values, written out.
                        - externalId: SKU-4711
                          description: Carton 60x40
                          color: '#3366cc'
                          amount: 24
                          geometry: { type: box, dimensionX: 0.6, dimensionY: 0.4, dimensionZ: 0.4 }
                          weight: 18
                          category: box
                          constraints: { allowStackingOnTop: true, allowedRotations: [Z] }
                          groups: [store-104]
                        # The same SKU with its own label, and the empty allowedRotations override that locks it.
                        - externalId: SKU-4711
                          description: Carton 60x40, this side up
                          amount: 6
                          geometry: { type: box, dimensionX: 0.6, dimensionY: 0.4, dimensionZ: 0.4 }
                          weight: 18
                          category: box
                          constraints: { allowStackingOnTop: true, allowedRotations: [] }
                        - externalId: SPOT-ITEM
                          description: Loose crate
                          amount: 2
                          weight: 30
                          geometry: { type: box, dimensionX: 0.8, dimensionY: 0.6, dimensionZ: 0.5 }
                    sources:
                      - libraryId: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
                        revision: 1
                        lines: ['/cargo/0', '/cargo/1']
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: >-
            The id is unknown, another account's, or a loading-instructions job (`code: "job_not_found"`).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                unknownJob:
                  $ref: '#/components/examples/JobNotFound'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}/name:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    put:
      tags: [Jobs]
      operationId: setJobName
      summary: Set, change or clear a job's name
      description: >-
        Replaces the job's optional label. Send `null`, or omit the key entirely, to clear it; there is no
        `DELETE`. Works in any job status, including a terminal one, and on a loading-instructions job as well
        as on an order.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: [string, 'null']
                  maxLength: 256
                  description: >-
                    The new label. `null`, an absent key (a body of `{}`), or a value that is only whitespace
                    all clear the name; anything else is trimmed and stored.
            examples:
              set:
                summary: Give the job a label
                value: { name: Rotterdam morning run }
              clear:
                summary: Remove the label again
                value: { name: null }
      responses:
        '200':
          description: The name now stored on the job, after trimming.
          content:
            application/json:
              schema:
                type: object
                required: [jobId, name]
                properties:
                  jobId: { type: string, format: uuid }
                  name: { type: [string, 'null'] }
              examples:
                set:
                  summary: The label as stored
                  value: { jobId: 01975cba-67b9-7b7c-a2f1-3d29e4a01c44, name: Rotterdam morning run }
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFoundProblem' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}/cancel:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    post:
      tags: [Jobs]
      operationId: cancelJob
      summary: Cancel a job (cooperative)
      description: >-
        A queued job is finalized to `cancelled` immediately. A running job only gets a cooperative cancel
        flag (the job winds down at its next checkpoint), so poll the status rather than assuming the job is
        already dead on `202`.
      responses:
        '202': { description: Cancel accepted (job was queued or running). Empty body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFoundProblem' }
        '409':
          description: 'Job already reached a terminal state; nothing to cancel (`code: "job_already_terminal"`).'
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                alreadyTerminal:
                  summary: The job already finished, failed or was cancelled
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.10'
                    title: Job already finished.
                    status: 409
                    code: job_already_terminal
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}/instructions:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    post:
      tags: [Jobs]
      operationId: createInstructions
      summary: Generate step-by-step loading-instruction PDFs
      description: >-
        Creates a *derived* job that renders one loading-instruction PDF per packed carrier instance of the
        source job, which must have already succeeded. Poll the returned (new) job id with `GET /jobs/{id}`
        until it has succeeded, then download each carrier's PDF via `GET /jobs/{id}/instructions?carrier={n}`
        with that same new id.
      responses:
        '202':
          description: Accepted. The instructions job is queued.
          headers:
            Location:
              description: 'URL of the instructions job this call created: poll it with `GET /jobs/{id}`.'
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobAccepted' }
              examples:
                queued:
                  summary: A derived job, with its own id
                  value: { jobId: 01975e02-7c7c-7000-8000-00000000abcd, status: queued }
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: >-
            Either the account's plan does not include customer-API access (`code:
            "customer_api_not_entitled"`), or the request carried a body and this operation accepts none
            (`code: "request_body_not_supported"`). Send it without a body.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                bodyNotSupported:
                  $ref: '#/components/examples/BodyNotSupported'
                notEntitled:
                  $ref: '#/components/examples/NotEntitled'
        '404': { $ref: '#/components/responses/NotFoundProblem' }
        '409':
          description: 'Source job has not succeeded; nothing to render (`code: "job_not_succeeded"`).'
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                sourceNotSucceeded:
                  summary: Nothing to render yet
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.10'
                    title: Source job has not succeeded.
                    status: 409
                    code: job_not_succeeded
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    get:
      tags: [Jobs]
      operationId: downloadInstructions
      summary: Download one carrier's loading-instruction PDF
      description: >-
        Here `{id}` is the *instructions* job id returned by `POST /jobs/{id}/instructions`, not the source job: any
        other id, the source order's included, answers `404 job_not_found`. Download once the instructions job
        has succeeded. One PDF per packed carrier; there is no ZIP bundling every carrier. `carrier` selects which one.
      parameters:
        - name: carrier
          in: query
          description: >-
            The 1-based position of the carrier in the source job result's `carriers[]` array, NOT the carrier's
            `instance` number, which restarts at 1 per carrier type. Omit for the first carrier. A value that
            is not a whole number of 1 or greater is rejected with `400` (`code: "validation_failed"`,
            `pointer: "carrier"`); a positive index whose PDF does not exist answers `404`
            `artifact_not_ready`, the same as one that has not finished rendering; once the instructions job has
            succeeded, that answer is final.
          schema: { type: integer, format: int32, minimum: 1, maximum: 2147483647 }
      responses:
        '200':
          description: One carrier's loading-instruction PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: >-
            Either `artifact_not_ready`: the instructions job is yours but this PDF is not available. While the job is
            queued or running, poll `GET /jobs/{id}` until it succeeds; once it has succeeded the answer is final
            (no PDF at that position, or swept by retention), so request instructions again. Or `job_not_found`: the
            id is unknown, another account's, or not an instructions job (for example the source order's id).
            There is no `410` for a retention-swept artifact.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                artifactNotReady:
                  summary: Not rendered yet (poll the job), or not there at all once the job has succeeded
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.5'
                    title: Instructions are not ready.
                    status: 404
                    code: artifact_not_ready
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /jobs/{id}/share:
    parameters: [ { $ref: '#/components/parameters/JobId' } ]
    post:
      tags: [Jobs]
      operationId: createShareLink
      summary: Create a read-only shareable link to the 3D viewer
      description: |-
        Mints a **capability link** to the read-only 3D viewer, on a job that has already succeeded. The link
        you hand out is the response's **`url`**: it opens the viewer in a browser and needs no API key, so
        you can send it to people who aren't users. It exposes only this job's visual result; it does not
        expose the job id, the input, other jobs, or any account data, and it can't call the API.

        The `shareId` is the opaque, unguessable token the link carries (NOT the job id). Re-sharing rotates
        it: there is only ever one active link per job, and the previous one stops working immediately.
        Revoke with `DELETE`; the link also honours `expiresAt`.
      responses:
        '201':
          description: Share link created. Hand out its `url`.
          headers:
            Location:
              description: >-
                Not a URL to hand out and not part of this API: requesting it answers `401`. Share `url` instead.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ShareLink' }
              examples:
                created:
                  summary: Link minted, hand out the `url`
                  value:
                    shareId: YzIzaeQtWUysVuPJToFCCHvXpQKZx1ftQAjgFcAuqDI
                    url: https://viewer.loadoptimizer.ai/YzIzaeQtWUysVuPJToFCCHvXpQKZx1ftQAjgFcAuqDI
                    expiresAt: '2027-03-01T12:00:00+00:00'
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: >-
            Either the account's plan does not include customer-API access (`code:
            "customer_api_not_entitled"`), or it does not include link sharing (`code:
            "link_sharing_not_entitled"`; checked before the job lookup, so a job id that isn't yours never
            leaks through this path either), or the request carried a body and this operation accepts none
            (`code: "request_body_not_supported"`). Send it without a body. The entitlement is checked first, so a caller
            without link sharing that also sends a body hears `link_sharing_not_entitled`.
            Revoking is never gated: only
            minting a new link is a paid feature.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                bodyNotSupported:
                  $ref: '#/components/examples/BodyNotSupported'
                notEntitled:
                  $ref: '#/components/examples/NotEntitled'
                linkSharingNotEntitled:
                  summary: The plan does not include link sharing
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4'
                    title: Share links are not included in your plan.
                    status: 403
                    code: link_sharing_not_entitled
        '404': { $ref: '#/components/responses/NotFoundProblem' }
        '409':
          description: 'Job has not succeeded; nothing to share (`code: "job_not_succeeded"`).'
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                jobNotSucceeded:
                  summary: Nothing to share yet
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.10'
                    title: Job has not succeeded.
                    status: 409
                    code: job_not_succeeded
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    delete:
      tags: [Jobs]
      operationId: revokeShareLink
      summary: Revoke the shareable link
      description: Idempotent. A job with no active link still answers `204`.
      responses:
        '204': { description: Revoked (or there was nothing to revoke). }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFoundProblem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /saved-loads:
    post:
      tags: [Saved loads]
      operationId: saveLoad
      summary: Save a run as a saved load
      description: >-
        Keeps one of your succeeded orders as a saved load, under the name you give it. A saved load is the run
        itself, kept: it has the run's id, and the run's request (`/jobs/{id}/input`), result, instructions and
        share link stay where they are, under `/jobs/{id}/...`. The name is the run's name, so
        saving sets it and `PUT /jobs/{id}/name` changes it for both. Your plan allows a number of saved loads
        per problem type. At that limit the save is refused with `422`. A valid `replace` names one of your saved
        loads of the same problem type, which is released in the same call whether or not you are at the limit; a
        refused save releases nothing. Saving a run that is already saved
        answers `409` and changes nothing, so a retry after a timeout tells you the first attempt landed.
      parameters:
        - name: jobId
          in: query
          required: true
          description: 'The id of the run to save: one of your orders that has succeeded. A value that is not a UUID in its 36-character form is rejected with `400` against `jobId`.'
          schema: { type: string, format: uuid, maxLength: 36 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 256
                  description: 'The name to keep it under. Trimmed, and stored on the run.'
                replace:
                  type: [string, 'null']
                  format: uuid
                  maxLength: 36
                  description: 'The id of one of your saved loads of the same problem type, released in the same call whether or not you are at the limit; a refused save releases nothing. A value that is not a UUID in its 36-character form, or that names a saved load of another problem type, is rejected with `400` against `/replace`.'
            examples:
              save:
                summary: Save a run
                value: { name: Rotterdam week 40 }
              replace:
                summary: Save it in place of an older one
                value: { name: Rotterdam week 41, replace: 01975cbe-8d17-7a64-b2c9-4f06a1d3e5b8 }
      responses:
        '201':
          description: Saved. The run is now a saved load.
          headers:
            Location:
              description: URL of the saved load.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SavedLoad' }
              examples:
                saved:
                  summary: The run, kept as a saved load
                  value:
                    id: 01975cbc-1f40-7d21-9b3e-5a7c2e8f0d19
                    name: Rotterdam week 40
                    problemType: LoadPlanning
                    createdAt: '2026-09-28T08:12:33.412+00:00'
                    savedAt: '2026-10-01T14:03:10.007+00:00'
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: >-
            The run is not one of your orders (`code: "job_not_found"`: unknown, another account's, or a
            loading-instructions job), or `replace` names no saved load of yours (`code: "saved_load_not_found"`).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                unknownJob:
                  $ref: '#/components/examples/JobNotFound'
                unknownSavedLoad:
                  $ref: '#/components/examples/SavedLoadNotFound'
        '409':
          description: >-
            The run has not succeeded (`code: "job_not_succeeded"`), or it is already saved
            (`code: "saved_load_exists"`; nothing was changed).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                alreadySaved:
                  summary: The run is already a saved load
                  value:
                    type: 'https://tools.ietf.org/html/rfc9110#section-15.5.10'
                    title: This run is already saved.
                    status: 409
                    code: saved_load_exists
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422':
          description: >-
            Your plan's saved-load limit for this problem type is reached (`code: "max_saved_loads"`); `detail`
            names the limit. Release a saved load, or name it in `replace`.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                limitReached:
                  summary: Every place for this problem type is taken
                  value:
                    type: 'https://tools.ietf.org/html/rfc4918#section-11.2'
                    title: Saved-load limit reached.
                    status: 422
                    code: max_saved_loads
                    detail: The account keeps at most 10 saved loads of this problem type.
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    get:
      tags: [Saved loads]
      operationId: listSavedLoads
      summary: List saved loads
      description: >-
        Your saved loads, a page at a time, the most recently saved first unless you sort otherwise. Each item is the
        same `SavedLoad` that `GET /saved-loads/{id}` returns. Follow `nextCursor` until it is null, passing the same
        filters, `sort` and `direction` on every page. There is no total count. Every invalid parameter is reported
        at once, in one `400` with one `errors[]` entry per parameter, whose `pointer` is the parameter's name.
      parameters:
        - name: problemType
          in: query
          required: false
          style: form
          explode: true
          description: 'Only saved loads of one of these problem types. Repeat the parameter for several.'
          schema: { type: array, items: { type: string, enum: [LoadPlanning, MixedPalletizing] } }
        - name: q
          in: query
          required: false
          description: 'Only saved loads whose `name` contains this text, ignoring case. Matched literally: `%` and `_` are ordinary characters. Leading and trailing whitespace is ignored. At most 256 characters.'
          schema: { type: string, maxLength: 256 }
        - name: savedFrom
          in: query
          required: false
          description: 'Only loads saved at or after this moment (inclusive). An RFC 3339 date-time with an offset; in a URL, send a `+` offset as `%2B`.'
          schema: { type: string, format: date-time, maxLength: 33 }
        - name: savedTo
          in: query
          required: false
          description: 'Only loads saved before this moment (exclusive). Same format as `savedFrom`, and not earlier than it.'
          schema: { type: string, format: date-time, maxLength: 33 }
        - name: sort
          in: query
          required: false
          description: 'The order of the list: when it was saved, when the run was submitted, its name, or its problem type. Within equal names or problem types the most recently saved comes first. Within equal times (`savedAt`, `createdAt`) the order follows the id of the run, in the direction you ask for. Under `sort=name`, a load whose name was cleared comes last in both directions.'
          schema: { type: string, enum: [savedAt, createdAt, name, problemType], default: savedAt }
        - name: direction
          in: query
          required: false
          description: 'The sort direction: `desc` by default for `savedAt` and `createdAt` (newest first), `asc` for `name` and `problemType`.'
          schema: { type: string, enum: [asc, desc] }
        - name: limit
          in: query
          required: false
          description: 'Saved loads per page, 1 to 50. Default 50.'
          schema: { type: integer, format: int32, minimum: 1, maximum: 50, default: 50 }
        - name: cursor
          in: query
          required: false
          description: 'The `nextCursor` of the previous page, passed back unchanged with the same `sort` and `direction`. A value this API did not issue, one issued by the other list, or one issued for another sort or direction, is rejected with `400`. At most 4,096 characters.'
          schema: { type: string, maxLength: 4096 }
      responses:
        '200':
          description: One page of saved loads.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SavedLoadPage' }
              examples:
                onePage:
                  summary: Everything fits on one page
                  value:
                    items:
                      - id: 01975cbc-1f40-7d21-9b3e-5a7c2e8f0d19
                        name: Rotterdam week 40
                        problemType: LoadPlanning
                        createdAt: '2026-09-28T08:12:33.412+00:00'
                        savedAt: '2026-10-01T14:03:10.007+00:00'
                      - id: 01975cbe-8d17-7a64-b2c9-4f06a1d3e5b8
                        name: Rotterdam week 39
                        problemType: LoadPlanning
                        createdAt: '2026-09-21T08:12:33.412+00:00'
                        savedAt: '2026-09-24T09:30:00.000+00:00'
                    nextCursor: null
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /saved-loads/{id}:
    parameters: [ { $ref: '#/components/parameters/SavedLoadId' } ]
    get:
      tags: [Saved loads]
      operationId: getSavedLoad
      summary: Get a saved load
      description: >-
        One of your saved loads, by its id, which is also its run's job id. An unknown id, a released load and
        another account's load all answer `404`.
      responses:
        '200':
          description: The saved load. Its request (`/jobs/{id}/input`), result, instructions and share link are under `/jobs/{id}/...`, with the same id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SavedLoad' }
              examples:
                saved:
                  summary: A saved load
                  value:
                    id: 01975cbc-1f40-7d21-9b3e-5a7c2e8f0d19
                    name: Rotterdam week 40
                    problemType: LoadPlanning
                    createdAt: '2026-09-28T08:12:33.412+00:00'
                    savedAt: '2026-10-01T14:03:10.007+00:00'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/SavedLoadNotFoundProblem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    delete:
      tags: [Saved loads]
      operationId: deleteSavedLoad
      summary: Release a saved load
      description: >-
        Stops keeping the run, so it no longer counts against your limit. The run itself is untouched: its result and
        name stay, and `GET /jobs?saved=false` lists it again. To undo, save it again. Never limited by your plan, so
        an account over its limit can always make room.
      responses:
        '204': { description: Released. The run itself is untouched. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/SavedLoadNotFoundProblem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

  /libraries:
    get:
      tags: [Libraries]
      operationId: listLibraries
      summary: List the libraries you can use
      description: >-
        The libraries your account can use. The system catalogue is listed first: it needs no company-library
        entitlement, so any plan that can use the customer API can read and reference it, and
        `libraries_not_entitled` never applies to a slug. Every company library type follows it when it is
        available, carriers first and then SKUs: a type is available when the plan includes company libraries
        and its own size limit is not 0. A type that is not available is left out of the list rather than
        answered with an error, so without company libraries the list holds only the system catalogue.
      parameters:
        - { name: type, in: query, required: false, description: 'Only libraries of this kind: `carrier` or `sku`. Any other value is rejected with `400`.', schema: { type: string, enum: [carrier, sku] } }
        - { name: scope, in: query, required: false, description: 'Only `company` or only `system` libraries. `system` returns the catalogue. Any other value is rejected with `400`.', schema: { type: string, enum: [system, company] } }
      responses:
        '200':
          description: The libraries you can use.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LibraryList' }
              examples:
                companyLibrary:
                  summary: A plan with company libraries, two carriers and three SKUs saved
                  value:
                    items:
                      - { id: carrier_system_default, name: Standard carriers, type: carrier, scope: system, overridable: true, entryCount: 49, access: reference }
                      - { id: 5f0c6a8e-3b1d-5e2a-9c47-81d2f6a0b3e4, name: Company carriers, type: carrier, scope: company, overridable: true, entryCount: 2, access: edit }
                      - { id: 9b3d7e21-4c6a-5f18-8e2b-6a0d4c9f1e73, name: Company SKUs, type: sku, scope: company, overridable: true, entryCount: 3, access: edit }
                companySkuLibrary:
                  summary: 'Only the SKU library: `?type=sku`'
                  value:
                    items:
                      - { id: 9b3d7e21-4c6a-5f18-8e2b-6a0d4c9f1e73, name: Company SKUs, type: sku, scope: company, overridable: true, entryCount: 3, access: edit }
                noCompanyLibraries:
                  summary: 'A plan without company libraries: only the system library'
                  value:
                    items:
                      - { id: carrier_system_default, name: Standard carriers, type: carrier, scope: system, overridable: true, entryCount: 49, access: reference }
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
  /libraries/{id}:
    parameters: [ { $ref: '#/components/parameters/LibraryId' } ]
    get:
      tags: [Libraries]
      operationId: getLibrary
      summary: Get one library
      description: One library you can use, by the id that `GET /libraries` lists for it.
      responses:
        '200':
          description: The library, as `GET /libraries` lists it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LibrarySummary' }
              examples:
                companyLibrary:
                  summary: Your company carrier library
                  value: { id: 5f0c6a8e-3b1d-5e2a-9c47-81d2f6a0b3e4, name: Company carriers, type: carrier, scope: company, overridable: true, entryCount: 2, access: edit }
                companySkuLibrary:
                  summary: Your company SKU library
                  value: { id: 9b3d7e21-4c6a-5f18-8e2b-6a0d4c9f1e73, name: Company SKUs, type: sku, scope: company, overridable: true, entryCount: 3, access: edit }
                systemLibrary:
                  summary: The system catalogue
                  value: { id: carrier_system_default, name: Standard carriers, type: carrier, scope: system, overridable: true, entryCount: 49, access: reference }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibrariesForbidden' }
        '404': { $ref: '#/components/responses/LibraryNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
  /libraries/{id}/entries:
    parameters: [ { $ref: '#/components/parameters/LibraryId' } ]
    get:
      tags: [Libraries]
      operationId: listLibraryEntries
      summary: List the entries in a library
      description: >-
        The entries in the order they were created, as full definitions, a page at a time: carriers in a carrier
        library, SKUs in the SKU library. Follow `nextCursor` until it is null. Never lists an archived entry: a
        retired one stops appearing here as well as by id. `q` and `externalId` combine: an entry must match both.
      parameters:
        - { name: q, in: query, required: false, description: 'Only entries whose `name` or `externalId` contains this text, ignoring case. Matched literally: `%` and `_` are ordinary characters. At most 256 characters.', schema: { type: string, maxLength: 256 } }
        - { name: externalId, in: query, required: false, description: 'Only entries with exactly this externalId, case-sensitive. On a SKU library at most one, because a SKU code is unique there; a carrier library allows duplicate labels, so it may return several. At most 128 characters.', schema: { type: string, maxLength: 128 } }
        - { name: limit, in: query, required: false, description: 'Entries per page, 1 to 200. Default 100.', schema: { type: integer, format: int32, minimum: 1, maximum: 200, default: 100 } }
        - { name: cursor, in: query, required: false, description: 'The `nextCursor` of the previous page of the same library, passed back unchanged. A value this API did not issue, or one issued for another library, is rejected with `400`. At most 4,096 characters.', schema: { type: string, maxLength: 4096 } }
      responses:
        '200':
          description: One page of entries.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LibraryEntryPage' }
              examples:
                firstPage:
                  summary: The first page, one more to follow
                  value:
                    items:
                      - id: 0197c3a0-5b1e-7c2d-9a41-2f6e8d0b11a1
                        type: carrier
                        scope: company
                        externalId: TRL-136
                        name: Trailer 13.6
                        note: Tail lift for the northern route
                        category: trailer
                        region: null
                        units: { length: mm, weight: kg }
                        geometry: { type: box, dimensionX: 13600, dimensionY: 2480, dimensionZ: 2700 }
                        tareWeight: 7200
                        revision: 1
                        updatedAt: '2026-09-24T08:12:33.412+00:00'
                    nextCursor: ZTE6NWYwYzZhOGUtM2IxZC01ZTJhLTljNDctODFkMmY2YTBiM2U0OjM
                systemLibrary:
                  summary: Browsing the system catalogue
                  value:
                    items:
                      - id: carrier_eur_pallet
                        type: carrier
                        scope: system
                        externalId: null
                        name: EPAL / EUR 1
                        note: null
                        category: pallet
                        region: europe
                        units: { length: mm, weight: kg }
                        geometry: { type: box, dimensionX: 1200, dimensionY: 800, dimensionZ: 1800 }
                        tareWeight: null
                        revision: 1
                        updatedAt: '2026-09-25T00:00:00.001+00:00'
                    nextCursor: null
                skuByCode:
                  summary: 'One SKU looked up by its code: `?externalId=SKU-4711`'
                  value:
                    items:
                      - id: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
                        type: sku
                        scope: company
                        externalId: SKU-4711
                        name: Carton 60x40
                        note: null
                        category: box
                        units: { length: mm, weight: kg }
                        geometry: { type: box, dimensionX: 600, dimensionY: 400, dimensionZ: 400 }
                        weight: 18
                        constraints: { allowStackingOnTop: true, allowedRotations: [Z] }
                        revision: 1
                        updatedAt: '2026-09-30T09:41:07.226+00:00'
                    nextCursor: null
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibrariesForbidden' }
        '404': { $ref: '#/components/responses/LibraryNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    post:
      tags: [Libraries]
      operationId: createLibraryEntry
      summary: Save an entry in a company library
      description: >-
        Stores an entry in its own units (metres and kilograms when `units` is omitted). The library decides the
        body: your company carrier library takes a `LibraryEntryInput`, your company SKU library a
        `SkuEntryInput`. A member of the other type is ignored like any unknown member, so a body sent to the
        wrong library fails on that library's own required fields: a SKU needs `weight`, a carrier needs a
        carrier `category`. On a carrier `category` is required, unlike on an inline job carrier, because a
        stored carrier must not change kind with the job that uses it. A SKU's `externalId`, when set, is unique
        within the SKU library and case-sensitive: a code another SKU already has answers `409
        external_id_taken`. A replay of the same `Idempotency-Key` and body is checked first and still answers
        `201`; the key holds as long as the entry it created exists.
        The same size rules apply as to an inline carrier or cargo line. A response body posted back as a
        request creates a copy: the read-only fields are ignored (on the SKU library, change or drop its
        `externalId` first).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              description: 'The library in the path decides which of the two shapes applies: your company carrier library takes a `LibraryEntryInput`, your company SKU library a `SkuEntryInput`. Members of the other shape are ignored, and an optional member sent as `null` counts as absent.'
              anyOf:
                - $ref: '#/components/schemas/LibraryEntryInput'
                - $ref: '#/components/schemas/SkuEntryInput'
            examples:
              trailerInMillimetres:
                summary: A trailer, written in millimetres and kilograms
                value: { externalId: TRL-136, name: Trailer 13.6, note: Tail lift for the northern route, category: trailer, units: { length: mm, weight: kg }, geometry: { type: box, dimensionX: 13600, dimensionY: 2480, dimensionZ: 2700 }, tareWeight: 7200 }
              palletInMetres:
                summary: A pallet with units omitted (metres and kilograms)
                value: { name: Our EUR pallet, category: pallet, geometry: { type: box, dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.8 } }
              skuCarton:
                summary: 'A SKU, to your company SKU library: a carton in millimetres and kilograms'
                value: { externalId: SKU-4711, name: Carton 60x40, category: box, units: { length: mm, weight: kg }, geometry: { type: box, dimensionX: 600, dimensionY: 400, dimensionZ: 400 }, weight: 18, constraints: { allowStackingOnTop: true } }
      responses:
        '201':
          description: Saved. `Location` is the entry's URL.
          headers:
            Location:
              description: URL of the new entry.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LibraryEntry' }
              examples:
                trailer:
                  summary: The saved trailer, echoed in its own units
                  value:
                    id: 0197c3a0-5b1e-7c2d-9a41-2f6e8d0b11a1
                    type: carrier
                    scope: company
                    externalId: TRL-136
                    name: Trailer 13.6
                    note: Tail lift for the northern route
                    category: trailer
                    region: null
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 13600, dimensionY: 2480, dimensionZ: 2700 }
                    tareWeight: 7200
                    revision: 1
                    updatedAt: '2026-09-24T08:12:33.412+00:00'
                sku:
                  summary: 'The saved SKU, its constraints stored filled in: `allowedRotations` was omitted and reads `["Z"]`'
                  value:
                    id: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
                    type: sku
                    scope: company
                    externalId: SKU-4711
                    name: Carton 60x40
                    note: null
                    category: box
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 600, dimensionY: 400, dimensionZ: 400 }
                    weight: 18
                    constraints: { allowStackingOnTop: true, allowedRotations: [Z] }
                    revision: 1
                    updatedAt: '2026-09-30T09:41:07.226+00:00'
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibraryWriteForbidden' }
        '404': { $ref: '#/components/responses/LibraryNotFound' }
        '409':
          description: >-
            Either the `Idempotency-Key` was reused with a different body, which answers with an empty body (no
            code, no detail), or, on the SKU library, another SKU already has this `externalId` (`code:
            "external_id_taken"`). The replay check comes first, so a genuine retry still answers `201`.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                externalIdTaken:
                  $ref: '#/components/examples/ExternalIdTaken'
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422':
          description: The library already holds as many entries as your plan allows. Delete one, or upgrade the plan.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                librarySizeExceeded:
                  summary: The library is full
                  value: { type: 'https://tools.ietf.org/html/rfc4918#section-11.2', title: Library size limit reached., status: 422, code: max_library_entries, detail: The library has reached its limit of 25 entries. }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
  /libraries/{id}/entries/{entryId}:
    parameters: [ { $ref: '#/components/parameters/LibraryId' }, { $ref: '#/components/parameters/EntryId' } ]
    get:
      tags: [Libraries]
      operationId: getLibraryEntry
      summary: Get one entry
      description: The full definition of one entry, a carrier or a SKU, in the units it was written in.
      responses:
        '200':
          description: The entry's full definition.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LibraryEntry' }
              examples:
                trailer:
                  summary: A saved trailer
                  value:
                    id: 0197c3a0-5b1e-7c2d-9a41-2f6e8d0b11a1
                    type: carrier
                    scope: company
                    externalId: TRL-136
                    name: Trailer 13.6
                    note: Tail lift for the northern route
                    category: trailer
                    region: null
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 13600, dimensionY: 2480, dimensionZ: 2700 }
                    tareWeight: 7200
                    revision: 1
                    updatedAt: '2026-09-24T08:12:33.412+00:00'
                systemPallet:
                  summary: A system-catalogue entry, read by slug
                  value:
                    id: carrier_eur_pallet
                    type: carrier
                    scope: system
                    externalId: null
                    name: EPAL / EUR 1
                    note: null
                    category: pallet
                    region: europe
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 1200, dimensionY: 800, dimensionZ: 1800 }
                    tareWeight: null
                    revision: 1
                    updatedAt: '2026-09-25T00:00:00.001+00:00'
                sku:
                  summary: A saved SKU, in the units it was written in
                  value:
                    id: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
                    type: sku
                    scope: company
                    externalId: SKU-4711
                    name: Carton 60x40
                    note: null
                    category: box
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 600, dimensionY: 400, dimensionZ: 400 }
                    weight: 18
                    constraints: { allowStackingOnTop: true, allowedRotations: [Z] }
                    revision: 1
                    updatedAt: '2026-09-30T09:41:07.226+00:00'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibrariesForbidden' }
        '404': { $ref: '#/components/responses/LibraryEntryNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    put:
      tags: [Libraries]
      operationId: replaceLibraryEntry
      summary: Replace an entry
      description: >-
        Replaces a company entry's whole definition, carrier or SKU, with the body: the same body `POST` takes on
        that library, under the same rules. It is a full replacement, not a patch: a member you leave out gets the
        value it would get on a create (`null`, the default units, the default constraints), not the value it had.
        `revision` goes up by 1 and `updatedAt` changes on every `PUT`; the `id` and the entry's place in the
        list stay. There is no `Idempotency-Key`: sending the same body again leaves the same definition. A job
        that already referenced the entry keeps its own copy, so a `PUT` never changes a submitted job. The body
        is checked before the entry is looked up, so an invalid body answers `400` even for an unknown id. On the
        SKU library an `externalId` another SKU already has answers `409 external_id_taken`; the SKU's own
        current code is no conflict.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              description: 'The library in the path decides which of the two shapes applies: your company carrier library takes a `LibraryEntryInput`, your company SKU library a `SkuEntryInput`. Members of the other shape are ignored, and an optional member sent as `null` counts as absent.'
              anyOf:
                - $ref: '#/components/schemas/LibraryEntryInput'
                - $ref: '#/components/schemas/SkuEntryInput'
            examples:
              trailerReplaced:
                summary: The trailer with a new note; every other member sent again as it was
                value: { externalId: TRL-136, name: Trailer 13.6, note: 'Tail lift, northern and eastern routes', category: trailer, units: { length: mm, weight: kg }, geometry: { type: box, dimensionX: 13600, dimensionY: 2480, dimensionZ: 2700 }, tareWeight: 7200 }
              skuHeavier:
                summary: The SKU with a new weight, keeping its own code
                value: { externalId: SKU-4711, name: Carton 60x40, category: box, units: { length: mm, weight: kg }, geometry: { type: box, dimensionX: 600, dimensionY: 400, dimensionZ: 400 }, weight: 21, constraints: { allowStackingOnTop: true } }
      responses:
        '200':
          description: Replaced. The entry as it is now stored.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LibraryEntry' }
              examples:
                trailer:
                  summary: The replaced trailer, at revision 2
                  value:
                    id: 0197c3a0-5b1e-7c2d-9a41-2f6e8d0b11a1
                    type: carrier
                    scope: company
                    externalId: TRL-136
                    name: Trailer 13.6
                    note: Tail lift, northern and eastern routes
                    category: trailer
                    region: null
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 13600, dimensionY: 2480, dimensionZ: 2700 }
                    tareWeight: 7200
                    revision: 2
                    updatedAt: '2026-09-30T14:05:51.903+00:00'
                sku:
                  summary: The replaced SKU, at revision 2
                  value:
                    id: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
                    type: sku
                    scope: company
                    externalId: SKU-4711
                    name: Carton 60x40
                    note: null
                    category: box
                    units: { length: mm, weight: kg }
                    geometry: { type: box, dimensionX: 600, dimensionY: 400, dimensionZ: 400 }
                    weight: 21
                    constraints: { allowStackingOnTop: true, allowedRotations: [Z] }
                    revision: 2
                    updatedAt: '2026-09-30T14:05:51.903+00:00'
        '400': { $ref: '#/components/responses/ValidationProblem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibraryWriteForbidden' }
        '404': { $ref: '#/components/responses/LibraryEntryNotFound' }
        '409':
          description: 'On the SKU library, another SKU already has this `externalId` (`code: "external_id_taken"`).'
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                externalIdTaken:
                  $ref: '#/components/examples/ExternalIdTaken'
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }
    delete:
      tags: [Libraries]
      operationId: deleteLibraryEntry
      summary: Delete an entry
      description: >-
        Removes the entry, carrier or SKU, for good. Jobs submitted before keep their own copy of what they
        referenced; a job submitted afterwards that names the id is rejected with `400`, field code
        `library_entry_not_found`.
      responses:
        '204': { description: Deleted. Jobs submitted before keep their own copy. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/LibraryWriteForbidden' }
        '404': { $ref: '#/components/responses/LibraryEntryNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerProblem' }

components:

  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 'Customer API key bound to your account, e.g. `X-Api-Key: ck_live_...`. Create it in the web app (see https://app.loadoptimizer.ai/docs/api-keys); the full key is shown only once.'

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Resubmitting the same key + same body returns the original job (on `POST /jobs`) or the original entry
        (on `POST /libraries/{id}/entries`) rather than creating a duplicate; same key + a different body
        → `409` (empty body). The whole submitted body is the fingerprint, values as sent (an enum value in another case is a different body), `units` included: no field is exempt.
        The replay and the `409` hold while the key is kept: on `POST /jobs` for at least 24 hours after the
        job was submitted, and for as long as it is queued or running, after which the same key can start a
        new job; on entry creation as long as that entry exists, so a retry after the entry was deleted
        creates a new entry. Max 200 chars; an oversized key is rejected with
        `400` `validation_failed`, pointer `Idempotency-Key`.
      schema: { type: string, maxLength: 200 }
    LibraryId:
      name: id
      in: path
      required: true
      description: >-
        The id of a library, as `GET /libraries` lists it: the system library's slug `carrier_system_default`,
        or the id (a UUID) of one of your company libraries, carriers or SKUs. A job carrier or cargo line
        names an entry, not a library: its `libraryId` field holds an entry's id, and the id of one of your libraries there is
        rejected with `400`, field code `library_entry_not_found`, with a message that says it named a library.
      schema: { type: string, maxLength: 64 }
    EntryId:
      name: entryId
      in: path
      required: true
      description: The id of one entry. This is the value a job carrier or cargo line puts in its `libraryId` field.
      schema: { type: string, maxLength: 64 }
    JobId:
      name: id
      in: path
      required: true
      description: 'The id of one of your jobs, as `POST /jobs` or `POST /jobs/{id}/instructions` returned it.'
      schema: { type: string, format: uuid, maxLength: 36 }
    SavedLoadId:
      name: id
      in: path
      required: true
      description: 'The saved load''s id, which is also its run''s job id.'
      schema: { type: string, format: uuid, maxLength: 36 }

  # ==========================================================================
  # SHARED EXAMPLES
  # Only bodies used by more than one operation live here. Everything used once
  # stays inline, where a reader sees it without following a reference.
  # ==========================================================================
  examples:
    OnePalletTwoBoxes:
      summary: One pallet type, one cargo line of two units
      value:
        units: { length: m, weight: kg }
        carriers:
          - externalId: EUR-PALLET
            geometry: { type: box, dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.5 }
        cargo:
          - externalId: SKU-123
            description: Box A
            amount: 2
            weight: 5.0
            geometry: { type: box, dimensionX: 0.3, dimensionY: 0.4, dimensionZ: 0.2 }
    MixedPalletsForOneStore:
      summary: 'Mixed palletizing: three cargo lines onto as few EUR pallets as they fit on'
      value:
        units: { length: m, weight: kg }
        # The only field that selects the problem. Drop it and this same body is a load-planning job.
        problemType: MixedPalletizing
        name: Store 104 mixed pallets
        carriers:
          # quantity: null is what turns this into "how many pallets does the order need". category
          # may be omitted: MixedPalletizing fills in pallet, the one value it accepts here.
          - externalId: EUR-PALLET
            name: EUR pallet, 1.8 m build height
            category: pallet
            geometry: { type: box, dimensionX: 1.2, dimensionY: 0.8, dimensionZ: 1.8 }
            quantity: null
            tareWeight: 25
        cargo:
          - externalId: SKU-4001
            description: Beverage crate
            amount: 24
            weight: 12.5
            geometry: { type: box, dimensionX: 0.4, dimensionY: 0.3, dimensionZ: 0.25 }
            constraints: { allowStackingOnTop: true, allowedRotations: [Z] }
            groups: [store-104]
          - externalId: SKU-4002
            description: Cereal case
            amount: 18
            weight: 4.0
            geometry: { type: box, dimensionX: 0.6, dimensionY: 0.4, dimensionZ: 0.3 }
            constraints: { allowStackingOnTop: true }
            groups: [store-104]
          # Nothing may rest on this line and it may not turn, which is what puts it on top.
          - externalId: SKU-4003
            description: Egg tray
            amount: 6
            weight: 2.2
            geometry: { type: box, dimensionX: 0.3, dimensionY: 0.3, dimensionZ: 0.15 }
            constraints: { allowStackingOnTop: false, allowedRotations: [] }
            groups: [store-104]
        configuration:
          objective: MinimizeCarriers
          secondaryObjectives:
            - { type: CenterLoadMass }
    CargoFromSkuLibrary:
      summary: 'Cargo lines that reference SKUs from your company SKU library, next to an inline line'
      value:
        units: { length: m, weight: kg }
        carriers:
          - externalId: TRAILER
            geometry: { type: box, dimensionX: 13.6, dimensionY: 2.48, dimensionZ: 2.7 }
        cargo:
          # A reference: the SKU supplies geometry, weight, category and constraints, in its own units.
          - libraryId: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
            amount: 24
            groups: [store-104]
            color: '#3366cc'
            overrides: { constraints: { allowStackingOnTop: true } }
          # The same SKU again, with its own label, and never turned for this job.
          - libraryId: 0197d1e4-2a6b-7f3c-8d15-4b9e0c7a2f68
            amount: 6
            description: Carton 60x40, this side up
            overrides: { constraints: { allowedRotations: [] } }
          - externalId: SPOT-ITEM
            description: Loose crate
            amount: 2
            weight: 30
            geometry: { type: box, dimensionX: 0.8, dimensionY: 0.6, dimensionZ: 0.5 }
    ExternalIdTaken:
      summary: Another SKU in the SKU library already has this externalId
      value:
        type: 'https://tools.ietf.org/html/rfc9110#section-15.5.10'
        title: An entry with this externalId already exists.
        status: 409
        code: external_id_taken
        detail: externalId is unique within a SKU library. Update that entry, or use another code.
    NotEntitled:
      summary: The key is valid; the plan is not
      value:
        type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4'
        title: The account's plan does not include the customer API.
        status: 403
        code: customer_api_not_entitled
        detail: >-
          Upgrade the account to a plan that includes customer-API access:
          https://app.loadoptimizer.ai/home/billing
    BodyNotSupported:
      summary: A request body was sent and this operation accepts none
      value:
        type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4'
        title: This endpoint does not accept a request body.
        status: 403
        code: request_body_not_supported
        detail: Send the request without a body.
    JobNotFound:
      summary: Unknown id, or someone else's job
      value:
        type: 'https://tools.ietf.org/html/rfc9110#section-15.5.5'
        title: Job not found.
        status: 404
        code: job_not_found
    SavedLoadNotFound:
      summary: Unknown, released, or someone else's saved load
      value:
        type: 'https://tools.ietf.org/html/rfc9110#section-15.5.5'
        title: Saved load not found.
        status: 404
        code: saved_load_not_found

  # ==========================================================================
  # CONVENTIONS: units + problem+json
  # ==========================================================================
  schemas:

    Units:
      type: object
      description: |-
        Per-kind units for the whole job. Interprets all **input** values and formats all **output** values
        (the result echoes this block). Mixing systems across kinds is allowed (e.g. length `cm` + weight `lb`).
        `volume`/`area`/`ldm` are **output-only** (you input dimensions, not volumes).
      properties:
        length: { type: [string, 'null'], enum: [mm, cm, m, in, ft, null], default: m }
        weight: { type: [string, 'null'], enum: [g, kg, t, lb, null], default: kg }
        volume: { type: [string, 'null'], enum: [m3, cm3, l, ft3, null], default: m3, description: 'Output only. Formats `kpis.loadedVolume`.' }
        area:   { type: [string, 'null'], enum: [m2, cm2, ft2, null], default: m2, description: 'Echoed on the result; no response value is expressed in this unit.' }
        ldm:    { type: [string, 'null'], enum: [m, null], default: m, description: 'Echoed on the result; no response value is expressed in this unit.' }
      additionalProperties: false

    # Every type URI below, and the titles of the 422 and of both quota 429s, come
    # from the framework rather than from our own code, as measured per status. Re-check
    # them after a major framework upgrade.
    Problem:
      type: object
      description: |-
        RFC 9457 `problem+json`. `code` is stable & machine-readable when present; `errors[].pointer` locates
        the offending field, query parameter or header. Two `/v2` errors have an **empty body** instead:
        `401` and the idempotency `409`.
        See [Errors & troubleshooting](errors.html).
      properties:
        type:
          type: string
          format: uri
          description: >-
            A reference for the response's status code, not for the specific failure: two failures with
            different `code` values share one `type`. RFC 9457 permits `about:blank` here; this API never
            sends that, and the `429` responses carry no `type` at all. Switch on `code`, never on this
            field.
        title:    { type: string }
        status:   { type: integer, format: int32 }
        detail:   { type: string, description: 'Human-readable explanation, present on some errors and often naming the limit value that was exceeded. Never switch on it; switch on code.' }
        code:     { $ref: '#/components/schemas/ProblemCode' }
        errors:
          type: array
          description: 'Field-level problems. Only on a 400 `validation_failed`.'
          items:
            type: object
            properties:
              pointer: { type: string, description: 'For a body field, a JSON Pointer (RFC 6901) into the request body, over the member names as this contract spells them (camelCase, also when you sent another case), e.g. /carriers/0/externalId. A name you chose yourself (a group name, an overrides member) appears as sent, with ~ written as ~0 and / as ~1. For a query parameter or a header, its name without a leading /, e.g. limit or Idempotency-Key. An empty string means the body as a whole.' }
              code:    { type: string, enum: [validation_failed, ambiguous_source, library_entry_not_found, override_field_not_allowed], description: 'validation_failed, unless a field code applies: ambiguous_source or library_entry_not_found on the libraryId of a job carrier or cargo line, or override_field_not_allowed on its overrides.' }
              message: { type: string }
      required: [title, status]
    ProblemCode:
      type: string
      description: Stable, machine-readable top-level error-code registry, by HTTP status.
      enum:
        # 400
        - validation_failed
        # 400 field codes, in errors[].code: a carrier or cargo line that is both a reference and an inline
        # definition, a libraryId that names no entry you can use, and an overrides member other than
        # maxStackHeight (on a carrier) or constraints (on a cargo line)
        - ambiguous_source
        - override_field_not_allowed
        # 403: key valid, plan lacks customer-API access
        - customer_api_not_entitled
        # 403: key valid, plan lacks the job's problem type
        - mixed_palletizing_not_entitled
        - load_planning_not_entitled
        # 403: key valid, plan lacks link sharing (POST .../share only; revoking is never gated)
        - link_sharing_not_entitled
        # 403: the operation accepts no request body and one was sent
        - request_body_not_supported
        # 403: key valid, plan lacks company libraries, or this type of them (its size limit is 0)
        - libraries_not_entitled
        # 403: a change (POST, PUT, DELETE) to a read-only library (the system catalogue)
        - library_read_only
        # 500: something failed that no slice foresaw. Distinct from the failed-job `error.code`
        # `internal_error`, which reports a job that was accepted and then did not solve.
        - internal_server_error
        # 404
        - job_not_found
        - result_not_ready
        - artifact_not_ready
        - saved_load_not_found
        # 404: no library you can use has this id; no entry with this id (also a 400 field code, see above)
        - library_not_found
        - library_entry_not_found
        # 409
        - job_already_terminal
        - job_not_succeeded
        - saved_load_exists
        # 409: another SKU in the SKU library already has this externalId (POST and PUT)
        - external_id_taken
        # 413: the request body is larger than the API will read. Permanent and caller-fixable, which is
        # why it is not internal_server_error: that code's detail tells you to retry, and this one cannot
        # succeed unchanged.
        - payload_too_large
        # 415: a request carrying content sent it under a content type other than application/json
        - unsupported_media_type
        # 422: the job is larger than the plan allows. Checked in this order, and the first one hit is the
        # one reported: items, then carrier types, then the summed carrier instances
        - max_items_per_job
        - max_carriers_per_job
        - max_carrier_instances_per_job
        # 422: the plan's saved-load limit for this problem type is reached; detail names the limit
        - max_saved_loads
        # 422: a company library already holds as many entries as the plan allows
        - max_library_entries
        # 429
        - too_many_active_jobs
        - monthly_quota_exceeded
        # 429: the account's request rate was exceeded. The ONLY response carrying Retry-After.
        - rate_limit_exceeded

    # ==========================================================================
    # CORE REQUEST
    # ==========================================================================
    JobInput:
      type: object
      required: [resolvedAt, request, sources]
      description: The request of an order, every line inline, with each library reference resolved to the values the solver used. An order accepted before the current request maxima existed reads back as it was sent, so it can exceed them.
      properties:
        resolvedAt:
          type: string
          format: date-time
          description: The order's `createdAt` (UTC).
        request: { $ref: '#/components/schemas/JobRequest' }
        sources:
          type: array
          description: >-
            The library entries the request used, one item per entry, in the order each first appears: carriers
            first, then cargo, each in request order. Empty when no line referenced a library entry.
          items:
            type: object
            required: [libraryId, revision, lines]
            properties:
              libraryId:
                type: string
                maxLength: 64
                description: >-
                  The id of the library entry, as `/result` echoes it: a lowercase UUID, or a system catalogue
                  slug. It names the entry, as a line's `libraryId` does, not the library that holds it.
              revision:
                type: integer
                format: int32
                minimum: 1
                description: The entry's revision when the order was submitted.
              lines:
                type: array
                items: { type: string, format: json-pointer }
                description: >-
                  The lines of `request` this entry resolved, as JSON Pointers (RFC 6901) relative to `request`,
                  such as `/carriers/0` or `/cargo/3`.
    JobRequest:
      type: object
      required: [carriers, cargo]
      description: >-
        The body of `POST /jobs` and `POST /jobs/validate`: the carriers on offer, the cargo to load, and how to
        solve it. An optional member sent as `null` counts as absent. `GET /jobs/{id}/input` returns an order's
        request in this shape, every line inline.
      properties:
        units: { anyOf: [ { $ref: '#/components/schemas/Units' }, { type: 'null' } ] }
        carriers:
          type: array
          minItems: 1
          maxItems: 200
          description: 'The carriers available for this job (supply side), tried in the order listed: each entry is used, up to its `quantity`, before the next one is tried, and the result''s `carriers[]` follows the same order. At most 200 entries; more is rejected with 400 against `/carriers`.'
          items: { $ref: '#/components/schemas/CarrierEntry' }
        cargo:
          type: array
          minItems: 1
          maxItems: 5000
          description: 'The cargo lines to load (demand side). At most 5,000 lines, whatever their amounts; more is rejected with 400 against `/cargo`.'
          items: { $ref: '#/components/schemas/ItemEntry' }
        groups:
          type: [object, 'null']
          description: >-
            Optional. The groups this job declares, keyed by the name a cargo line opts into through its own
            `groups` array. Declaring a name is what promotes it from a label into a claim on a section of
            the carrier; a label you do not declare here stays what labels have always been and has no
            effect on placement. Declaring groups does nothing on its own: `configuration.groupSeparation`
            is what activates them, so you can leave these declarations in a request permanently and turn
            the behaviour on and off with one field. At most 20 groups, and a name is at most 64 characters;
            both are rejected with 400 against `/groups`, and both apply whether or not a separation is set,
            so switching one on can never turn a body the API accepted into one it rejects. A declared group
            that no cargo line references is allowed and simply produces no section, so a fixed set of
            routes can stay in a template.
          maxProperties: 20
          propertyNames: { minLength: 1, maxLength: 64 }
          additionalProperties: { $ref: '#/components/schemas/GroupSettings' }
        configuration:
          anyOf: [ { $ref: '#/components/schemas/Configuration' }, { type: 'null' } ]
        name:
          type: [string, 'null']
          maxLength: 256
          description: >-
            Optional label for this job, echoed back on the status and result responses. Free text; it has no
            effect on the packing. Leading and trailing whitespace is trimmed, and a value that is only
            whitespace is stored as no name at all. `null` is accepted and has the same effect as omitting
            the key: no name is stored. Change or clear it later with PUT /jobs/{id}/name.
        problemType:
          type: [string, 'null']
          enum: [LoadPlanning, MixedPalletizing, null]
          default: LoadPlanning
          description: >-
            Which problem this job poses. Omit it for load planning (containers, trailers, vans).
            MixedPalletizing declares a pallet-building job; it requires the feature on your plan and is
            metered against its own monthly quota. This field, and only this field, selects the problem type -
            a carrier's `category` does not. The relationship runs the other way: a carrier that omits
            `category` has its kind filled in from this field.

    GroupSettings:
      type: object
      description: >-
        Settings shared by every cargo line that names this group. Two properties today; more per-group
        settings land here beside them rather than introducing a second groups concept.
      properties:
        packingOrder:
          type: [integer, 'null']
          format: int32
          minimum: 1
          maximum: 2147483647
          description: >-
            Where this group's section sits along the packing axis. 1 is nearest the carrier origin, the
            `x = 0` end that the default `-X` consolidation packs toward, and higher numbers follow behind
            it. Values must be distinct across the declared groups, but they need not be contiguous: 10, 20,
            30 is as valid as 1, 2, 3, and leaves room to insert a group later without renumbering. Required
            on every declared group whenever `configuration.groupSeparation` is set; a group without one is
            rejected with 400 against `/groups/<name>/packingOrder`. A value below 1 or a duplicate is rejected the same way (a duplicate against the second group that uses it).
        keepTogether:
          type: [boolean, 'null']
          default: true
          description: >-
            Whether this group stays on one carrier. Optional: omitted or null means true. Takes effect only
            while `configuration.groupSeparation` is set. When the group does not fit entirely on a carrier,
            that carrier closes at this group: the group and every section packed behind it, including the
            cargo that matches no declared group, continue on the next carrier, and the room behind the cut
            stays empty. A group too large for the empty carrier it would start on goes to the first later entry in
            `carriers[]` whose empty carrier takes it whole, for that carrier only. Only a group no entry can
            take whole is split, and every carrier it spans starts with it. Each of those carriers is chosen the same
            way: what is left of the group goes to the first entry whose empty carrier takes it whole or, while none
            does, to the first entry whose empty carrier takes each of its units on its own. A unit that fits no
            carrier at all is left unplaced on its own
            and never holds its group back. When the carriers run out, a group that would fit an empty carrier
            comes back whole in `unplacedItems`, with everything packed behind it, rather than partly loaded.
            Set false to let the group be split across carriers instead.

    GroupSeparation:
      type: object
      required: [mode]
      description: >-
        How strictly the declared groups are kept apart. Setting this object is what activates group
        separation; omitting it, or sending null, means no separation at all, which is what every request
        that does not mention groups is doing. There is no default value, unlike `objective`: a solve always
        has an objective, but it does not always separate groups.
      properties:
        mode:
          type: string
          enum: [segmented]
          description: >-
            Required whenever the object is present; there is no default, because the only discipline
            available is also the most expensive one in space and choosing it for you could cost you a
            carrier. `segmented` gives each declared group its own contiguous section of the carrier along
            the packing axis, in `packingOrder` order, with no interleaving: within one carrier a later
            section is never placed in front of an earlier one. Cargo that matches no declared group shares
            a single section that follows every declared one. Across carriers, a declared group stays on one
            carrier by default: when it does not fit entirely, the carrier closes at that group and the group
            continues, whole, on the next one (see `GroupSettings.keepTogether`). A group is split only when
            its `keepTogether` is false or when no entry in `carriers[]` has an empty carrier that takes it whole. A
            group with `keepTogether: false` can then have its remainder land on a carrier after one that
            already holds the group behind it. Packing a group can also leave less usable room than its volume
            suggests, and a carrier that closes early leaves the room behind the cut empty, so a separated job
            may need more carriers than the same cargo would without it.
            Cannot be combined with a `Z` consolidation direction: a section is a slice of the carrier taken
            along the packing axis, and sliced vertically every section above the first would start at the
            highest point of the one below it with its cargo hanging over empty space. That pair is rejected
            with 400.

    CarrierEntry:
      type: object
      description: >-
        A carrier offered for this job, in one of two forms: an inline definition (`geometry` plus the other
        type-level fields), or a reference to one of your library entries (`libraryId`). Never both: the presence
        of `libraryId` is the switch, and a reference that also sends `geometry`, `name`, `category` or `tareWeight`
        is rejected with `400`, field code `ambiguous_source`. `overrides` is reference-only the same way: an
        inline carrier that sends it is rejected with `400` against `/carriers/{i}/overrides`. A property sent as `null` counts
        as absent, so `libraryId: null` on an inline carrier is fine. Carriers never nest.
      oneOf:
        - title: Inline
          required: [externalId, geometry]
          properties: { externalId: { type: string, maxLength: 128 }, geometry: { type: object } }
          not: { anyOf: [ { required: [libraryId], properties: { libraryId: { type: string, maxLength: 64 } } }, { required: [overrides], properties: { overrides: { type: object } } } ] }
        - title: Reference
          required: [libraryId]
          properties: { libraryId: { type: string, maxLength: 64 } }
          not: { anyOf: [ { required: [geometry], properties: { geometry: { type: object } } }, { required: [name], properties: { name: { type: string, maxLength: 256 } } }, { required: [category], properties: { category: { type: string, enum: [container, pallet, truck, trailer] } } }, { required: [tareWeight], properties: { tareWeight: { type: number } } } ] }
      properties:
        libraryId:  { type: [string, 'null'], maxLength: 64, description: 'Reference form: the id of one of your library entries (any UUID notation), or a system catalogue slug such as `carrier_iso_40hc`, which needs no company-library entitlement: any plan that can use the customer API can reference it, and `libraries_not_entitled` never applies to a slug. The definition comes from the entry, in the entry''s own units, and is frozen into the job at submit: deleting the entry later changes nothing here. An id you cannot use is rejected with `400`, field code `library_entry_not_found`, one error per such carrier.' }
        externalId: { type: [string, 'null'], minLength: 1, maxLength: 128, description: 'Customer label for this carrier type, echoed on the result. Duplicates across carriers are allowed. Required and non-empty on an inline carrier: a missing, null or empty value is rejected with 400. Optional on a reference, which inherits the entry''s label.' }
        name:       { type: [string, 'null'], maxLength: 256, description: 'Inline only. Optional display label. Carriers have no description field.' }
        geometry:   { anyOf: [ { $ref: '#/components/schemas/Box' }, { type: 'null' } ] }
        quantity:   { type: [integer, 'null'], format: int32, minimum: 1, maximum: 2147483647, description: 'How many instances are available. null/omitted = unlimited: the solver adds instances until the cargo runs out or the carrier fits nothing more, and only the carriers it used come back. An explicit N is used in full: all N instances appear in the result''s carriers[], so an instance past the point where the cargo ran out comes back with an empty placements[]. summary.carriersUsed counts only carriers with at least one placement.' }
        tareWeight: { type: [number, 'null'], minimum: 0, description: 'Inline only. Optional. The carrier''s own empty weight, in units.weight, stored with the job. At most 100000 kg after conversion from units.weight (so 100000 in kg, 100 in t, 220462 in lb); a value past it is rejected with 400 against this field. Per-carrier weight on the result is `kpis.loadedWeight`: the summed weight of that carrier''s placements.' }
        category:   { type: [string, 'null'], enum: [container, pallet, truck, trailer, null], description: 'Inline only. Optional. Which kind of carrier this entry is. A value outside this list is rejected with 400 against this field. Omit it and the kind implied by problemType is filled in (pallet for MixedPalletizing, container for LoadPlanning) and echoed, so a result names the kind it was planned and drawn as. A MixedPalletizing job accepts only `pallet`: `container`, `truck` and `trailer` are rejected with 400 against this field. LoadPlanning accepts any of the four. A reference takes its category from the entry, and a MixedPalletizing job rejects a referenced entry that is not a pallet, against `/carriers/{i}/libraryId`.' }
        overrides:
          anyOf: [ { $ref: '#/components/schemas/CarrierOverrides' }, { type: 'null' } ]
          description: >-
            Reference form only. Optional per-job adjustment of the referenced entry. An inline carrier that
            sends it is rejected with `400` against this field.

    CarrierOverrides:
      type: object
      description: 'Per-job adjustments of a referenced library entry. `maxStackHeight` is the only member today.'
      properties:
        maxStackHeight:
          type: [number, 'null']
          exclusiveMinimum: 0
          description: >-
            Load height for this job, in `units.length` (the job's unit, not the entry's). On a pallet it
            replaces the entry's `dimensionZ`, the default load height above the deck. On a container, truck
            or trailer it may lower the usable height but not exceed the entry's own: a larger value is
            rejected with 400 against `/carriers/{i}/overrides/maxStackHeight`. Any other member is rejected
            with field code `override_field_not_allowed`.

    ItemEntry:
      type: object
      description: >-
        A cargo line, in one of two forms: an inline definition (`geometry` and `weight`, optionally `category`
        and `constraints`), or a reference to a SKU in your company SKU library (`libraryId`). Never both: the
        presence of `libraryId` is the switch, and a reference that also sends `geometry`, `weight`, `category` or
        `constraints` is rejected with `400`, field code `ambiguous_source`, against `/cargo/{i}/libraryId`.
        `overrides` is reference-only the same way: an inline line that sends it is rejected with `400` against
        `/cargo/{i}/overrides`. A property sent as `null` counts as absent, so `libraryId: null` on an inline line is
        fine. Both forms take `amount`, `groups`, `color`, the `description` label and `externalId`, and plan
        limits count a reference exactly like an inline line.
      oneOf:
        - title: Inline
          required: [amount, geometry, weight]
          properties: { geometry: { type: object }, weight: { type: number } }
          not: { anyOf: [ { required: [libraryId], properties: { libraryId: { type: string, maxLength: 64 } } }, { required: [overrides], properties: { overrides: { type: object } } } ] }
        - title: Reference
          required: [libraryId, amount]
          properties: { libraryId: { type: string, maxLength: 64 } }
          not: { anyOf: [ { required: [geometry], properties: { geometry: { type: object } } }, { required: [weight], properties: { weight: { type: number } } }, { required: [category], properties: { category: { type: string, maxLength: 64 } } }, { required: [constraints], properties: { constraints: { type: object } } } ] }
      properties:
        libraryId:   { type: [string, 'null'], maxLength: 64, description: 'Reference form: the id of a SKU in your company SKU library (any UUID notation). The SKU''s geometry, weight, category and constraints, in the SKU''s own units, are frozen into the job at submit: a later PUT or DELETE of the SKU changes nothing here. An id that names no SKU you can use (unknown, deleted, another account''s, malformed, or a slug: there is no system SKU catalogue) is rejected with `400`, field code `library_entry_not_found`, against `/cargo/{i}/libraryId`, one error per such line. When the SKU library is not available on your plan, a UUID here answers `403 libraries_not_entitled` before any lookup.' }
        externalId:  { type: [string, 'null'], maxLength: 128, description: 'Optional echo label. Not unique and not a join key; use the result placement''s itemId to join back to this line. On a reference it falls back to the SKU''s externalId, and must be non-empty when sent.' }
        description: { type: [string, 'null'], maxLength: 512, description: 'Human label, echoed on result placements and unplaced entries. Cargo has no name field; use this instead. On a reference it falls back to the SKU''s name, and must be non-empty when sent.' }
        color:       { type: [string, 'null'], maxLength: 64, description: 'Display colour, free-form (e.g. a hex string), echoed on result placements.' }
        amount:      { type: integer, format: int32, minimum: 1, maximum: 2147483647, description: 'How many identical units this line represents.' }
        geometry:    { anyOf: [ { $ref: '#/components/schemas/CargoGeometry' }, { type: 'null' } ] }
        weight:      { type: [number, 'null'], minimum: 0, description: 'Inline only. Per-unit weight, in units.weight. At most 100000 kg after conversion from units.weight (so 100000 in kg, 100 in t, 220462 in lb); a value past it is rejected with 400 against this field.' }
        constraints: { anyOf: [ { $ref: '#/components/schemas/ItemConstraints' }, { type: 'null' } ] }
        category:    { type: [string, 'null'], enum: [box, null], description: 'Inline only. Optional. The kind the unit is drawn as: `box`, matched in any case and echoed in that spelling. Any other value is rejected with 400 against this field. Omit it and `box` is filled in and echoed. A reference takes the SKU''s. Unrelated to a carrier''s category, which is a closed set of four values (`container`, `pallet`, `truck`, `trailer`) that decides the shape drawn around the cargo. The set may grow.' }
        overrides:
          anyOf: [ { $ref: '#/components/schemas/ItemOverrides' }, { type: 'null' } ]
          description: >-
            Reference form only. Optional per-job adjustment of the referenced SKU. An inline line that sends it
            is rejected with `400` against this field.
        groups:
          type: [array, 'null']
          maxItems: 10
          items: { type: string, maxLength: 256 }
          description: >-
            Free labels, at most 10 of them. A label affects placement only when the job also declares it in
            the top-level `groups` block and sets `configuration.groupSeparation`, in which case that
            group's cargo is packed into its own contiguous section of the carrier; every other label on the
            line stays inert. While `configuration.groupSeparation` is set, a line may name at most one declared
            group: naming two asks for one unit in two sections and is rejected with 400 against
            `/cargo/{i}/groups`, so split the line's `amount` across one
            cargo line per group instead. A line that names no declared group joins the single section that
            follows all of them. Echoed back as groups on placements and unplaced entries either way
            (and under the deprecated name group). A null entry is rejected with 400 against `/cargo/{i}/groups`.
            A label is at most 256 characters; a longer one is rejected with 400 against `/cargo/{i}/groups/{j}`.

    ItemOverrides:
      type: object
      additionalProperties: false
      description: >-
        Per-job adjustments of a referenced SKU, applied per field: a member sent replaces the SKU's value, a
        member left out keeps it. Any other member is rejected with `override_field_not_allowed`, against
        `/cargo/{i}/overrides/<member>`, the member spelled exactly as you sent it (`~` written as `~0`, `/` as `~1`).
      properties:
        constraints:
          anyOf: [ { $ref: '#/components/schemas/ItemConstraints' }, { type: 'null' } ]
          description: >-
            Per field, for this job only: `allowStackingOnTop` or `allowedRotations` sent replaces the SKU's
            value, and a member left out (or `null`) keeps the SKU's. The defaults of `ItemConstraints` do not
            apply here. `allowedRotations: []` locks the unit in the orientation its geometry describes, whatever
            the SKU allows.

    # ---- Geometry --------------------------------------------------------------
    Box:
      type: object
      required: [type, dimensionX, dimensionY, dimensionZ]
      description: >-
        Box geometry on a carrier, a cargo line or a library entry. X = length, Y = width, Z = height, in
        units.length (an entry's own units on a library entry). Box is the only geometry this API implements;
        any other `type` is rejected with 400. `geometry.type` and the fields of `constraints` may grow; tolerate
        values and fields you do not know.


        Each dimension must be greater than 0 and at most **100 m** after conversion from `units.length`,
        so the accepted range depends on the unit you declared: 100000 in `mm`, 100 in `m`, 328.08 in `ft`.
        The bound is not a fixed `maximum` in this schema for that reason. A value past it is rejected with
        `400` against the field you sent (e.g. `/cargo/0/geometry/dimensionX`). Dimensions are handled to the
        nearest millimetre, so a positive value below half a millimetre is rejected too.
      properties:
        type: { const: box }
        dimensionX: { type: number, exclusiveMinimum: 0 }
        dimensionY: { type: number, exclusiveMinimum: 0 }
        dimensionZ: { type: number, exclusiveMinimum: 0 }
    CargoGeometry:
      description: >-
        The geometry of a cargo unit, on an inline cargo line and on a SKU (its input and the stored entry): one
        schema, told apart by `type`, with `box` its only member today. A carrier's geometry is a `Box`.
        `geometry.type` and the fields of `constraints` may grow; tolerate values and fields you do not know.
      oneOf:
        - $ref: '#/components/schemas/Box'
      discriminator:
        propertyName: type
        mapping:
          box: '#/components/schemas/Box'
    Geometry:
      type: object
      required: [type, dimensionX, dimensionY, dimensionZ, box]
      description: >-
        Original (unrotated) box dimensions, echoed on a result carrier or placement in the same shape as the
        request `Box`: `type` and `dimensionX/Y/Z`, in units.length. A placement's sizeX/Y/Z is this box's
        rotated extent. `box` repeats the dimensions in the earlier nested form; it is deprecated and will be
        removed in the next major version, so read `dimensionX/Y/Z`.
      properties:
        type: { const: box }
        dimensionX: { type: number, exclusiveMinimum: 0 }
        dimensionY: { type: number, exclusiveMinimum: 0 }
        dimensionZ: { type: number, exclusiveMinimum: 0 }
        box:
          type: object
          deprecated: true
          required: [dimensionX, dimensionY, dimensionZ]
          description: 'Deprecated: the same dimensions as dimensionX/Y/Z. Removed in the next major version.'
          properties:
            dimensionX: { type: number, exclusiveMinimum: 0 }
            dimensionY: { type: number, exclusiveMinimum: 0 }
            dimensionZ: { type: number, exclusiveMinimum: 0 }

    # ---- Per-item constraints ---------------------------------------------------
    ItemConstraints:
      type: object
      description: >-
        Per-cargo-line behavioural rules, on an inline cargo line and on a SKU. Carriers have no constraints
        field in this API; only a cargo entry carries one. `geometry.type` and the fields of `constraints` may
        grow; tolerate values and fields you do not know.
      properties:
        allowStackingOnTop: { type: [boolean, 'null'], default: false, description: 'Whether other cargo may be placed directly on top of this line.' }
        allowedRotations:
          type: [array, 'null']
          items: { type: string, enum: [X, Y, Z] }
          uniqueItems: true
          maxItems: 3
          default: [Z]
          description: >-
            Axes the box may be turned 90° about, relative to the submitted orientation. This is the only
            rotation control; there is no tilt switch. Omitted → upright, free to spin about Z (equivalent to
            ["Z"]); [] explicitly locks the box in the submitted orientation with no rotation at all.
            Each axis at most once: naming one twice is rejected with 400 against this field.

    # ---- Configuration / objectives --------------------------------------------
    Configuration:
      type: object
      description: 'Two independent, optional switches, not ordered lexicographic tie-breakers.'
      properties:
        objective:
          type: [string, 'null']
          enum: [MinimizeCarriers, null]
          default: MinimizeCarriers
          description: 'The only supported primary objective. Omitted, null or "" also resolves to MinimizeCarriers; any other value is rejected with 400.'
        secondaryObjectives:
          type: [array, 'null']
          maxItems: 2
          description: 'At most one entry per type; order has no effect. A duplicate type, or a null entry, is rejected with 400.'
          items: { $ref: '#/components/schemas/SecondaryObjective' }
        groupSeparation:
          anyOf: [ { $ref: '#/components/schemas/GroupSeparation' }, { type: 'null' } ]
          description: >-
            Optional. Present activates group separation for the groups declared in the job's `groups`
            block; absent or null means none, which is the behaviour of every request that omits it.
            Rejected with 400 when the job declares no group to separate.
    SecondaryObjective:
      type: object
      description: 'An optional switch on top of the primary objective, at most one of each `type`.'
      required: [type]
      properties:
        type:
          type: string
          enum: [CenterLoadMass, consolidation]
          description: >-
            CenterLoadMass centres the load's mass after solving; consolidation picks the axis the solver packs
            along from the first step.
        direction:
          anyOf: [ { $ref: '#/components/schemas/ConsolidationDirection' }, { type: 'null' } ]
          description: 'Required when type is consolidation; accepted and ignored alongside CenterLoadMass.'
    ConsolidationDirection:
      type: string
      enum: ['-X', '-Y', 'Z']
      description: >-
        The packing axis to consolidate toward. Only these three values are supported: the solver fills
        outward from the carrier frame's zero position and cannot gather toward the opposite end, so +X/+Y are rejected with
        400. Omitting consolidation is not "no consolidation": the solver always packs along an axis,
        defaulting to -X.

    # ==========================================================================
    # RESPONSES
    # ==========================================================================
    JobAccepted:
      type: object
      description: 'The job the request created, or on a replay the original job, with its status at the time of the answer.'
      required: [jobId, status]
      properties:
        jobId: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, succeeded, failed, cancelled], description: 'The job''s status at the time of this answer: `queued` for a new job. A replay of an earlier submit (see the `Idempotency-Key` header) carries the original job in the status it has now, which can be any of these.' }
    JobStatus:
      type: object
      required: [jobId, status, createdAt, saved]
      description: >-
        The status of one job, as `GET /jobs/{id}` returns it for an order or a loading-instructions job, and as
        `GET /jobs` lists each order. `queued` and `running` are active; `succeeded`, `failed` and `cancelled`
        are terminal, and a terminal status does not change.
      properties:
        jobId: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, succeeded, failed, cancelled] }
        percentage:
          type: [integer, 'null']
          format: int32
          minimum: 0
          maximum: 100
          description: 'A packing job reports 0, 50 or 100 only; a derived instructions job reports finer progress.'
        queuePosition: { type: [integer, 'null'], format: int32, description: 'Always null.' }
        errorCategory:
          type: [string, 'null']
          description: 'Failure category. Diagnostic only, not a closed set. Switch on error.code instead.'
        error:
          type: [object, 'null']
          description: 'Present only when status is failed.'
          properties:
            code: { type: string, enum: [invalid_input, solver_error, render_error, internal_error], description: 'Closed set.' }
            message: { type: string, description: 'Safe, human-readable sentence; never contains exception detail.' }
        name:
          type: [string, 'null']
          maxLength: 256
          description: 'The label given at submit or set with PUT /jobs/{id}/name; null when the job has none.'
        createdAt:
          type: string
          format: date-time
          description: 'When the job was submitted (UTC).'
        problemType:
          type: [string, 'null']
          enum: [LoadPlanning, MixedPalletizing, null]
          description: >-
            The job's problem type, echoed in its published spelling. Null only for a job with no order of its
            own: a derived instructions job created by `POST /jobs/{id}/instructions` has nothing to classify.
        saved:
          type: boolean
          description: 'Whether this order is kept as a saved load (see `/saved-loads`). Always false for a loading-instructions job.'
    JobPage:
      type: object
      required: [items, nextCursor]
      description: 'One page of `GET /jobs`.'
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/JobStatus' } }
        nextCursor: { type: [string, 'null'], pattern: '^[A-Za-z0-9_-]+$', description: 'Pass it as `cursor` for the next page; null on the last page. Opaque: do not build or parse it.' }
    SavedLoad:
      type: object
      required: [id, name, problemType, createdAt, savedAt]
      description: 'A run you kept. Its id is the run''s job id; the request (`/jobs/{id}/input`), result, instructions and share link stay under `/jobs/{id}/...`.'
      properties:
        id: { type: string, format: uuid, description: 'The run''s job id: its request, result, instructions and share link are under `/jobs/{id}/...`.' }
        name: { type: [string, 'null'], maxLength: 256, description: 'The run''s name. Set when saving; null only when it was cleared afterwards with `PUT /jobs/{id}/name`.' }
        problemType: { type: string, enum: [LoadPlanning, MixedPalletizing] }
        createdAt: { type: string, format: date-time, description: 'When the run was submitted (UTC).' }
        savedAt: { type: string, format: date-time, description: 'When it was saved (UTC).' }
    SavedLoadPage:
      type: object
      required: [items, nextCursor]
      description: 'One page of `GET /saved-loads`.'
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/SavedLoad' } }
        nextCursor: { type: [string, 'null'], pattern: '^[A-Za-z0-9_-]+$', description: 'Pass it as `cursor` for the next page; null on the last page. Opaque: do not build or parse it.' }
    JobResult:
      type: object
      required: [jobId, status, summary, carriers, unplacedItems, units, createdAt]
      description: 'Result of a succeeded job.'
      properties:
        jobId: { type: string, format: uuid }
        status: { type: string, enum: [succeeded] }
        units: { $ref: '#/components/schemas/Units' }
        summary: { $ref: '#/components/schemas/ResultSummary' }
        carriers:
          type: array
          description: 'Every carrier of the plan, in the order the carriers were loaded.'
          items: { $ref: '#/components/schemas/PackedCarrier' }
        unplacedItems:
          type: array
          items: { $ref: '#/components/schemas/UnplacedItem' }
        name:
          type: [string, 'null']
          maxLength: 256
          description: 'The label given at submit or set with PUT /jobs/{id}/name; null when the job has none.'
        createdAt:
          type: string
          format: date-time
          description: 'When the job was submitted (UTC).'

    ResultSummary:
      type: object
      description: Overall totals across all carriers.
      required: [carriersUsed, itemsPlaced, itemsUnplaced, totalWeight]
      properties:
        carriersUsed: { type: integer, format: int32, description: 'Carriers with at least one placement.' }
        itemsPlaced: { type: integer, format: int32, description: 'Total placement count (expanded units, not lines).' }
        itemsUnplaced: { type: integer, format: int32, description: 'Sum of unplaced amounts across all lines (units, not lines).' }
        totalWeight: { type: number, description: 'Total placed weight across all carriers, in units.weight.' }

    PackedCarrier:
      type: object
      description: 'One carrier instance in the result, with its key figures and the units placed in it.'
      required: [externalId, instance, geometry, kpis, placements, libraryId]
      properties:
        externalId: { type: [string, 'null'], description: 'Echo of the label. Null only for a carrier that references a library entry when neither the job line nor the entry has a label.' }
        name: { type: [string, 'null'], description: Echo. }
        instance: { type: integer, format: int32, description: '1-based instance number for this carrier type (an unlimited-quantity type can yield several).' }
        geometry: { $ref: '#/components/schemas/Geometry' }
        kpis: { $ref: '#/components/schemas/CarrierKpis' }
        placements:
          type: array
          description: The units placed in this carrier. Carriers never nest, so this list is flat.
          items: { $ref: '#/components/schemas/Placement' }
        category: { type: [string, 'null'], enum: [container, pallet, truck, trailer, null], description: 'The carrier kind: the submitted category, or the kind filled in from problemType when the entry omitted one. The set may grow; tolerate values you do not know.' }
        libraryId: { type: [string, 'null'], description: 'The library entry this carrier referenced; null for an inline carrier.' }

    LibraryList:
      type: object
      description: 'The libraries your account can use, the system catalogue first.'
      required: [items]
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/LibrarySummary' } }
    LibrarySummary:
      type: object
      description: 'One library: what it holds, whose it is, how many entries it has and what you may do with it.'
      required: [id, name, type, scope, overridable, entryCount, access]
      properties:
        id: { type: string, description: 'The library id. For a company library a UUID that is stable for your account and exists before you save anything.' }
        name: { type: string }
        type: { type: string, enum: [carrier, sku], description: 'What the library holds: `carrier` entries or `sku` entries (cargo units). The set may grow; tolerate values you do not know.' }
        scope: { type: string, enum: [system, company], description: '`company`: your own, editable. `system`: a standard catalogue, read-only.' }
        overridable: { type: boolean, description: '`true`: a job may adjust a referenced entry for that job, with `overrides.maxStackHeight` on a carrier (both scopes) and `overrides.constraints` on a SKU.' }
        entryCount: { type: integer, format: int32, minimum: 0 }
        access: { type: string, enum: [reference, edit], description: '`edit`: you may save and delete entries. `reference`: you may only use them.' }
    EntryUnits:
      type: object
      description: The units an entry is written in. Omitted, they are metres and kilograms. The entry keeps them; a job never reinterprets them.
      properties:
        length: { type: [string, 'null'], enum: [mm, cm, m, in, ft, null], default: m }
        weight: { type: [string, 'null'], enum: [g, kg, t, lb, null], default: kg }
    LibraryEntryInput:
      type: object
      required: [name, category, geometry]
      description: 'The body of `POST` and `PUT` on your company carrier library: one carrier, in its own units.'
      properties:
        externalId: { type: [string, 'null'], minLength: 1, maxLength: 128, description: 'Optional label from your own system. Not a key: duplicates are allowed. A job line that references the entry inherits it unless the line sets its own.' }
        name: { type: string, minLength: 1, maxLength: 256 }
        note: { type: [string, 'null'], minLength: 1, maxLength: 1000, description: 'Optional free-text note about the entry, kept in the library: a job that references the entry does not copy it. Not to be confused with a cargo line''s `description`, which is that line''s label.' }
        category: { type: string, enum: [container, pallet, truck, trailer], description: 'Required on an entry. The set may grow; tolerate values you do not know.' }
        region: { type: [string, 'null'], enum: [europe, north_america, asia, oceania, null], description: 'Optional. Where this entry is typically used, grouping it for display. The set may grow; tolerate values you do not know.' }
        units: { anyOf: [ { $ref: '#/components/schemas/EntryUnits' }, { type: 'null' } ] }
        geometry: { $ref: '#/components/schemas/Box' }
        tareWeight: { type: [number, 'null'], minimum: 0, description: 'Optional empty weight, in the entry''s `units.weight`. At most 100000 kg after conversion. Stored and echoed; not used in solving yet.' }
    SkuEntryInput:
      type: object
      required: [name, geometry, weight]
      description: >-
        The body of `POST` and `PUT` on your company SKU library: one cargo unit, in its own units. It meets the
        rules an inline cargo line meets (the same geometry, weight, category and constraints rules), measured
        in the entry's own units.
      properties:
        externalId: { type: [string, 'null'], minLength: 1, maxLength: 128, description: 'Optional code from your own system, such as the SKU code of your product master. Unique within the SKU library when set, and case-sensitive: `SKU-1` and `sku-1` are two codes. A code another SKU already has answers `409 external_id_taken`. A cargo line that references the SKU inherits it unless the line sets its own.' }
        name: { type: string, minLength: 1, maxLength: 256, description: 'A cargo line that references the SKU and sends no `description` label is labelled with this name.' }
        note: { type: [string, 'null'], minLength: 1, maxLength: 1000, description: 'Optional free-text note about the SKU, kept in the library: a job that references it does not copy it. Not to be confused with a cargo line''s `description`, which is that line''s label.' }
        category: { type: [string, 'null'], enum: [box, null], description: 'Optional. The kind the SKU is drawn as: `box`, as on a cargo line, matched in any case. Omit it and `box` is stored. A cargo line that references the SKU takes it from here.' }
        units: { anyOf: [ { $ref: '#/components/schemas/EntryUnits' }, { type: 'null' } ] }
        geometry: { $ref: '#/components/schemas/CargoGeometry' }
        weight: { type: number, minimum: 0, description: 'Per-unit weight, in the entry''s `units.weight`. At most 100000 kg after conversion.' }
        constraints:
          anyOf: [ { $ref: '#/components/schemas/ItemConstraints' }, { type: 'null' } ]
          description: >-
            Optional. Stored filled in: a member you leave out is stored with its default (`allowStackingOnTop:
            false`, `allowedRotations: ["Z"]`), so what you read back is exactly what a job referencing the SKU
            gets.
    LibraryEntry:
      description: >-
        One library entry: a carrier or a SKU, told apart by `type`. Every field of either shape is always
        present, `null` when not set. A carrier library holds only carriers and the SKU library only SKUs.
      oneOf:
        - $ref: '#/components/schemas/CarrierLibraryEntry'
        - $ref: '#/components/schemas/SkuLibraryEntry'
      discriminator:
        propertyName: type
        mapping:
          carrier: '#/components/schemas/CarrierLibraryEntry'
          sku: '#/components/schemas/SkuLibraryEntry'
    CarrierLibraryEntry:
      type: object
      description: 'A carrier as a library stores it. Every field is present, `null` when not set.'
      required: [id, type, scope, externalId, name, note, category, region, units, geometry, tareWeight, revision, updatedAt]
      properties:
        id: { type: string, description: 'The entry id: the value a job carrier puts in `libraryId`. A system-catalogue entry''s id is a permanent slug (e.g. `carrier_iso_40hc`) rather than a UUID; retiring an entry never reuses its slug.' }
        type: { type: string, enum: [carrier] }
        scope: { type: string, enum: [system, company] }
        externalId: { type: [string, 'null'] }
        name: { type: string }
        note: { type: [string, 'null'], description: 'The free-text note about the entry. Always present, null when not set.' }
        category: { type: string, enum: [container, pallet, truck, trailer] }
        region: { type: [string, 'null'], enum: [europe, north_america, asia, oceania, null], description: 'Where this entry is typically used, grouping it for display. Always present, null when not set. The set may grow; tolerate values you do not know.' }
        units: { $ref: '#/components/schemas/EntryUnits' }
        geometry: { $ref: '#/components/schemas/Box' }
        tareWeight: { type: [number, 'null'] }
        revision: { type: integer, format: int32, description: 'Starts at 1; every PUT adds 1.' }
        updatedAt: { type: string, format: date-time }
    SkuLibraryEntry:
      type: object
      description: 'A SKU as your company SKU library stores it. Every field is present, `null` when not set.'
      required: [id, type, scope, externalId, name, note, category, units, geometry, weight, constraints, revision, updatedAt]
      properties:
        id: { type: string, format: uuid, description: 'The entry id (a UUID): the value a cargo line puts in `libraryId`.' }
        type: { const: sku }
        scope: { type: string, enum: [system, company], description: '`company` on every SKU today: there is no system SKU catalogue.' }
        externalId: { type: [string, 'null'], description: 'The SKU code: unique within the SKU library when set, and case-sensitive. Always present, null when not set.' }
        name: { type: string }
        note: { type: [string, 'null'], description: 'The free-text note about the SKU. Always present, null when not set.' }
        category: { type: string, enum: [box], description: 'The kind the SKU is drawn as. Always set: `box` when the entry was written without one. The set may grow; tolerate values you do not know.' }
        units: { $ref: '#/components/schemas/EntryUnits' }
        geometry: { $ref: '#/components/schemas/CargoGeometry' }
        weight: { type: number, description: 'Per-unit weight, in the entry''s `units.weight`.' }
        constraints: { $ref: '#/components/schemas/SkuConstraints' }
        revision: { type: integer, format: int32, description: 'Starts at 1; every PUT adds 1.' }
        updatedAt: { type: string, format: date-time }
    SkuConstraints:
      type: object
      required: [allowStackingOnTop, allowedRotations]
      description: >-
        A SKU's constraints as stored: always filled in, so this is exactly what a job referencing the SKU gets
        unless the job line overrides a field. The fields may grow; tolerate fields you do not know.
      properties:
        allowStackingOnTop: { type: boolean, description: 'Whether other cargo may be placed directly on top of this unit.' }
        allowedRotations:
          type: array
          items: { type: string, enum: [X, Y, Z] }
          uniqueItems: true
          maxItems: 3
          description: 'The axes the unit may be turned 90° about, uppercase and in X, Y, Z order. `[]` means it is never turned.'
    LibraryEntryPage:
      type: object
      description: 'One page of `GET /libraries/{id}/entries`.'
      required: [items, nextCursor]
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/LibraryEntry' } }
        nextCursor: { type: [string, 'null'], pattern: '^[A-Za-z0-9_-]+$', description: 'Pass it as `cursor` for the next page; null on the last page. Opaque: do not build or parse it.' }

    CarrierKpis:
      type: object
      description: 'Key figures of one carrier: how much of its volume is used, and the weight, volume and number of the units placed in it.'
      required: [volumeUtilization, loadedWeight, loadedVolume, itemCount]
      properties:
        volumeUtilization: { type: number, description: '% of the carrier''s geometry volume used by placements (0-100). Unit-free.' }
        loadedWeight: { type: number, description: 'Sum of placement weights, in units.weight.' }
        loadedVolume: { type: number, description: 'Sum of placement volumes, in units.volume (default m3).' }
        itemCount: { type: integer, format: int32, description: 'Placement count in this carrier.' }

    Placement:
      type: object
      required: [x, y, z, sizeX, sizeY, sizeZ, orientationRx, orientationRy, orientationRz, weight, geometry, group, groups, itemId, libraryId]
      description: >-
        A positioned unit in its carrier's frame. `x,y,z` is the min corner of the placed box's rotated
        bounding box; `sizeX/Y/Z` are that box's rotated extents; the rotation is already applied.
      properties:
        x: { type: number }
        y: { type: number }
        z: { type: number }
        sizeX: { type: number, description: 'Rotated bounding footprint, X.' }
        sizeY: { type: number }
        sizeZ: { type: number }
        orientationRx: { type: number, description: 'Rotation in degrees, always a multiple of 90.' }
        orientationRy: { type: number }
        orientationRz: { type: number }
        weight: { type: number, description: 'This placement''s weight, in units.weight.' }
        geometry: { $ref: '#/components/schemas/Geometry', description: 'Original, unrotated dimensions.' }
        description: { type: [string, 'null'], description: Echo. }
        color: { type: [string, 'null'], description: Echo. }
        externalId: { type: [string, 'null'], description: 'Echo, not unique.' }
        category: { type: [string, 'null'], enum: [box, null], description: 'Echo of the cargo line''s category. The set may grow; tolerate values you do not know. Results of jobs submitted before the release that closed this set (see the changelog) can carry `null` or free text; treat a value you do not know as `box`.' }
        group:
          type: array
          deprecated: true
          items: { type: string }
          description: 'Deprecated: the same labels as groups. Removed in the next major version.'
        groups:
          type: array
          items: { type: string }
          description: 'Echo of the source cargo line''s groups[], under the same name as on the request.'
        itemId:
          type: string
          format: uuid
          description: >-
            Join key the API assigns to the source cargo line. Shared by every placement of the same
            line, the only reliable way to attribute a placement to a submitted line; do not join on externalId.
        libraryId: { type: [string, 'null'], format: uuid, description: 'The SKU the cargo line referenced; null for an inline line.' }

    UnplacedItem:
      type: object
      required: [description, amount, group, groups, itemId, libraryId]
      description: >-
        A cargo line that could not be fully placed. Intentionally carries no `category`, `geometry`, `weight` or
        `color`: only what identifies the line and how much of it is missing.
      properties:
        description: { type: string, description: 'Echo (may be empty).' }
        amount: { type: integer, format: int32, description: 'Units of this line left unplaced.' }
        externalId: { type: [string, 'null'], description: Echo. }
        group:
          type: array
          deprecated: true
          items: { type: string }
          description: 'Deprecated: the same labels as groups. Removed in the next major version.'
        groups:
          type: array
          items: { type: string }
          description: 'Echo of the source cargo line''s groups[].'
        itemId: { type: string, format: uuid, description: 'Same join key as the line''s placements.' }
        libraryId: { type: [string, 'null'], format: uuid, description: 'The SKU the cargo line referenced; null for an inline line.' }

    ShareLink:
      type: object
      description: 'A job''s share link: the token, the link to hand out and when it stops working.'
      required: [shareId, expiresAt]
      properties:
        shareId: { type: string, pattern: '^[A-Za-z0-9_-]{43}$', description: 'Opaque, unguessable token identifying this share: distinct from the job id. It is what the link carries; it is not a URL, and it is not a credential for this API.' }
        url: { type: [string, 'null'], format: uri, description: 'The link to hand out: the read-only 3D viewer, openable in any browser with no API key. Null when the API has no viewer link to give out.' }
        expiresAt: { type: string, format: date-time, description: 'When the link stops working. DELETE the share to revoke sooner.' }

    ValidateResult:
      type: object
      required: [valid, warnings]
      description: >-
        This 200 response is only reached when the request validates, so `valid` is always true and
        `warnings` is always empty today; an invalid body gets a 400 `problem+json` instead of this shape.
      properties:
        valid: { type: boolean }
        warnings:
          type: array
          description: >-
            Reserved. Always empty today: no warning is emitted, so there is no warning-code registry to
            switch on. `code` is a plain string rather than a ProblemCode: that enum is the error-code
            registry and none of its members is a warning.
          items:
            type: object
            properties:
              code: { type: string }
              pointer: { type: string }
              message: { type: string }

  # ==========================================================================
  # REUSABLE RESPONSES
  # ==========================================================================
  responses:
    ServerProblem:
      description: >-
        The request could not be completed for a reason the API did not anticipate. Transient as far as a
        caller can tell: retry, and if it persists contact support. The cause is recorded internally and is
        deliberately absent from the body, so there is nothing here to branch on beyond `code`.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            internalServerError:
              summary: An unanticipated failure while handling the request
              value:
                type: 'https://tools.ietf.org/html/rfc9110#section-15.6.1'
                title: Internal server error.
                status: 500
                code: internal_server_error
                detail: 'The request could not be completed. Retry; if it keeps failing, contact support.'
    Unauthorized:
      description: 'Missing, invalid or revoked API key. Empty body.'
    Forbidden:
      description: >-
        The API key is valid, but the account's plan does not include customer-API access
        (`code: "customer_api_not_entitled"`). Permanent, not transient: retrying will not help; the
        account's plan must add customer-API access first.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            notEntitled:
              $ref: '#/components/examples/NotEntitled'
    ValidationProblem:
      description: >-
        Validation failed: `code: "validation_failed"`, with `errors[]` naming each offending field, query
        parameter or header. Every 400 on `/v2` has this shape; see Errors & troubleshooting.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            invalidGeometry:
              summary: A cargo line with a geometry type the API does not implement
              value:
                type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1'
                title: Validation failed.
                status: 400
                code: validation_failed
                errors:
                  - pointer: /cargo/0/geometry/type
                    code: validation_failed
                    message: Only 'box' geometry is supported.
    NotFoundProblem:
      description: 'Unknown job id, an id that is not a UUID in its 36-character form, or a job belonging to another account: all answer identically (no existence leak).'
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            unknownJob:
              $ref: '#/components/examples/JobNotFound'
    SavedLoadNotFoundProblem:
      description: 'Unknown saved-load id, one that is not a UUID in its 36-character form, one already released, or one belonging to another account: all answer identically (no existence leak).'
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            unknownSavedLoad:
              $ref: '#/components/examples/SavedLoadNotFound'
    PayloadTooLarge:
      description: >-
        The request body is larger than the API will read (`code: "payload_too_large"`). Permanent for that
        body: split the order into smaller jobs rather than retrying it unchanged.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            bodyTooLarge:
              summary: A body past the size the API reads
              value:
                type: 'https://tools.ietf.org/html/rfc9110#section-15.5.14'
                title: Payload too large.
                status: 413
                code: payload_too_large
                detail: The request body is larger than this API accepts. Split the order into smaller jobs.
    UnsupportedMediaType:
      description: >-
        The request body was sent under a content type other than `application/json`, or without one
        (`code: "unsupported_media_type"`). These operations read JSON only.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            wrongContentType:
              summary: A body sent as something other than JSON
              value:
                type: 'https://tools.ietf.org/html/rfc9110#section-15.5.16'
                title: Unsupported media type.
                status: 415
                code: unsupported_media_type
                detail: Send the request body as application/json.
    IdempotencyConflict:
      description: 'Idempotency-Key reused with a different request body. Empty body: no code, no detail.'
    EntitlementProblem:
      description: >-
        Job exceeds the account's entitlement. `code` says which of the three limits was hit
        (`max_items_per_job`, `max_carriers_per_job` or `max_carrier_instances_per_job`) and `detail` repeats
        it in prose with the limit's value. They are checked in that order and the first one hit is reported,
        so a job over two limits at once names only the first.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            tooManyItems:
              summary: More cargo units than the plan allows
              value:
                type: 'https://tools.ietf.org/html/rfc4918#section-11.2'
                title: Unprocessable Entity
                status: 422
                code: max_items_per_job
                detail: Job exceeds the maximum of 500 items.
    LibrariesForbidden:
      description: >-
        Either your plan has no customer-API access at all (`customer_api_not_entitled`), or it does not include
        the company library the id names (`libraries_not_entitled`): a type is available when the plan includes
        company libraries and its own size limit is not 0. The second is answered before any lookup, so it never
        tells you whether a library exists.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            notEntitled:
              $ref: '#/components/examples/NotEntitled'
            librariesNotEntitled:
              summary: The plan does not include company libraries
              value: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4', title: Company libraries are not available on your plan., status: 403, code: libraries_not_entitled, detail: Your plan does not include company libraries. }
    LibraryWriteForbidden:
      description: >-
        Either your plan has no customer-API access at all (`customer_api_not_entitled`), or it does not include
        the company library the id names (`libraries_not_entitled`; a type is available when the plan includes
        company libraries and its own size limit is not 0), or the id names the system catalogue, which only we
        change (`library_read_only`). The system-catalogue check needs no lookup and runs first, so a `POST`,
        `PUT` or `DELETE` on `carrier_system_default` always answers `library_read_only`, before the body or
        `Idempotency-Key` is read at all.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            notEntitled:
              $ref: '#/components/examples/NotEntitled'
            librariesNotEntitled:
              summary: The plan does not include company libraries
              value: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4', title: Company libraries are not available on your plan., status: 403, code: libraries_not_entitled, detail: Your plan does not include company libraries. }
            libraryReadOnly:
              summary: The library named by the id is the system catalogue
              value: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.4', title: This library is read-only., status: 403, code: library_read_only, detail: 'System libraries can be read and referenced in jobs, not changed.' }
    LibraryNotFound:
      description: >-
        No library you can use has this id. Another account's library answers exactly like one that does not exist.
        On a plan that lacks one of the company library types, a UUID that names none of your libraries answers
        `403 libraries_not_entitled` instead, because which type it would be cannot be told without a lookup.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            libraryNotAccessible:
              summary: Unknown, or not yours
              value: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.5', title: Library not found., status: 404, code: library_not_found }
    LibraryEntryNotFound:
      description: The library is not one you can use (`library_not_found`), or it has no entry with this id (`library_entry_not_found`).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            libraryItemNotFound:
              summary: No such entry in this library
              value: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.5', title: Library entry not found., status: 404, code: library_entry_not_found }
    RateLimited:
      description: >-
        Too many requests, too fast (`code: "rate_limit_exceeded"`). The account may make **300 requests per
        minute** across `/v2`, replenished continuously, and every operation except `GET /health` counts. The
        request was not carried out: wait the number of seconds in the **`Retry-After`** header, then send it
        again. A full queue and a used-up monthly quota answer `429` on `POST /jobs` only.
      headers:
        Retry-After:
          description: 'Seconds to wait before retrying.'
          schema: { type: integer, format: int32 }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            rateLimited:
              summary: Too many requests too fast
              value:
                title: Too many requests.
                status: 429
                code: rate_limit_exceeded
                detail: The account exceeded the request rate for the customer API. Retry after the number of seconds in the Retry-After header.
    TooManyActiveJobs:
      description: |-
        Three different limits answer `429`, and the `code` says which one you hit.

        - `rate_limit_exceeded`: too many requests, too fast. The account may make **300 requests per
          minute** across `/v2`, replenished continuously. This response carries a **`Retry-After`** header
          with the number of seconds to wait; no response with any other `code` carries one.
        - `too_many_active_jobs`: the account already has its maximum number of jobs queued or running,
          instructions jobs included. Wait for one to finish. **No `Retry-After`**.
        - `monthly_quota_exceeded`: the monthly job quota is used up. **No `Retry-After`**; it resets at the
          start of the next month (`GET /jobs/{id}` keeps working for jobs you already submitted).

        All three are retryable. For the two without a header, back off on a schedule of your own choosing.
      headers:
        Retry-After:
          description: 'Seconds to wait before retrying. Sent only with `code: "rate_limit_exceeded"`.'
          schema: { type: integer, format: int32 }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            rateLimited:
              summary: Too many requests too fast; the only response with Retry-After
              value:
                title: Too many requests.
                status: 429
                code: rate_limit_exceeded
                detail: The account exceeded the request rate for the customer API. Retry after the number of seconds in the Retry-After header.
            tooManyActive:
              summary: Concurrency limit; no Retry-After
              value:
                title: Too Many Requests
                status: 429
                code: too_many_active_jobs
                detail: Too many active jobs (limit 5). Wait for jobs to finish and retry.
            monthlyQuota:
              summary: Monthly quota used up; no Retry-After
              value:
                title: Too Many Requests
                status: 429
                code: monthly_quota_exceeded
                detail: Monthly order quota reached (limit 100).
