{
  "openapi": "3.0.3",
  "info": {
    "title": "Loader4D API",
    "version": "1.0.0",
    "description": "Cargo load planning. Send spaces and items, get a placement plan.\n\nUNITS: lengths are centimetres (cm), weights are kilograms (kg). There is no per-request unit setting; convert on your side.\n\nCOORDINATES: the origin is the front-left-bottom corner of the space. x runs along the length (front to back), y along the width (left to right), z along the height (floor upwards). A placement's x/y/z is that corner of the piece, not its centre.",
    "contact": { "name": "Loader4D", "url": "https://loader4d.com" }
  },
  "servers": [
    { "url": "https://loader4d.com", "description": "Production" }
  ],
  "security": [ { "bearerAuth": [] } ],
  "tags": [
    { "name": "Packing", "description": "Create and read packing jobs." },
    { "name": "Account", "description": "Usage and quota." }
  ],
  "paths": {
    "/api/v1/pack": {
      "post": {
        "tags": [ "Packing" ],
        "summary": "Start a packing job",
        "description": "Validates the request and queues the optimisation. Returns immediately with a job id; poll GET /api/v1/jobs/{id} for the result.\n\nThe job is asynchronous because an optimisation takes seconds and grows with the shipment. If the connection drops you come back with the job id and the result is still there.\n\nIDEMPOTENCY: send an Idempotency-Key header to make retries safe. The same key with the same body returns the original job instead of creating a second one. The same key with a different body is rejected with 422.",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "description": "A unique string you generate per logical request. Retrying with the same key does not create a second job."
          }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PackRequest" } } }
        },
        "responses": {
          "202": {
            "description": "Job accepted and queued.",
            "headers": {
              "Location": {
                "description": "Where to poll for the result.",
                "schema": { "type": "string" }
              }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/QuotaExceeded" },
          "422": { "$ref": "#/components/responses/IdempotencyConflict" },
          "429": { "$ref": "#/components/responses/TestKeyLimit" },
          "409": { "$ref": "#/components/responses/InProgress" }
        }
      }
    },
    "/api/v1/jobs/{id}": {
      "get": {
        "tags": [ "Packing" ],
        "summary": "Read a packing job",
        "description": "Returns the job. While status is \"queued\" or \"running\" the result is absent; poll again. A job belonging to another team returns 404 rather than 403, so that job ids cannot be probed.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "The job id returned by POST /api/v1/pack."
          }
        ],
        "responses": {
          "200": {
            "description": "The job.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/usage": {
      "get": {
        "tags": [ "Account" ],
        "summary": "Credit usage for the current period",
        "description": "Calls made with a test key are not counted.",
        "responses": {
          "200": {
            "description": "Usage.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Usage" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your API key, created on the API keys page. Send it as: Authorization: Bearer l4d_live_...\n\nKeys starting with l4d_test_ do not consume credits. Never put a key in a query string, a browser bundle or a mobile app."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body is malformed or a field is invalid. details.field names the offending field.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "NotFound": {
        "description": "No such job for this team.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "QuotaExceeded": {
        "description": "Monthly credit limit reached, or the API is not included in the plan. Code is \"quota_exceeded\" in the first case and \"forbidden\" in the second; the two are distinct because a team without an API plan has no quota to exhaust.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "InProgress": {
        "description": "A request with the same Idempotency-Key is still running. The body matched, so this is a duplicate in flight rather than a conflict. Retry in a moment; the completed response will then be replayed.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "TestKeyLimit": {
        "description": "Monthly test-key call limit reached. Test keys run the real engine and return real plans, so they are capped per month; they never consume credits. Switch to a live key.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "IdempotencyConflict": {
        "description": "This Idempotency-Key was already used with a different body.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      }
    },
    "schemas": {
      "PackRequest": {
        "type": "object",
        "required": [ "spaces", "items" ],
        "properties": {
          "spaces": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/Space" },
            "description": "At least one space. Single-space requests also use an array."
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 2000,
            "items": { "$ref": "#/components/schemas/Item" },
            "description": "Item lines. Total quantity across all lines must not exceed 20000."
          },
          "strategy": { "$ref": "#/components/schemas/Strategy" },
          "reference": {
            "type": "string",
            "description": "Your own reference. Returned unchanged on the job so you can match it to your record.",
            "example": "SO-2026-4417"
          }
        }
      },
      "Space": {
        "type": "object",
        "required": [ "length", "width", "height" ],
        "properties": {
          "id": { "type": "string", "description": "Your identifier; returned on the packed space." },
          "name": { "type": "string", "example": "40HC" },
          "type": {
            "type": "string",
            "enum": [ "container", "lorry", "semi_trailer", "trailer", "pallet" ],
            "default": "container"
          },
          "length": { "type": "number", "format": "double", "description": "Internal length, cm.", "example": 1200 },
          "width": { "type": "number", "format": "double", "description": "Internal width, cm.", "example": 235 },
          "height": { "type": "number", "format": "double", "description": "Internal height, cm.", "example": 269 },
          "max_weight": { "type": "number", "format": "double", "description": "Payload limit, kg. 0 means unlimited.", "example": 26000 },
          "available": { "type": "integer", "description": "How many you have. 0 means unlimited.", "default": 0 },
          "cost": { "type": "number", "format": "double", "description": "Trip cost, used when choosing spaces automatically. 0 means unset." },
          "stack_height": { "type": "number", "format": "double", "description": "Pallet only: how high cargo may rise above the pallet, cm." }
        }
      },
      "Item": {
        "type": "object",
        "required": [ "length", "quantity" ],
        "properties": {
          "id": { "type": "string", "description": "Your identifier; returned on every placement.", "example": "A1" },
          "name": { "type": "string" },
          "shape": { "type": "string", "enum": [ "box", "cylinder" ], "default": "box" },
          "length": { "type": "number", "format": "double", "description": "cm. For a cylinder this is the body length." },
          "width": { "type": "number", "format": "double", "description": "cm. Ignored for cylinders." },
          "height": { "type": "number", "format": "double", "description": "cm. Ignored for cylinders." },
          "diameter": { "type": "number", "format": "double", "description": "cm. Required when shape is cylinder; width and height are derived from it." },
          "weight": { "type": "number", "format": "double", "description": "Weight per piece, kg." },
          "quantity": { "type": "integer", "minimum": 1, "default": 1 },
          "color": { "type": "string", "description": "#RRGGBB for the 3D view. Does not affect packing." },
          "group": { "type": "string", "description": "Items in the same group are placed together in their own zone. Use it when unloading order matters." },
          "constraints": { "$ref": "#/components/schemas/Constraints" }
        }
      },
      "Constraints": {
        "type": "object",
        "description": "All default to false.",
        "properties": {
          "no_tilt": { "type": "boolean", "description": "May not be laid on its side." },
          "no_rotate": { "type": "boolean", "description": "May not be turned about the vertical axis." },
          "no_stack": { "type": "boolean", "description": "Nothing may be placed on top." },
          "floor_only": { "type": "boolean", "description": "Must sit on the floor." }
        }
      },
      "Strategy": {
        "type": "object",
        "description": "All fields optional. Omitting a field leaves the application default; it is not the same as sending false.",
        "properties": {
          "group_items": { "type": "boolean", "description": "Place groups as separate zones." },
          "respect_weight_limit": { "type": "boolean" },
          "multi_space": { "type": "boolean", "description": "Open more than one space. Switched on automatically when you send several spaces." },
          "nest_cylinders": { "type": "boolean" },
          "balance_load": { "type": "boolean", "description": "Balance the load across axles." },
          "keep_order": { "type": "boolean", "description": "Preserve the given order, for LIFO unloading." }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "status": { "type": "string", "enum": [ "queued", "running", "succeeded", "failed", "cancelled" ] },
          "progress": { "type": "integer", "description": "0-100. A rough indicator." },
          "reference": { "type": "string", "description": "Your reference, returned unchanged." },
          "created_at": { "type": "string", "format": "date-time" },
          "finished_at": { "type": "string", "format": "date-time", "nullable": true },
          "credits": { "type": "integer", "description": "Credits this request consumed." },
          "result": { "$ref": "#/components/schemas/PackResult" },
          "error": { "$ref": "#/components/schemas/Error" }
        }
      },
      "PackResult": {
        "type": "object",
        "description": "Present only when status is succeeded.",
        "properties": {
          "summary": { "$ref": "#/components/schemas/Summary" },
          "spaces": { "type": "array", "items": { "$ref": "#/components/schemas/PackedSpace" } },
          "unpacked": { "type": "array", "items": { "$ref": "#/components/schemas/Unpacked" } }
        }
      },
      "Summary": {
        "type": "object",
        "properties": {
          "spaces_used": { "type": "integer" },
          "pieces_requested": { "type": "integer" },
          "pieces_packed": { "type": "integer" },
          "weight": { "type": "number", "format": "double", "description": "Total loaded weight, kg." },
          "volume_utilization": { "type": "number", "format": "double", "description": "0-1." }
        }
      },
      "PackedSpace": {
        "type": "object",
        "properties": {
          "space_id": { "type": "string", "nullable": true, "description": "The id you sent, matched by position." },
          "name": { "type": "string" },
          "length": { "type": "number", "format": "double" },
          "width": { "type": "number", "format": "double" },
          "height": { "type": "number", "format": "double" },
          "weight": { "type": "number", "format": "double" },
          "volume_utilization": { "type": "number", "format": "double" },
          "placements": { "type": "array", "items": { "$ref": "#/components/schemas/Placement" } },
          "axle_loads": { "type": "array", "items": { "$ref": "#/components/schemas/AxleLoad" } }
        }
      },
      "Placement": {
        "type": "object",
        "description": "Where one piece sits. x/y/z is the front-left-bottom corner of the piece. length/width/height are the placed dimensions and may differ from the request if the piece was rotated.",
        "properties": {
          "item_id": { "type": "string", "nullable": true },
          "name": { "type": "string" },
          "sequence": { "type": "integer", "description": "Loading order, starting at 1." },
          "x": { "type": "number", "format": "double" },
          "y": { "type": "number", "format": "double" },
          "z": { "type": "number", "format": "double" },
          "length": { "type": "number", "format": "double" },
          "width": { "type": "number", "format": "double" },
          "height": { "type": "number", "format": "double" },
          "weight": { "type": "number", "format": "double" },
          "pallet_id": { "type": "string", "nullable": true }
        }
      },
      "AxleLoad": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "position": { "type": "number", "format": "double", "description": "Distance from the front, cm." },
          "load": { "type": "number", "format": "double", "description": "kg" },
          "max_load": { "type": "number", "format": "double", "description": "kg. 0 means unset." }
        }
      },
      "Unpacked": {
        "type": "object",
        "properties": {
          "item_id": { "type": "string", "nullable": true },
          "name": { "type": "string" },
          "requested": { "type": "integer" },
          "packed": { "type": "integer" },
          "reason": {
            "type": "string",
            "nullable": true,
            "description": "A machine-readable code. There is deliberately no human sentence here: that text depends on the reader's language and an API response should not. Translate the code on your side."
          }
        }
      },
      "Usage": {
        "type": "object",
        "properties": {
          "period_start": { "type": "string", "format": "date-time" },
          "credits_limit": { "type": "integer" },
          "credits_used": { "type": "integer" },
          "credits_left": { "type": "integer" }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "description": "Every failure has this shape, whatever the status code, so you only need one parsing path.",
        "properties": { "error": { "$ref": "#/components/schemas/Error" } }
      },
      "Error": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "unauthorized", "forbidden", "not_found", "invalid_request",
              "quota_exceeded", "rate_limited", "idempotency_conflict",
              "request_in_progress", "internal_error"
            ]
          },
          "message": { "type": "string", "description": "For developers. English and fixed; it does not vary by language." },
          "details": {
            "type": "object",
            "additionalProperties": { "type": "string" },
            "description": "Extra context, such as which field was wrong."
          }
        }
      }
    }
  }
}
