Persistence API
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. To try it in Postman, import /api/v1/postman.json, and follow the API quickstart for the full flow. 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.
Authentication
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.
Scopes
Each key has one of two scopes, chosen when it is created:
- Read & write (
read_write, the default) calls every route. - Read only (
read) calls only theGETroutes: reading a Project, its cargo, a Load Plan, or a calculate run, and listing the libraries. Any other route answers403withcode: "FORBIDDEN"anddata.reason: "insufficient_scope", before anything is changed.
Keys created before scopes existed are read & write. Rotating a key keeps its scope.
Expiry
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.
Who the key depends on
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.
What a key can do
| 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 |
- Cargo lines are already in millimetres and grams. A line may carry a
groupKey: lines that share one are a keep-together group, loaded into one Unit as one block or left Unloaded whole with reasongroup-does-not-fit. An import of up to 5,000 lines is one transaction: it is appended whole or not at all. - A line may carry a
stop, an integer from 1 to 99 ornull, optional on write. Stop 1 is unloaded first, and a line without a Stop is unloaded after every Stop. A Project may carrystops, an array of{stop, name}with unique ordinals in ascending order, optional on write. The names are labels only. Project files carry the same optional fields, and older files still import. - A line may carry
minSupportPermille, an integer from 501 to 1000 ornull, optional on write. It is the minimum share of the piece’s base, in permille, that must rest on cargo directly below it when it is not on the floor.nulland omitted both mean 1000, full support. Library Items and project files carry the same optional field, and older files still import. - A line may carry
frictionSurface, what its pieces stand on for the load securing check: one ofwood_on_wood,planed_wood_on_wood,wood_on_steel,plastic_on_wood,plastic_on_metal,steel_on_wood,steel_on_steel, andrubber_mat, ornull, optional on write.nulland omitted both mean the Load Plan’s default friction. Library Items and project files carry the same optional field, and older files still import. - A Load Plan in a project file may carry
securing, the transport legs it checks load securing for:{"legs": [...]}with one or more ofroad,rail,sea-a,sea-b, andsea-c, and an optionalfrictionPermillefrom 1 to 1500.nulland omitted both mean no securing check, so older files still import. A file whose stored result was checked against other legs imports with that result rechecked against the plan’s legs. - Equipment ids come from
GET /libraries/equipment, built-in Sea and Road types included.
Reading Projects and cargo
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.
Units and formats
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.
Equipment
PATCH /load-plans/{loadPlanId}/equipment merges a change into the Load Plan’s equipment:
equipmentTypeIdsis the full set of selected library Equipment Types. A type you leave out is deselected.maxUnitsByEquipmentTypeIdoptionally sets the most Units per selected type (nullmeans no limit). A selected type you leave out keeps its limit.- Equipment without a library type (imported, or whose type was deleted) stays unless its id is in
removeEquipmentIds. An id no longer on the plan is ignored. - The selected types plus the remaining equipment may not exceed 20 rows.
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.
Calculate
A calculate runs the same optimizer as Calculate in the app, in the background:
GET /load-plans/{id}and noterevision.POST /load-plans/{loadPlanId}/calculatewith{"expectedRevision": <revision>}. Locked placements are always kept. If a planner moved items by hand without locking them, send"replaceUnlockedManual": trueto let the run replace them; without it the call answers409instead of overwriting the manual work. A Locked placement above the floor must stand on other Locked placements: otherwise the call answers400with 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 answer400naming the group or pieces. The app locks and unlocks a group as a whole. The call answers202with the runidandstatus: "queued".- Poll
GET /calculates/{id}, or subscribe to webhooks and wait for the event.statusmoves throughqueuedandrunning(withphase,processedItems, andtotalItems) tocompleted,cancelled,failed, ortimed_out. A failed run carries afailureCode, for examplerevision_conflictwhen the Load Plan changed after your read. - When the run is
completed,GET /load-plans/{id}again for the placements. The run itself reports only the newrevisionandsolvedAt.
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. A Stop whose cargo has later-Stop or no-Stop cargo above it or in front of every opening it could leave through is reported as stop-blocked in the optional stopWarnings array, next to warnings and axleWarnings. The warnings list is unchanged. It is a warning only, and a calculate is never refused for it.
GET /load-plans/{id} returns the plan’s securing: the transport legs it checks load securing for, or null for no check. The legs are set in the app; the API reads them only. Load securing advisories (securing-unblocked and securing-tipping) are listed in the optional securingWarnings array of the stored result, which is omitted when there are none. Each names a Unit, a direction (forward, rearward, or sideways), and the pieces to secure. They are advisory and not a legal-compliance determination, and they never appear in warnings. The load securing guide explains them.
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.
Webhooks
You do not have to poll. An organization on the Enterprise plan can register HTTPS endpoints under Settings → Webhooks and receive a signed POST when a Calculate ends: calculate.completed, calculate.failed, or calculate.cancelled. The body names the Calculate, Load Plan, and Project and carries no plan data, so after calculate.completed you still call GET /load-plans/{id} for the placements. Delivery is at least once and not ordered. The webhooks guide covers verifying the signature, retries, and the network rules, and the spec lists the events under webhooks.
Share Links
POST /load-plans/{loadPlanId}/share-links mints a view-only Share Link. Loader Links, whose visitors can tick loading steps, are created in the app. 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.
Idempotency
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.
- A retry with the same key and the same body within 24 hours returns the first response, with the same status, instead of a second Project, a second import, a second calculate, or a second Share Link.
- The same key with a different body, or while the first request is still running, answers
409withcode: "CONFLICT". - A request that failed before anything was written (for example a
400or a404) does not use up the key; retry with the same key. - Keys are scoped to the API key. After you rotate a key, the new key starts with no stored keys.
- A replayed Share Link has
tokenandurlset tonull, 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.
Pagination
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"
Rate limits
| 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.
Errors
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.
Audit log
The server keeps an API audit row (key, operation, status, resource id) for:
- every call that writes, including an
Idempotency-Keyreplay, whether it succeeds or fails; - every read that fails once it reaches the operation, for example a
404for another organization’s id; - every refusal of a known key: rate limited (
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.
What a key cannot do
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.
Versioning and deprecation
/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 exampleDeprecation: @1790812800.Sunset: the removal date as an HTTP date (RFC 8594), for exampleSunset: 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.
Example
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.