# CargoDuo Packing API: integration playbook You are integrating the CargoDuo packing API into this project. Follow the steps in order. Everything you need is in this file; per-page docs are listed at https://runner.cargoduo.com/api/public/api-docs/llms.txt and the OpenAPI document is at https://runner.cargoduo.com/api/public/api-docs/openapi.json. ## 1. What the API does Input: the vehicles available and the items to ship. Output: a 3D load plan (which vehicle, position, orientation and loading step of every item unit), what did not fit, and a PDF link. Jobs run asynchronously: create a job, wait for it, read the result. ## 2. Setup - Base URL: `https://runner.cargoduo.com/v1` - Auth: `Authorization: Bearer $CARGODUO_API_KEY` on every request. Keys look like `cd_live_` + 40 characters and are created in the CargoDuo developer console. - Read the key from the environment variable `CARGODUO_API_KEY`. Call the API from server-side code only; never put the key in a browser or mobile app. - JSON in and out (`Content-Type: application/json`). Lengths in cm, weights in kg, money in credits. Max body 10 MB. - Every response has `X-Request-Id` (req_…). Log it. ## 3. Build the request `POST /v1/pack` and the free `POST /v1/estimate` take the same body: ```json { "reference": "order-1042", "vehicles": [ { "id": "trailer", "type": "trailer", "name": "13.6 m trailer", "length": 1360, "width": 245, "height": 270, "max_weight": 24000 } ], "items": [ { "id": "pallet-eu", "label": "Euro pallet", "length": 120, "width": 80, "height": 140, "weight": 450, "quantity": 18, "stackable": false }, { "id": "carton-a", "label": "Carton A", "length": 60, "width": 40, "height": 40, "weight": 12, "quantity": 40, "rotation": "upright" } ], "options": { "packing_mode": "auto" } } ``` Rules: - `id`s are yours and unique per list; results refer to them as `vehicle_id` / `item_id`. - `weight` is per unit. `quantity` repeats a definition. Vehicle sizes are inner cargo-space sizes; item sizes are outer package sizes. - `rotation`: `all` (default), `upright` (only turns around the vertical axis), `fixed`. `stackable: false` keeps everything off an item's top. - A vehicle may name a saved vehicle with `vehicle_id` (a preset or one of the organization's, from `GET /v1/vehicles`) and an item a template with `template_id` (`GET /v1/templates`); fields sent next to the id override the saved values. - Vehicle `type` must be one of: `truck`, `trailer`, `container`, `pallet`. - At most 2,000 item units (sum of `quantity`) and 20 vehicles per job. Every item must fit at least one listed vehicle. - Per vehicle: at most 32 `blocked_areas` and 10 axles. - Fields marked "Requires access" below (the axle model) are enabled per organization on request. Without access they are accepted and ignored: the plan does not check axle limits and the result has no `axle_loads`. Leave them out unless the user asks for them. - Unknown fields are rejected (`unknown_field`). Validate with `POST /v1/estimate` first: it is free and returns every problem at once. ### PackRequest One packing job: the vehicles available, the items to load, optional groups and options. Same body for POST /v1/pack and POST /v1/estimate. | Name | Type | Required | Description | |---|---|---|---| | `reference` | string | No | Your own reference (order number, shipment id…). Echoed back on the job. Max 128 characters. | | `vehicles` | Vehicle[] | Yes | Vehicle definitions. Each one may have quantity > 1. At least one. | | `items` | Item[] | Yes | Item definitions. Each one may have quantity > 1. At least one. | | `groups` | Group[] | No | Optional groups (customers, stops, orders). Required for every group an item names when `options.group_routing` is not `"none"`; otherwise groups are created from the item group names in order of appearance. | | `options` | Options | No | Packing options. Every field is optional. | ### Vehicle A vehicle definition: a truck, trailer, container, pallet… Either name a saved vehicle with `vehicle_id` (a preset or one of yours, from GET /v1/vehicles) or give `type` + dimensions + `max_weight`. Fields sent next to `vehicle_id` override the saved values. | Name | Type | Required | Description | |---|---|---|---| | `id` | string | No | Your identifier, unique within the request. Results refer to it as vehicle_id. Defaults to vehicle_id. Max 128 characters. | | `vehicle_id` | string | No | A vehicle id from GET /v1/vehicles: a preset or one of your organization's vehicles (veh_…). Supplies type, name, dimensions, payload and every other saved value. | | `type` | string | No | Vehicle type. Required unless vehicle_id is given. Only the types listed in GET /v1/limits (allowed_vehicle_types) are accepted. One of: `truck`, `trailer`, `flatbed`, `container`, `pallet`, `aircraft`, `ship`. | | `name` | string | No | Display name (defaults to the saved vehicle's name or the id). Max 255 characters. | | `length` | number | No | Internal cargo-space length, along x (from the front wall toward the door). Required unless vehicle_id is given. Unit: `cm`. | | `width` | number | No | Internal cargo-space width, along z. Required unless vehicle_id is given. Unit: `cm`. | | `height` | number | No | Internal cargo-space height, along y. Required unless vehicle_id is given. Unit: `cm`. | | `max_weight` | number | No | Maximum payload. Required unless vehicle_id is given. Unit: `kg`. | | `quantity` | integer | No | How many identical vehicles of this definition are available. Default: `1`. 1–100. | | `floor_load_capacity` | number | No | Floor load capacity: 0 or more (0 = no limit). Unit: `kg/m²`. | | `aux_deck` | boolean | No | The vehicle has a second deck (double-decker). Default: `false`. | | `aux_deck_height` | number | No | Second deck height above the floor. Required when `aux_deck` is `true`. Unit: `cm`. | | `aux_deck_max_weight` | number | No | Load limit of the second deck. Required when `aux_deck` is `true`. Unit: `kg`. | | `blocked_areas` | Rect[] | No | Floor zones nothing may stand on (wheel arches, damage, reserved space). Each must lie on the vehicle floor. At most 32 entries. | | `axle_positions` | number[] | No | Axle (support point) positions measured from the front of the cargo area; negative = in front of it. Same length as axle_max_loads. Each between −300 (an axle ahead of the cargo area, e.g. a rigid truck's front axle under the cab) and the vehicle length. At most 10 entries. Unit: `cm`. Requires access (`axle_loads`). | | `axle_max_loads` | number[] | No | Maximum cargo load per axle, in the order of axle_positions. Each greater than 0 and at most 100000. At most 10 entries. Unit: `kg`. Requires access (`axle_loads`). | | `cog_range_x` | number | No | Allowed centre-of-gravity deviation from the middle of the length: 0 to length / 2. Unit: `cm`. Requires access (`axle_loads`). | | `cog_range_y` | number | No | Allowed centre-of-gravity deviation from the middle of the width: 0 to width / 2. Unit: `cm`. Requires access (`axle_loads`). | | `tractor_weight` | number | No | Semi-trailer axle model: tractor unit mass. Unit: `kg`. Requires access (`axle_loads`). | | `empty_trailer_weight` | number | No | Semi-trailer axle model: unladen trailer mass. Unit: `kg`. Requires access (`axle_loads`). | | `front_axle_to_middle_axle` | number | No | Semi-trailer axle model: tractor front axle to drive (middle) axle. Unit: `cm`. Requires access (`axle_loads`). | | `front_axle_to_tractor_cog` | number | No | Semi-trailer axle model: front axle to tractor centre of gravity. Unit: `cm`. Requires access (`axle_loads`). | | `front_axle_to_kingpin` | number | No | Semi-trailer axle model: front axle to kingpin (fifth wheel). Unit: `cm`. Requires access (`axle_loads`). | | `kingpin_to_rear_axle` | number | No | Semi-trailer axle model: kingpin to trailer rear axle group. Unit: `cm`. Requires access (`axle_loads`). | | `trailer_cog_to_rear_axle` | number | No | Semi-trailer axle model: trailer centre of gravity to rear axle group. Unit: `cm`. Requires access (`axle_loads`). | | `trailer_start_to_kingpin` | number | No | Semi-trailer axle model: trailer front to kingpin. Unit: `cm`. Requires access (`axle_loads`). | | `max_middle_axle_weight` | number | No | Semi-trailer axle model: legal drive (middle) axle limit. Unit: `kg`. Requires access (`axle_loads`). | | `max_rear_axle_weight` | number | No | Semi-trailer axle model: legal rear axle group limit. Unit: `kg`. Requires access (`axle_loads`). | ### Item An item definition (one SKU / package type). `quantity` identical units are loaded; each unit is reported as one placement with an `instance` number. Name a saved template with `template_id` and send only what differs. | Name | Type | Required | Description | |---|---|---|---| | `id` | string | No | Your identifier, unique within the request. Results refer to it as item_id. Defaults to template_id. Max 128 characters. | | `template_id` | string | No | A template id (tpl_…) from GET /v1/templates. Supplies every item field; fields sent next to it override them. | | `label` | string | No | Display label (defaults to the id). Max 255 characters. | | `shape` | string | No | Item shape. `cylinder` and `drum` use `radius` + `height` instead of `length` + `width`; `pallet` and `drum` always stay upright. One of: `box`, `cylinder`, `pallet`, `bag`, `drum`. Default: `"box"`. | | `length` | number | No | Length. Required unless `shape` is `cylinder` or `drum`. Unit: `cm`. | | `width` | number | No | Width. Required unless `shape` is `cylinder` or `drum`. Unit: `cm`. | | `height` | number | Yes | Height (the vertical side when the item stands as defined). Unit: `cm`. | | `radius` | number | No | Radius. Required when `shape` is `cylinder` or `drum`. Unit: `cm`. | | `weight` | number | Yes | Weight of ONE unit. Unit: `kg`. | | `quantity` | integer | No | Number of identical units. Default: `1`. 1–10000. | | `stackable` | boolean | No | Other items may be placed on top of this one. Default: `true`. | | `max_stack_weight` | number | No | Maximum weight that may rest on one unit. 0 = unlimited. Default: `0`. Unit: `kg`. | | `rotation` | string | No | `all` = any of the six orientations; `upright` = may only turn around the vertical axis (this side up); `fixed` = never rotated. One of: `all`, `upright`, `fixed`. Default: `"all"`. | | `hazardous` | boolean | No | Dangerous goods: kept low and separated from other hazardous items. Default: `false`. | | `priority` | integer | No | Load priority. Higher = loaded first and kept when space runs out. 0 = none. Default: `0`. | | `group` | string | No | Name of the group this item belongs to (see `groups` and `options.group_routing`). | | `color` | string | No | Display colour as #rrggbb (viewer and PDF only). | | `nesting_height` | number | No | Telescoping overlap when identical units stack (nested crates, buckets). Default: `0`. Unit: `cm`. | ### Group A group of items (a customer, a stop, an order). With group routing, group order decides the loading sequence: order 1 is loaded first (deepest, at the front wall), the highest order last (at the door). Items without a group go last. | Name | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Group name, referenced by Item.group. Max 128 characters. | | `order` | integer | No | Loading order (lower = loaded first). Defaults to the position in the list. | | `color` | string | No | Display colour as #rrggbb. | ### Options Packing options. Every field is optional. | Name | Type | Required | Description | |---|---|---|---| | `packing_mode` | string | No | `auto` = when the listed vehicles are full, more copies of the most suitable vehicle are added (up to the job's vehicle limit); `fleet_only` = only the listed vehicles are used, anything that does not fit is reported as unplaced. One of: `auto`, `fleet_only`. Default: `"auto"`. | | `group_routing` | string | No | `none` = groups are informational; `wall` = each group gets its own section of the vehicle; `routing` = groups load in order and a later group may sit on or beside an earlier one but never below it (multi-stop delivery); `no_stack` = groups load in order side by side, never on top of each other. One of: `none`, `wall`, `routing`, `no_stack`. Default: `"none"`. | | `allow_overweight` | boolean | No | Ignore vehicle payload limits. Default: `false`. | | `max_stack_items` | integer | No | Maximum items in one stack. 0 = unlimited. Default: `0`. | | `max_stack_density_kg_m2` | number | No | Maximum stack weight per floor area. 0 = unlimited. Default: `0`. Unit: `kg/m²`. | | `lock_rotations` | boolean | No | Keep the first orientation that fits the vehicle height. Default: `false`. | | `group_identical_bins` | boolean | No | Treat identical vehicles as one pool. Default: `false`. | | `time_limit` | number | No | Upper bound on compute time for the whole job. 0 = automatic (capped at max_time_limit_seconds from GET /v1/limits). When the limit is reached the best plan found so far is returned. You pay only for the compute time actually used. Default: `0`. Unit: `s`. | | `enforce_axle` | boolean | No | Reject placements that overload an axle group, on vehicles that carry all ten axle-model fields. Default: `false`. Requires access (`axle_loads`). | ### Rect A rectangle on the vehicle floor (min-corner origin). | Name | Type | Required | Description | |---|---|---|---| | `x` | number | Yes | Distance from the front wall to the rectangle's near edge. Unit: `cm`. | | `z` | number | Yes | Distance from the right wall (z = 0; right as seen from the door looking in) to the rectangle's near edge. Unit: `cm`. | | `length` | number | Yes | Extent along x. Unit: `cm`. | | `width` | number | Yes | Extent along z. Unit: `cm`. | ### Saved data and projects The organization's vehicles (`/v1/vehicles`), cargo templates (`/v1/templates`) and projects (`/v1/projects`) are the same records the CargoDuo app shows. Reading needs `resources:read`, changing needs `resources:write`; these calls are free (rate-limited and logged). Ids: `veh_…`, `tpl_…`, `prj_…`, project slots `pv_…` and items `item_…`; presets keep their slug. Lists take `limit`, `cursor`, `q` and `created_by=me|usr_…` and answer `{"data": [...], "next_cursor"}`. Every record shows `created_by` / `updated_by` (`{id, name}`): calls are recorded against the member who created the key. Another organization's ids answer 404. To plan inside the app: `POST /v1/projects` (name, `vehicles` as `{vehicle_id, quantity}` plus overrides, `items` in the pack shape), then `POST /v1/projects/{id}/pack` (needs `pack:write` and `resources:write`). It runs and costs exactly like `POST /v1/pack` and, on success, saves the plan into the project so it opens in the app's 3D editor; `GET /v1/projects/{id}` shows the plan and its `pdf_url`. If the project changed during the run the plan stays on the job (warning `project_not_saved`). ## 4. Create the job and wait 1. `POST /v1/pack?wait=60` with header `Idempotency-Key: ` (e.g. `order-1042-v1`). 2. `200`: the job ended within the wait; `status` is final and `result` is inlined. `202`: still `queued` or `running`. 3. On `202`, poll `GET /v1/jobs/{id}?include=result` every 2 s until `status` is `succeeded`, `failed` or `canceled`. 4. `succeeded`: read `result`. `failed` / `canceled`: read `error.code` (section 7). Statuses: `queued` → `running` → `succeeded` | `failed` | `canceled`. Final statuses never change. `DELETE /v1/jobs/{id}` cancels a queued or running job. ## 5. Read the result Coordinates are cm from the min corner of each vehicle's cargo space: **x** along the length from the front wall (0) to the door, **y** up from the floor, **z** across the width from the right wall (0, seen from the door looking in) to the left wall. A placement's `x, y, z` is the item's min corner; `length`, `width`, `height` are its size along x, z and y as placed. `orientation` names which defined side lies along x, z and y (`lwh` = as defined, `wlh` = turned 90° around the vertical axis). `step` is the loading order inside the vehicle, 1 first. ### Result | Name | Type | Required | Description | |---|---|---|---| | `job_id` | string | No | The job. | | `vehicles` | ResultVehicle[] | No | Loaded vehicles in loading order (empty vehicles are left out). | | `unplaced` | UnplacedItem[] | No | Units that could not be placed, with the reason. | | `warnings` | Warning[] | No | Remarks about the plan. | | `pdf_url` | string | No | Browser link that renders the plan as a PDF. | | `expires_at` | string | No | When this result is deleted (RFC 3339). | ### ResultVehicle | Name | Type | Required | Description | |---|---|---|---| | `index` | integer | No | 0-based position in the plan. | | `vehicle_id` | string | No | Your vehicle definition id (for a project job: the slot id, pv_…). | | `copy` | integer | No | 1-based copy number of that definition (quantity > 1, or added in auto mode). | | `auto_added` | boolean | No | Added by `packing_mode` `auto` beyond the `quantity` you listed. | | `name` | string | No | Vehicle name. | | `type` | string | No | Vehicle type. | | `length` | number | No | Cargo-space length. Unit: `cm`. | | `width` | number | No | Cargo-space width. Unit: `cm`. | | `height` | number | No | Cargo-space height. Unit: `cm`. | | `max_weight` | number | No | Payload limit. Unit: `kg`. | | `items_placed` | integer | No | Units in this vehicle. | | `total_weight` | number | No | Weight in this vehicle. Unit: `kg`. | | `volume_utilization` | number | No | Used volume / cargo-space volume, 0–1. | | `weight_utilization` | number | No | total_weight / max_weight, 0–1 (may exceed 1 with allow_overweight). | | `center_of_gravity` | Point | No | Centre of gravity of the load (cm). | | `placements` | Placement[] | No | Every unit in this vehicle. | | `axle_loads` | AxleLoad[] | No | Load per axle (vehicles with axle data only). Requires access (`axle_loads`). | ### Placement | Name | Type | Required | Description | |---|---|---|---| | `item_id` | string | No | Your item definition id (for a project job: the item id, item_…). | | `instance` | integer | No | 1-based unit number within that item definition (unique across the whole plan). | | `label` | string | No | Item label. | | `x` | number | No | Min-corner position along the length. Unit: `cm`. | | `y` | number | No | Min-corner height above the floor. Unit: `cm`. | | `z` | number | No | Min-corner position across the width. Unit: `cm`. | | `length` | number | No | Size along x as placed. Unit: `cm`. | | `width` | number | No | Size along z as placed. Unit: `cm`. | | `height` | number | No | Size along y as placed. Unit: `cm`. | | `rotated` | boolean | No | Placed in a different orientation than defined. | | `orientation` | string | No | Which defined side lies along x, z and y: `lwh` = as defined, `wlh` = turned 90° around the vertical axis, `lhw`, `hwl`, `whl`, `hlw` = tipped onto a side. | | `step` | integer | No | 1-based loading step within the vehicle (load in ascending order). | | `weight` | number | No | Unit weight. Unit: `kg`. | ### UnplacedItem | Name | Type | Required | Description | |---|---|---|---| | `item_id` | string | No | Your item definition id. | | `instance` | integer | No | 1-based unit number. | | `label` | string | No | Item label. | | `reason` | string | No | Why it could not be placed. | `pdf_url` opens a page that builds the loading plan PDF in the browser. It works until the job's `expires_at` (14 days); store the result JSON if you need it longer. ## 6. Idempotency The same `Idempotency-Key` within 24 hours returns the original job (header `Idempotent-Replayed: true`) instead of creating and charging a new one. Keys are at most 128 characters. Reuse the key only to retry the same body. ## 7. Errors and retries Every non-2xx response: `{"error": {"code", "message", "request_id", "details"}}`. Branch on `error.code`. On `validation_failed`, `details[]` has `path` (e.g. `items[3].length`), `code` and `message` for every problem. | Status | Code | Retry | What to do | |---|---|---|---| | 400 | `invalid_json` | no | Fix the request body. | | 401 | `unauthorized` | no | Send Authorization: Bearer cd_live_…; check the key was copied completely. | | 401 | `key_revoked` | no | Create a new key in the developer console. | | 401 | `key_expired` | no | Create a new key in the developer console. | | 402 | `insufficient_credits` | no | Buy a credit pack or wait for the monthly credits; nothing was charged. | | 403 | `ip_not_allowed` | no | Call from an allowed IP or edit the allowlist. | | 403 | `subscription_inactive` | no | Renew the subscription. | | 403 | `account_inactive` | no | Contact support. | | 403 | `scope_missing` | no | Use a key with the scope (pack:write, jobs:read, account:read, resources:read, resources:write). | | 403 | `preset_read_only` | no | Create your own vehicle with POST /v1/vehicles. | | 403 | `plan_limit_reached` | no | Delete projects you no longer need, or move to a larger plan. | | 404 | `not_found` | no | Check the method and path against the endpoint list. | | 404 | `job_not_found` | no | Check the id. | | 404 | `vehicle_not_found` | no | Check the id against GET /v1/vehicles. | | 404 | `template_not_found` | no | Check the id against GET /v1/templates. | | 404 | `project_not_found` | no | Check the id against GET /v1/projects. | | 409 | `job_not_finished` | yes | Poll GET /v1/jobs/{id} until `status` is `succeeded`. | | 409 | `project_busy` | yes | Wait for that run to end, then retry. | | 409 | `job_failed` | no | Read `job.error`; failures on our side are not charged (time_limit_exceeded is charged like a canceled job) — resubmit if the cause was temporary. | | 409 | `job_canceled` | no | Resubmit if needed. | | 409 | `job_already_finished` | no | Nothing to do. | | 400 | `idempotency_key_invalid` | no | Use a shorter key (a UUID works). | | 410 | `result_expired` | no | Resubmit the job if you need the plan again. | | 413 | `payload_too_large` | no | Send fewer or simpler items. | | 422 | `validation_failed` | no | Fix each details[].path; validation failures are free. | | 429 | `rate_limited` | yes | Wait Retry-After seconds, then retry with backoff. | | 429 | `too_many_jobs` | yes | Wait for running jobs to finish, then retry. | | 500 | `internal_error` | yes | Retry with backoff; quote request_id if it persists. | | 503 | `api_disabled` | yes | Retry later. | Job errors (`job.error.code`): | Code | Meaning | |---|---| | `canceled` | Canceled by DELETE /v1/jobs/{id}. Charged base + compute used. | | `time_limit_exceeded` | The compute budget ran out before any vehicle was packed. Charged like a canceled job: per-job fee, large-load surcharge and the compute used, never the per-vehicle fee. | | `engine_failed` | The packing engine failed. Not charged. | | `queue_timeout` | The job waited too long for a compute slot. Not charged. | | `server_restarted` | The service restarted while the job was queued or running. Not charged. | Retry policy: retry `429`, `500`, `503` with exponential backoff, waiting at least `Retry-After` seconds, always with the same `Idempotency-Key`. Never retry other 4xx unchanged. Failed authentications are limited per IP: after 60 in a minute, that IP gets `429 rate_limited`. ## 8. Limits | Limit | Default | |---|---| | Requests per minute / hour / day (per key and per organization) | 60 / 1,000 / 5,000 | | Item units per job | 2,000 | | Vehicles per job | 20 | | Compute budget per job | 90 s | | Jobs computing at once / queued + running | 1 / 20 | | `?wait` | 60 s | | Retention | 14 days | `GET /v1/limits` returns the values for the organization. Rate-limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. ## 9. Credits Balances and costs are in US dollars: 1 credit = $1 (fields keep the name `credits`). Most jobs cost 0.27–0.34 credits (rate card v1; `GET /v1/limits` returns it). Only packing jobs cost credits. A job reserves its worst-case price and pays the actual one when it ends. Canceled jobs pay the per-job fee and the compute used, never the per-vehicle fee. Jobs whose compute budget ran out before anything was packed (`time_limit_exceeded`) are charged like canceled jobs. Rejected requests (4xx) and failures on our side are free. With no available credits `POST /v1/pack` answers `402 insufficient_credits`. `POST /v1/estimate` returns the price range for free; `X-Credits-Remaining` and `GET /v1/balance` show the balance. ## 10. Worked example ### cURL ```bash API=https://runner.cargoduo.com/v1 # Free validation and price range curl -s -X POST "$API/estimate" -H "Authorization: Bearer $CARGODUO_API_KEY" \ -H "Content-Type: application/json" -d @request.json # Create the job, wait up to 60 s JOB=$(curl -s -X POST "$API/pack?wait=60" -H "Authorization: Bearer $CARGODUO_API_KEY" \ -H "Content-Type: application/json" -H "Idempotency-Key: order-1042-v1" -d @request.json) ID=$(echo "$JOB" | jq -r .id) # Poll until it ends while :; do STATUS=$(curl -s "$API/jobs/$ID" -H "Authorization: Bearer $CARGODUO_API_KEY" | jq -r .status) [ "$STATUS" = queued ] || [ "$STATUS" = running ] || break sleep 2 done # The plan curl -s "$API/jobs/$ID/result" -H "Authorization: Bearer $CARGODUO_API_KEY" ``` ### Node.js (18+) ```javascript const API = "https://runner.cargoduo.com/v1"; const KEY = process.env.CARGODUO_API_KEY; // server-side only async function call(method, path, body, headers = {}) { for (let attempt = 0; ; attempt++) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...headers }, body: body ? JSON.stringify(body) : undefined, }); if ([429, 500, 503].includes(res.status) && attempt < 4) { const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt; await new Promise((r) => setTimeout(r, wait * 1000)); continue; } const data = await res.json(); if (!res.ok) throw Object.assign(new Error(data.error.message), data.error, { status: res.status }); return data; } } export async function planLoad(request, idempotencyKey) { let job = await call("POST", "/pack?wait=60", request, { "Idempotency-Key": idempotencyKey }); while (job.status === "queued" || job.status === "running") { await new Promise((r) => setTimeout(r, 2000)); job = await call("GET", `/jobs/${job.id}?include=result`); } if (job.status !== "succeeded") throw new Error(`job ${job.id} ${job.status}: ${job.error?.code}`); return job; // job.result.vehicles[].placements, job.pdf_url, job.cost.credits } ``` ### Python (requests) ```python import os, time, requests API = "https://runner.cargoduo.com/v1" S = requests.Session() S.headers["Authorization"] = f"Bearer {os.environ['CARGODUO_API_KEY']}" def call(method, path, json=None, headers=None): for attempt in range(5): r = S.request(method, API + path, json=json, headers=headers or {}, timeout=90) if r.status_code in (429, 500, 503) and attempt < 4: time.sleep(int(r.headers.get("Retry-After", 2 ** attempt))) continue body = r.json() if not r.ok: raise RuntimeError(f"{r.status_code} {body['error']['code']}: {body['error']['message']}") return body def plan_load(request, idempotency_key): job = call("POST", "/pack?wait=60", json=request, headers={"Idempotency-Key": idempotency_key}) while job["status"] in ("queued", "running"): time.sleep(2) job = call("GET", f"/jobs/{job['id']}?include=result") if job["status"] != "succeeded": raise RuntimeError(f"job {job['id']} {job['status']}: {job.get('error')}") return job request = { "reference": "order-1042", "vehicles": [ { "id": "trailer", "type": "trailer", "name": "13.6 m trailer", "length": 1360, "width": 245, "height": 270, "max_weight": 24000, }, ], "items": [ { "id": "pallet-eu", "label": "Euro pallet", "length": 120, "width": 80, "height": 140, "weight": 450, "quantity": 18, "stackable": False, }, { "id": "carton-a", "label": "Carton A", "length": 60, "width": 40, "height": 40, "weight": 12, "quantity": 40, "rotation": "upright", }, ], "options": { "packing_mode": "auto", }, } job = plan_load(request, "order-1042-v1") for v in job["result"]["vehicles"]: for p in v["placements"]: print(v["vehicle_id"], p["step"], p["item_id"], p["instance"], (p["x"], p["y"], p["z"]), p["orientation"]) print("PDF:", job["pdf_url"], "cost:", job["cost"]["credits"]) ``` ## 11. Checklist - [ ] Key read from `CARGODUO_API_KEY`, used server-side only. - [ ] Request validated with `POST /v1/estimate` while building it. - [ ] `Idempotency-Key` on every `POST /v1/pack`, reused on retries. - [ ] `?wait=60`, then polling every 2 s; `Retry-After` honoured. - [ ] Errors branch on `error.code`; `request_id` logged. - [ ] Result JSON stored if needed beyond `expires_at`. ## 12. Endpoints ### POST /v1/pack Validates the request, reserves the worst-case price from your credits and queues a job. Answers 202 with the job; poll GET /v1/jobs/{id}, or pass ?wait= to hold the response until it ends. Validation failures (422) are free and create no job. Scope: `pack:write`. Parameters: `wait` (query), `Idempotency-Key` (header). 202: `Job`. 200: `Job`. Errors: `invalid_json`, `idempotency_key_invalid`, `insufficient_credits`, `payload_too_large`, `validation_failed`, `too_many_jobs`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/create-pack-job.md ### POST /v1/estimate Runs the same validation as POST /v1/pack and returns the price range, without creating a job or reserving credits. Free. An invalid request answers 200 with `valid: false` and every problem in `errors[]`. Scope: `pack:write`. 200: `Estimate`. Errors: `invalid_json`, `payload_too_large`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/estimate-pack-job.md ### GET /v1/jobs Your organization's jobs, newest first, one page at a time. Scope: `jobs:read`. Parameters: `limit` (query), `cursor` (query), `status` (query). 200: `JobList`. Errors: `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/list-jobs.md ### GET /v1/jobs/{id} A job's status, progress, summary and cost. Poll it every 1–2 s until `status` is `succeeded`, `failed` or `canceled`. Scope: `jobs:read`. Parameters: `id` (path), `include` (query). 200: `Job`. Errors: `job_not_found`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-job.md ### GET /v1/jobs/{id}/result The load plan of a succeeded job: every vehicle used, every placement, what did not fit and the PDF link. Scope: `jobs:read`. Parameters: `id` (path). 200: `Result`. Errors: `job_not_found`, `job_not_finished`, `job_failed`, `job_canceled`, `result_expired`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-job-result.md ### DELETE /v1/jobs/{id} Stops a queued or running job. A canceled job pays the per-job fee, any large-load surcharge and the compute used so far, never the per-vehicle fee. Canceling a canceled job returns it unchanged. Scope: `pack:write`. Parameters: `id` (path). 200: `Job`. Errors: `job_not_found`, `job_already_finished`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/cancel-job.md ### GET /v1/projects Your organization's projects (the app's load plans), newest first, one page at a time. Free: no credits are charged. Scope: `resources:read`. Parameters: `limit` (query), `cursor` (query), `q` (query), `created_by` (query). 200: `ProjectList`. Errors: `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/list-projects.md ### POST /v1/projects Creates a project that opens in the app: a name, and optionally vehicles (saved vehicles by vehicle_id), items and groups in the /v1/pack shapes and the stored options. Pack it with POST /v1/projects/{id}/pack. Free: no credits are charged. Scope: `resources:write`. 201: `Project`. Errors: `invalid_json`, `payload_too_large`, `validation_failed`, `plan_limit_reached`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/create-project.md ### GET /v1/projects/{id} A project with its vehicles, items, groups, options and a summary of its current plan (with the PDF link while the plan is the one an API job saved). Free: no credits are charged. Scope: `resources:read`. Parameters: `id` (path). 200: `Project`. Errors: `project_not_found`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-project.md ### PATCH /v1/projects/{id} Renames a project, changes its options, or replaces its vehicles or its items (with groups). Replacing vehicles or items clears the stored plan; open app sessions reload. Free: no credits are charged. Scope: `resources:write`. Parameters: `id` (path). 200: `Project`. Errors: `project_not_found`, `invalid_json`, `payload_too_large`, `validation_failed`, `plan_limit_reached`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/update-project.md ### DELETE /v1/projects/{id} Deletes a project with its vehicles, items and plan history, in the app too. Free: no credits are charged. Scope: `resources:write`. Parameters: `id` (path). 200: `Deleted`. Errors: `project_not_found`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/delete-project.md ### POST /v1/projects/{id}/pack Packs the project's vehicles and items exactly like POST /v1/pack: same price, credit reservation, limits, Idempotency-Key, ?wait= and job. When the job succeeds its plan is saved into the project, which then opens in the app with that plan. If the project changed while the job ran, the plan stays on the job only (warning project_not_saved). Result ids are the project's: vehicle_id is the slot id (pv_…), item_id the item id (item_…). Scope: `pack:write` and `resources:write`. Parameters: `id` (path), `wait` (query), `Idempotency-Key` (header). 202: `Job`. 200: `Job`. Errors: `project_not_found`, `project_busy`, `invalid_json`, `idempotency_key_invalid`, `insufficient_credits`, `payload_too_large`, `validation_failed`, `too_many_jobs`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/pack-project.md ### GET /v1/vehicles Every vehicle you can name with vehicle_id: the presets (containers, trailers, trucks, pallets of the types your organization may use), then your organization's own vehicles. Free: no credits are charged. Scope: `resources:read`. Parameters: `limit` (query), `cursor` (query), `kind` (query), `type` (query), `q` (query), `created_by` (query). 200: `SavedVehicleList`. Errors: `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/list-vehicles.md ### GET /v1/vehicles/{id} A preset or one of your vehicles. Free: no credits are charged. Scope: `resources:read`. Parameters: `id` (path). 200: `SavedVehicle`. Errors: `vehicle_not_found`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-vehicle.md ### POST /v1/vehicles Saves a vehicle to your organization's fleet (it shows in the app too). Same fields and access rules as a /v1/pack vehicle, judged by the app's size and payload limits for its type. Free: no credits are charged. Scope: `resources:write`. 201: `SavedVehicle`. Errors: `invalid_json`, `payload_too_large`, `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/create-vehicle.md ### PATCH /v1/vehicles/{id} Changes one of your vehicles. Presets are read-only. Free: no credits are charged. Scope: `resources:write`. Parameters: `id` (path). 200: `SavedVehicle`. Errors: `vehicle_not_found`, `preset_read_only`, `invalid_json`, `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/update-vehicle.md ### DELETE /v1/vehicles/{id} Deletes one of your vehicles. Presets are read-only. Free: no credits are charged. Scope: `resources:write`. Parameters: `id` (path). 200: `Deleted`. Errors: `vehicle_not_found`, `preset_read_only`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/delete-vehicle.md ### GET /v1/templates Your organization's cargo templates (saved items), newest first. Name one in an item with template_id. Free: no credits are charged. Scope: `resources:read`. Parameters: `limit` (query), `cursor` (query), `q` (query), `created_by` (query). 200: `TemplateList`. Errors: `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/list-templates.md ### GET /v1/templates/{id} One template. Free: no credits are charged. Scope: `resources:read`. Parameters: `id` (path). 200: `Template`. Errors: `template_not_found`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-template.md ### POST /v1/templates Saves an item as a template (it shows in the app too). Same fields and checks as a /v1/pack item. Free: no credits are charged. Scope: `resources:write`. 201: `Template`. Errors: `invalid_json`, `payload_too_large`, `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/create-template.md ### PATCH /v1/templates/{id} Changes a template. Items already in projects keep their own copy. Free: no credits are charged. Scope: `resources:write`. Parameters: `id` (path). 200: `Template`. Errors: `template_not_found`, `invalid_json`, `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/update-template.md ### DELETE /v1/templates/{id} Deletes a template. Items already in projects stay. Free: no credits are charged. Scope: `resources:write`. Parameters: `id` (path). 200: `Deleted`. Errors: `template_not_found`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/delete-template.md ### GET /v1/limits The limits that apply to your organization and the rate card new jobs are priced with. Scope: `account:read`. 200: `Limits`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-limits.md ### GET /v1/balance Available, plan, purchased and reserved credits. Scope: `account:read`. 200: `Balance`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-balance.md ### GET /v1/usage Requests, jobs, credits, compute seconds and vehicles per UTC day. Without from/to: the last 30 days. At most 92 days per call. Scope: `account:read`. Parameters: `from` (query), `to` (query). 200: `Usage`. Errors: `validation_failed`. Docs: https://runner.cargoduo.com/api/public/api-docs/pages/get-usage.md