---
title: Persistence API
description: 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](/guides/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:

```http
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 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.

### 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](#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 reason `group-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 or `null`, optional on write. Stop 1 is unloaded first, and a line without a Stop is unloaded after every Stop. A Project may carry `stops`, 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 or `null`, 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. `null` and 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 of `wood_on_wood`, `planed_wood_on_wood`, `wood_on_steel`, `plastic_on_wood`, `plastic_on_metal`, `steel_on_wood`, `steel_on_steel`, and `rubber_mat`, or `null`, optional on write. `null` and 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 of `road`, `rail`, `sea-a`, `sea-b`, and `sea-c`, and an optional `frictionPermille` from 1 to 1500. `null` and 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:

- `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.
- 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:

1. `GET /load-plans/{id}` and note `revision`.
2. `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"`.
3. Poll `GET /calculates/{id}`, or subscribe to [webhooks](#webhooks) and wait for the event. `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.
4. When the run is `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. 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](/guides/load-securing) 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](/guides/webhooks) 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 `409` with `code: "CONFLICT"`.
- A request that failed before anything was written (for example a `400` or a `404`) 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 `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.

## 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:

```bash
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:

```json
{
  "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:

```json
{
  "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-Key` replay, whether it succeeds or fails;
- every read that fails once it reaches the operation, for example a `404` for 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](https://www.rfc-editor.org/rfc/rfc9745) form `@<unix seconds>`, for example `Deprecation: @1790812800`.
- `Sunset`: the removal date as an HTTP date ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)), 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.

## Example

```bash
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.
