Create a Project, import cargo, run a calculate, and read the stored Load Plan.
The persistence API is REST at /api/v1 on the app origin, for example https://app.example.com/api/v1. The OpenAPI 3.1 document is at /api/v1/spec.json, and /api/v1 itself serves the Scalar reference, where you can try every route. It is not the cookie /rpc tree, and it is not the admin reference at /api-reference.
Every route, field, and error code below is generated from one contract, so the spec at /api/v1/spec.json always matches what the server does. Generate a client from it rather than copying shapes by hand.
Send an organization API key as a bearer token on every request:
Authorization: Bearer sc_live_…The secret is shown once in the app, under API keys, and only a hash is stored. Owners and admins can rotate or revoke it. A revoked or rotated key stops working immediately. A key acts for its organization only: ids from another organization answer 404, as if they did not exist.
Each key has one of two scopes, chosen when it is created:
read_write, the default) calls every route.read) calls only the GET routes: reading a Project, its cargo, a Load Plan, or a calculate run, and listing the libraries. Any other route answers 403 with code: "FORBIDDEN" and data.reason: "insufficient_scope", before anything is changed.Keys created before scopes existed are read & write. Rotating a key keeps its scope.
A key never expires unless you set an expiry when you create it: in 30 days, 90 days, or one year (the API allows at most two years ahead). From that moment the key answers 401 like a revoked key. Rotating a key that expires gives the new key a fresh term of the same length, so rotation also brings an expired key back.
A key stays valid only while the member who created it is still in the organization and is still an owner or admin, the roles allowed to manage API keys. If that member leaves, is removed, or is demoted to member or viewer, every key they created answers 401. The key is not downgraded to read only; it stops. An owner or admin then creates a new key.
A missing, malformed, revoked, expired, or unknown key, or one whose creator no longer qualifies, answers 401 with code: "UNAUTHORIZED" and the header WWW-Authenticate: Bearer realm="api". The response is the same in every case, so it does not reveal which check failed; the reason is recorded in the API audit log on the server (see Audit log).
API access is Enterprise only (apiAccess). Other plans receive 403 with code: "FORBIDDEN". The plans currently for sale do not grant this flag until the Enterprise catalog is live.
The API is server-to-server. Keep the key on your server; do not call the API from a browser. Cross-origin browser requests are not allowed from other sites, and no cookie is ever sent with or accepted by /api/v1.
| Method | Path | Does | Success |
|---|---|---|---|
POST | /projects | Create a Project | 201 |
GET | /projects/{projectId} | Read a Project with its Load Plan headers | 200 |
POST | /projects/{projectId}/load-plans | Create a Load Plan | 201 |
POST | /projects/{projectId}/cargo | Import cargo lines | 201 |
GET | /projects/{projectId}/cargo | List the Project's cargo lines | 200 |
PATCH | /load-plans/{loadPlanId}/equipment | Change the Load Plan's equipment | 200 |
POST | /load-plans/{loadPlanId}/calculate | Enqueue a calculate | 202 |
GET | /calculates/{id} | Read a calculate run | 200 |
POST | /calculates/{id}/cancel | Cancel a calculate run | 200 |
GET | /load-plans/{id} | Read the stored Load Plan, with placements | 200 |
POST | /load-plans/{loadPlanId}/share-links | Mint a view-only Share Link | 201 |
GET | /libraries/cargo | List the Cargo Library | 200 |
GET | /libraries/equipment | List Equipment Types, built-in Sea/Road types | 200 |
groupKey: lines that share one are a keep-together group, loaded into one Unit as one block or left Unloaded whole with reason group-does-not-fit. An import of up to 5,000 lines is one transaction: it is appended whole or not at all.GET /libraries/equipment, built-in Sea and Road types included.GET /projects/{projectId} returns the Project's metadata, cargoLineCount, and one header per Load Plan, oldest first: id, name, revision, stale, solvedAt (null before the first calculate), createdAt, and updatedAt. It does not include cargo, equipment, or placements; read a Load Plan's equipment and stored result with GET /load-plans/{id}.
GET /projects/{projectId}/cargo pages through the Project's cargo lines in grid order, the order they were imported or arranged in the app. Each item has every field an import line takes, with defaults filled in (rotation: "free", stackable: true, fragile: false) and absent values as null, plus id, revision, sourceLibraryItemId (set when the line came from the Cargo Library), createdAt, and updatedAt. To copy cargo into another Project, drop those extra fields and send the rest to POST /projects/{projectId}/cargo.
Lengths are whole millimetres (lengthMm, innerWidthMm, …) and weights whole grams (weightG, maxPayloadG, …). Timestamps are RFC 3339 strings in UTC; calendar dates are YYYY-MM-DD. Request bodies are JSON with Content-Type: application/json, at most 4 MB; a larger body answers 413. Request bodies reject unknown keys with 400. Responses may gain new fields, so ignore fields you do not know.
PATCH /load-plans/{loadPlanId}/equipment merges a change into the Load Plan's equipment:
equipmentTypeIds is the full set of selected library Equipment Types. A type you leave out is deselected.maxUnitsByEquipmentTypeId optionally sets the most Units per selected type (null means no limit). A selected type you leave out keeps its limit.removeEquipmentIds. An id no longer on the plan is ignored.Send expectedRevision with the revision from your last read. If the Load Plan changed in the meantime, nothing is saved and the call answers 412 with code: "PRECONDITION_FAILED"; data.conflicts names the resource with its expectedRevision and currentRevision. Read again and retry. The response carries the new revision for your next write.
A calculate runs the same optimizer as Calculate in the app, in the background:
GET /load-plans/{id} and note revision.POST /load-plans/{loadPlanId}/calculate with {"expectedRevision": <revision>}. Locked placements are always kept. If a planner moved items by hand without locking them, send "replaceUnlockedManual": true to let the run replace them; without it the call answers 409 instead of overwriting the manual work. A Locked placement above the floor must stand on other Locked placements: otherwise the call answers 400 with a message naming the floating piece, because the solver never puts a free piece under a lock. Locks may not break a keep-together group either: a group locked only in part, locked pieces of one group in two Units, or a lock inside another group's block also answer 400 naming the group or pieces. The app locks and unlocks a group as a whole. The call answers 202 with the run id and status: "queued".GET /calculates/{id}. status moves through queued and running (with phase, processedItems, and totalItems) to completed, cancelled, failed, or timed_out. A failed run carries a failureCode, for example revision_conflict when the Load Plan changed after your read.completed, GET /load-plans/{id} again for the placements. The run itself reports only the new revision and solvedAt.In the stored result, warnings lists rule problems, unloaded cargo, and the centre-of-gravity advisory. Axle-load advisories for road equipment with an Axle Profile (axle-overload, axle-lift, steer-axle-underload, drive-axle-underload, gross-overload) are listed separately in axleWarnings, which is omitted when there are none.
POST /calculates/{id}/cancel stops a queued or running calculate. A cancelled run writes no placements.
One Load Plan runs one calculate at a time: a second enqueue while one is queued or running answers 409. An organization may have 10 calculates queued or running at once; the eleventh answers 429.
One calculate supports at most 10,000 Cargo Lines, 20,000 pieces in total, and 500 Units. Cargo over one of these limits is rejected with 400 before a run is queued, and the message names the limit and your count. The Unit check uses a lower bound (weight, volume, and floor area over the largest equipment offered), so it only rejects cargo that certainly needs more than 500 Units.
POST /load-plans/{loadPlanId}/share-links mints a view-only Share Link, like every Share Link. The body is optional; send {"expiresAt": "2026-12-31T23:59:59Z"} for a link that expires, or omit it for one that never does. If the Load Plan has no stored result yet, the call answers 400 with data.reason load_plan_not_calculated. Run a calculate first. Send showLoadingSteps: false to hide step-by-step loading from visitors; it defaults to true. This is a display option: the step pages, step images, and loading step column go, but the report's placement list and the CSV keep the loading order, and the link's plan data still holds the full result.
The token and url are shown only on the response that minted the link: only a hash of the token is stored. Keep them, or mint a new link if they are lost.
POST /projects, POST /projects/{projectId}/load-plans, POST /projects/{projectId}/cargo, POST /load-plans/{loadPlanId}/calculate, and POST /load-plans/{loadPlanId}/share-links require an Idempotency-Key header: 1 to 255 visible ASCII characters without spaces (a UUID works). Without it they answer 400.
409 with code: "CONFLICT".400 or a 404) does not use up the key; retry with the same key.token and url set to null, because the token is never stored.PATCH equipment and POST /calculates/{id}/cancel take no Idempotency-Key: the first is guarded by expectedRevision, and cancelling twice has the same effect as cancelling once.
GET /libraries/cargo, GET /libraries/equipment, and GET /projects/{projectId}/cargo return one page: { "items": [...], "nextCursor": "…" }. Pass nextCursor as cursor for the next page; it is null on the last page. A cursor that is not an item of the list answers 404. On the libraries, limit sets the page size, 1 to 100 (default 20), and query filters by a case-insensitive substring. On cargo lines, limit is 1 to 500 (default 100), and totalCount counts every line of the Project.
The equipment list also filters by mode (sea or road) and by ids, which takes bracket notation even for one id:
curl -sS "$ORIGIN/api/v1/libraries/equipment?ids[]=eqt_a&ids[]=eqt_b" \
-H "Authorization: Bearer $CARGO_MARSHAL_API_KEY"| Limit | Scope | Budget |
|---|---|---|
| Requests | per client IP, before auth | 300 per minute |
| Requests | per API key | 120 per minute |
| Calculate enqueues | per API key | 30 per 10 minutes |
| Calculates in flight | per organization | 10 queued or running at once |
A request over a limit answers 429 with code: "TOO_MANY_REQUESTS" and a Retry-After header in seconds (also in data.retryAfter when the limit knows it). Wait that long before you retry. A replayed calculate (same Idempotency-Key) does not spend the calculate budget.
The per-key limits fail closed: if the limiter is unavailable in production, the call answers 503 instead of being let through. Retry 503 with backoff.
Every error answers with the same JSON body:
{
"defined": true,
"code": "BAD_REQUEST",
"status": 400,
"message": "Input validation failed",
"data": {
"reason": "invalid_input",
"issues": [
{
"path": ["body", "lines", 0, "lengthMm"],
"message": "Too small: expected number to be >=1",
"code": "too_small"
}
]
}
}Branch on code, and on data.reason where it is set; message is English text for people and may change. defined: true means the error is one the spec documents for that route, with data in the documented shape; defined: false is an unexpected error, usually a 500.
| Status | code | When |
|---|---|---|
400 | BAD_REQUEST | Invalid input (data.reason invalid_input, data.issues lists at most 50 failed checks), cargo the solver cannot take, or a Share Link on an uncalculated Load Plan (load_plan_not_calculated) |
401 | UNAUTHORIZED | Missing, invalid, revoked, or expired API key, or its creator is no longer an owner or admin |
403 | FORBIDDEN | The organization's plan does not include API access, or a read-only key called a route that writes (insufficient_scope) |
404 | NOT_FOUND | The id does not exist in the key's organization, or the path is not a route |
405 | — | The path exists for other methods; the Allow header lists them |
409 | CONFLICT | Idempotency-Key reuse, a calculate already running, or unlocked manual placements |
409 | PLAN_LIMIT | A plan cap on Projects, calculates, or Share Links |
412 | PRECONDITION_FAILED | expectedRevision is stale |
413 | — | The body is larger than 4 MB |
429 | TOO_MANY_REQUESTS | A rate limit is reached; see Retry-After |
503 | SERVICE_UNAVAILABLE | The rate limiter or a dependency is unavailable; retry with backoff |
The spec lists, per route, exactly which of these codes it can return.
A plan cap answers 409 with code: "PLAN_LIMIT" and data naming the cap:
{
"reason": "plan-limit",
"resource": "solves",
"limit": 10,
"planName": "Free"
}resource is projects, solves, or share-links.
Every response carries an x-request-id header. Quote it when you contact support; you may also send your own x-request-id and it is kept.
The server keeps an API audit row (key, operation, status, resource id) for:
Idempotency-Key replay, whether it succeeds or fails;404 for another organization's id;429), read-only scope (403), plan without API access (403), and an expired key or one whose creator no longer qualifies (401).Successful reads (GET routes answering 200) are not recorded: they change nothing, and integrations poll them. Requests rejected by the input schema (400 with invalid_input), unknown or revoked keys, and requests refused by the per-IP limit are not recorded either.
A key cannot submit placements or a solve snapshot: the calculate body takes only the expected revision and the manual-placement flag, and placements are always computed on the server. Admin, billing, chat, sign-in, and file storage stay off this API.
/api/v1 is stable. Within v1, changes are additive only: new routes, new optional request fields, new response fields, new error code or data.reason values on an existing status. Build clients that ignore unknown response fields.
A breaking change (removing or renaming a route or field, making an optional input required, changing a type or a success status) is released under a new version, /api/v2, and v1 keeps working alongside it. Every change to the spec is checked for breaking changes before it ships.
Before a route or field is removed, it is marked deprecated: true in the spec. Every response from a deprecated route, successful or not, carries:
Deprecation: true, or the date the route was deprecated in the RFC 9745 form @<unix seconds>, for example Deprecation: @1790812800.Sunset: the removal date as an HTTP date (RFC 8594), for example Sunset: Thu, 01 Apr 2027 00:00:00 GMT, once it is set.Link: <…>; rel="deprecation": the page that explains the migration, once it is published.The sunset date is at least 6 months after the deprecation is announced. Log these headers in your client so a deprecation does not go unnoticed. No v1 route is deprecated today.
curl -sS -X POST "$ORIGIN/api/v1/projects" \
-H "Authorization: Bearer $CARGO_MARSHAL_API_KEY" \
-H "Idempotency-Key: project-4401" \
-H "Content-Type: application/json" \
-d '{"name":"Hamburg 40HC"}'Replace $ORIGIN with the app URL. The call answers 201 with the new Project.