Async & validate

Submit a job, track it to completion, and pull the result back, plus the dry-run and the safety rails (idempotency, cancel).

When to use this

  • You're integrating the core submit→poll→fetch loop and want to handle every lifecycle state correctly.
  • You want to validate a request (and catch bad input before it costs you a job) without spending quota on it.
  • You need safe retries (Idempotency-Key) or a clean way to cancel a job that's in flight.

The job lifecycle

Solving is asynchronous. Every job moves through:

queued → running → succeeded | failed | cancelled

The last three are terminal: once a job reaches one it never changes again.

Job lifecycle state machine

the job state machine: queued/running are transient; succeeded/failed/cancelled are terminal.

Walkthrough

Which call to make, and what its response leaves you holding:

Flowchart of the calls a client makes: POST /v2/jobs, then a loop polling GET /v2/jobs/{id} while the status is queued or running, with failed and cancelled as dead ends and succeeded continuing to GET /v2/jobs/{id}/result; from there two optional branches, POST /v2/jobs/{id}/share which returns a link right away, and POST /v2/jobs/{id}/instructions which starts a second job with its own id that you poll the same way before downloading one PDF per carrier

the branch worth reading twice is the lower one. POST /v2/jobs/{id}/instructions answers with a job, not a document, so it needs a poll loop of its own on the new id before any PDF exists. Steps 1 to 3 below are the single path down the middle.

1. Submit the job

POST /v2/jobs returns 202 Accepted with a jobId and status: queued. A Location header points at the new job.

curl -i -X POST https://api.loadoptimizer.ai/v2/jobs \
  -H "X-Api-Key: ck_live_..." \
  -H "Content-Type: application/json" \
  -d @job.json
{ "jobId": "01975cba-67b9-7b7c-a2f1-3d29e4a01c44", "status": "queued" }

2. Poll for status

GET /v2/jobs/{id} returns a JobStatus. While the job runs you get percentage (0 to 100). errorCategory and error are set only once the job has failed; see Reading a failed job below.

curl https://api.loadoptimizer.ai/v2/jobs/01975cba-67b9-7b7c-a2f1-3d29e4a01c44 \
  -H "X-Api-Key: ck_live_..."
{
  "jobId": "01975cba-67b9-7b7c-a2f1-3d29e4a01c44",
  "status": "running",
  "percentage": 50,
  "queuePosition": null,
  "errorCategory": null,
  "error": null,
  "name": null,
  "createdAt": "2026-08-19T08:12:33.412+00:00"
}

Poll every few seconds until status is terminal (no need to poll faster). Don't assume the job is still queued right after submitting: once the job leaves the queue and starts calculating, the status moves straight to running.

3. Fetch the result

Once status is succeeded, GET /v2/jobs/{id}/result returns the packing result. This is a different endpoint from the status one in step 2: keep your wait loop on the status endpoint (GET /v2/jobs/{id}), and call GET /v2/jobs/{id}/result once, after it reports succeeded.

Don't loop on the result endpoint itself waiting for it to "turn ready." While the job is still queued or running there is no result yet, so it returns 404 (code result_not_ready), and a 404 here is ambiguous (it also means an unknown or foreign job id), so it's not a reliable "not done yet" signal. The status endpoint is the one built to tell you when to fetch.

curl https://api.loadoptimizer.ai/v2/jobs/01975cba-67b9-7b7c-a2f1-3d29e4a01c44/result \
  -H "X-Api-Key: ck_live_..."

Validate first (dry-run)

POST /v2/jobs/validate takes the identical body as POST /v2/jobs, runs the same validation and mapping, but creates no job, triggers no solve, and spends no quota:

curl -X POST https://api.loadoptimizer.ai/v2/jobs/validate \
  -H "X-Api-Key: ck_live_..." \
  -H "Content-Type: application/json" \
  -d @job.json

On success you get a ValidateResult:

{ "valid": true, "warnings": [] }

Two things worth knowing about that shape: warnings is reserved and is always empty today, and validating does not run the entitlement or quota checks that a real submit does. A body that validates fine can still be turned down by POST /v2/jobs with a 422 (plan limits) or a 429 (quota). A malformed body returns the same 400 problem+json shape you'd get from POST /v2/jobs. See Errors & troubleshooting.

Safe retries with Idempotency-Key

Send an Idempotency-Key header on POST /v2/jobs so a network retry doesn't create a duplicate job:

curl -X POST https://api.loadoptimizer.ai/v2/jobs \
  -H "X-Api-Key: ck_live_..." \
  -H "Idempotency-Key: order-4821-submit" \
  -H "Content-Type: application/json" \
  -d @job.json
  • Same key + same body → returns the original job (no new job created). Bodies are compared canonically: key order and whitespace in the JSON you sent are ignored.
  • Same key + a different body → 409 with an empty body. Reusing a key for genuinely different work is rejected, never silently re-run.
  • Scope is per-account, so keys you choose only collide with your own account's requests.

The whole request body is the fingerprint, units included. Retrying the same key with the order re-expressed in different units (say mm the first time and m on the retry) is a different body, and gets the 409, not a replay of the original job. There is no unit block exempted from the comparison. If you rely on idempotency, keep the whole body (units included) identical across retries of the same key.

Cancelling a job

POST /v2/jobs/{id}/cancel requests a cooperative cancel:

curl -X POST https://api.loadoptimizer.ai/v2/jobs/01975cba-67b9-7b7c-a2f1-3d29e4a01c44/cancel \
  -H "X-Api-Key: ck_live_..."
  • 202: accepted; a queued job is finalized to cancelled immediately, a running job gets a cooperative cancel flag and winds down at its next checkpoint.
  • 409 (code job_already_terminal): the job already reached a terminal state; there's nothing to cancel.
  • 404 (code job_not_found): unknown job id, or a job that belongs to another account.

Cancel is a request, not an instant kill: a 202 on a running job doesn't mean it has stopped yet. Keep polling status until it actually reaches cancelled.

Reading a failed job

A failed job is still a 200 from GET /v2/jobs/{id}: the failure lives in the body, not the HTTP status. When status is failed, the JobStatus carries errorCategory (a raw internal category string; diagnostic only, not a closed set) and an error object with a stable code and a safe, human-readable message that never contains exception detail.

{
  "jobId": "01975cba-67b9-7b7c-a2f1-3d29e4a01c44",
  "status": "failed",
  "percentage": 50,
  "queuePosition": null,
  "errorCategory": "solver_error",
  "error": { "code": "solver_error", "message": "The solve did not complete. Please retry." },
  "name": null,
  "createdAt": "2026-08-19T08:12:33.412+00:00"
}

error.code is a closed set, so switch on it, not on errorCategory:

error.code Meaning
invalid_input The job couldn't be solved as submitted; have the user adjust the input.
solver_error The solve didn't complete; retry.
render_error Document rendering failed; retry.
internal_error Everything else: retry, or contact support.

Cargo that couldn't be placed in a succeeded job is not a failure: it comes back in unplacedItems on the result. See Errors & troubleshooting.

Key fields

Field Where Notes
status JobStatus queued/running/succeeded/failed/cancelled.
percentage JobStatus Progress while running, 0 to 100.
queuePosition JobStatus Always null.
errorCategory JobStatus Set only when status=failed; null otherwise. Raw and diagnostic-only; switch on error.code, not this.
error.code / error.message JobStatus Failure detail: a closed-set code (invalid_input, solver_error, render_error, internal_error) plus a safe message.
name JobStatus The caller's optional label, set at submit or with PUT /jobs/{id}/name; null when the job has none. See Naming a job.
createdAt JobStatus When the job was submitted (UTC), always present.
Idempotency-Key header Canonical-body compare (the whole body, units included), per-account; conflict → empty 409.
valid / warnings ValidateResult Dry-run output from POST /v2/jobs/validate. warnings is reserved and always empty today.

Notes & caveats

  • Poll status, not the result. GET /v2/jobs/{id}/result is 404 (result_not_ready) until the job succeeds; that 404 is expected while the job is queued or running.
  • Validate doesn't reserve anything. It queues no job, triggers no solve, and doesn't run the entitlement or quota checks a real submit does: a body that validates fine can still 422 or 429 on POST /v2/jobs.
  • Idempotency compares the whole body you sent (including units) and replays the original job even if the original failed. Change any part of the body, units included, and the same key gets a 409 instead of a replay.
  • Cancelling a running job is cooperative. A 202 means the request was accepted, not that the job has stopped; poll status to confirm it reached cancelled.