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.
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:
the branch worth reading twice is the lower one.
POST /v2/jobs/{id}/instructionsanswers 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 →
409with 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,
unitsincluded. Retrying the same key with the order re-expressed in different units (saymmthe first time andmon the retry) is a different body, and gets the409, 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; aqueuedjob is finalized tocancelledimmediately, arunningjob gets a cooperative cancel flag and winds down at its next checkpoint.409(codejob_already_terminal): the job already reached a terminal state; there's nothing to cancel.404(codejob_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}/resultis404(result_not_ready) until the job succeeds; that404is 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 a409instead of a replay. - Cancelling a running job is cooperative. A
202means the request was accepted, not that the job has stopped; poll status to confirm it reachedcancelled.
Related
- Instructions & sharing: the derived instructions job and share links, both built on this same submit/poll model.
- Errors & troubleshooting: problem+json shapes and the closed
error.codeset.