Errors & troubleshooting

Most errors from the /v2 API are machine-readable problem+json with a stable top-level code. A few have an empty body instead, and one class of validation failure uses a different, code-less shape. This guide covers all of it: the shapes, the code registry by status, and how to react to each.

When to use this

  • You're writing error handling and want to branch on a stable code, not a status code alone.
  • You hit a 400/401/403/404/409/410/413/415/422/429 and need to know what it means and what to do.

The problem+json shape

Most errors follow RFC 9457 (application/problem+json) with a stable top-level code. For field-level problems, errors[] lists each offending field, but read the subsection below before you parse errors: its shape is not always the same.

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Validation failed.",
  "status": 400,
  "code": "validation_failed",
  "errors": [
    { "pointer": "Cargo[0].Geometry.Type", "code": "validation_failed", "message": "Only 'box' geometry is supported." }
  ]
}
Field Notes
type URI for the problem type. Not customized per error: it points at the response's HTTP status, so two failures with different code values share one type. Usually the RFC 9110 section for that status, but the 422 points at RFC 4918 instead, because RFC 9110 defines no 422, and the three 429s carry no type at all. Switch on code, never on this field.
title Short, human-readable summary.
status HTTP status code (also on the response).
detail Human-readable explanation for this occurrence, where present.
code Stable machine-readable code; branch on this when it's present.
errors[] Per-field problems on this shape: pointer, code (always validation_failed per entry), message.

Only title and status are guaranteed present; type/code/detail/errors appear where they apply. type is on every problem body except the three 429s, and, as the next section explains, some 400s have no code at all.

What a client actually has to handle

  1. errors[].pointer is a PascalCase C# property path with [i] indexers (e.g. Carriers[0].ExternalId). It is not a JSON Pointer and not camelCase. Lowercase the first letter of each segment yourself if you map it back to a form field.
  2. A second, code-less 400 shape exists. Some validation failures on POST /v2/jobs are caught only after the request is mapped onto the internal job shape; those come back as a standard ASP.NET ValidationProblemDetails, where errors is an object map keyed by property name (each value a list of messages) and there is no code field at all. Treat any 400 without a top-level code as a validation failure and fall back to title/errors.
  3. Some responses have no body. 401, the idempotency 409 (reused Idempotency-Key with a different body) and the 400 for an oversized Idempotency-Key are all empty. The 409s for job_already_terminal and job_not_succeeded do carry problem+json with a code.

The error code registry, by status

HTTP code When
400 validation_failed Request body invalid: errors[] lists the fields (see above for the shapes a 400 can take)
401 none (empty body) Key missing, invalid or revoked
403 customer_api_not_entitled Key valid, but the plan has no customer API access. You can create a key on any plan, so this is the response an integration built ahead of an upgrade will see. The detail field names where to upgrade
403 request_body_not_supported You sent a request body to POST /v2/jobs/{id}/instructions or POST /v2/jobs/{id}/share. Neither takes one; send the request without a body. Note this shares the 403 status with the plan check above, so branch on code, not on the status
403 mixed_palletizing_not_entitled POST /v2/jobs submitted a MixedPalletizing job and the plan does not include that problem type. Distinct from the three 422s below: this means the capability is missing, not that the job is too big
403 load_planning_not_entitled The symmetric case: POST /v2/jobs submitted, or defaulted to, a LoadPlanning job and the plan does not include it. Rare in practice, since every tier ships load planning enabled
403 link_sharing_not_entitled POST /v2/jobs/{id}/share and the plan does not include link sharing. Checked before the job lookup, so an unentitled caller learns about the plan rather than about the job. Revoking a link is never gated this way
404 job_not_found Unknown job id, or a job that belongs to another account
404 result_not_ready Job exists but hasn't succeeded yet
404 artifact_not_ready The instructions job is yours, but its PDF hasn't rendered yet, or was cleaned up by retention. Either way this is 404; keep polling
409 job_already_terminal POST .../cancel on a job that already finished
409 job_not_succeeded POST .../instructions or POST .../share on a job that hasn't succeeded
409 none (empty body) Idempotency-Key reused with a different body
413 payload_too_large The request body is larger than the API will read. Permanent for that body, so retrying it unchanged cannot succeed: split the order across several jobs instead. A very large cargo array is the usual cause
415 unsupported_media_type A body sent to POST /v2/jobs or POST /v2/jobs/validate under a content type other than application/json, or with none at all: these two read JSON only. An empty body with a stray content type is not an error. The endpoints that take no body answer 403 request_body_not_supported instead, whatever the content type
422 max_items_per_job The job has more cargo units than your plan allows. Send fewer items, or upgrade the plan. detail names the limit's value
422 max_carriers_per_job The job uses more distinct carrier types than your plan allows. Note this counts carrier types, not how many of each: one carrier entry with quantity: 20 is one type. Combine types, or upgrade the plan
422 max_carrier_instances_per_job The carrier quantities add up to more carriers than your plan allows. That is the sum over all entries, where an entry without a quantity counts as one. Lower the quantities, or upgrade the plan
429 rate_limit_exceeded Too many requests, too fast: the account may make 300 requests per minute across /v2. This is the one response that tells you how long to wait, in a Retry-After header
429 too_many_active_jobs Concurrent-job limit reached
429 monthly_quota_exceeded The monthly job quota is used up for the job's problem type. Each problem type meters its own pot, so mixed palletizing running out says nothing about load planning, and the other pot keeps accepting jobs. See Account limits
500 internal_server_error Something failed that the API did not anticipate. Treat it as transient: retry, and if it persists contact support. Not the same as the failed-job error.code internal_error further down this page, which reports a job that was accepted and then did not solve

A 500 carries internal_server_error and gives you nothing else to branch on: the cause is recorded internally and never appears in the body. It is the one entry in this registry that does not tell you what to change, because there is nothing on your side to change. Retry it.

A 429 is retryable, but how you wait depends on which one you hit. rate_limit_exceeded carries a Retry-After header with the number of seconds; honour it. too_many_active_jobs and monthly_quota_exceeded carry no such header, so back off on a schedule of your own choosing.

Validate first to catch 400s without queuing a job: POST /v2/jobs/validate runs the same two-stage validation as POST /v2/jobs, but always answers with the code-bearing shape above, even for a post-mapping failure. The code-less ValidationProblemDetails shape only shows up on POST /v2/jobs itself. Validate never returns 404, 422, too_many_active_jobs or monthly_quota_exceeded: it does no job lookup, entitlement check or quota check of its own. It is not exempt from the request rate limit, though, so a tight validation loop can still earn a 429 rate_limit_exceeded. It is not exempt from 401/403, though: authentication and the plan's customer-API gate run for every /v2 route, including this one, before any handler gets to see the request. See Async & validate.

Failed jobs aren't HTTP errors

A job that fails during solving still returns 200 from GET /v2/jobs/{id}; the failure is in the body: status: failed, a raw internal errorCategory string, and an error object (code + message). Likewise, cargo that couldn't be placed in a succeeded job appears in unplacedItems, not as an error. See Async & validate.

error.code is a closed set:

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.

error.message is a safe, human-readable sentence and never contains exception detail. errorCategory is the raw internal category behind it: diagnostic only, and not a closed set. Switch on error.code, not errorCategory.

Notes & caveats

  • Branch on code, not title/detail. Codes are stable; the human-readable text may change, and, per above, some 400s have no code at all.
  • 403 is permanent, 429 is transient. Retrying a 403 won't help: the account's plan needs customer API access before the call will succeed. 429 is worth retrying after a wait. Only rate_limit_exceeded tells you how long, in Retry-After; for the other two, pick your own interval.
  • 404 hides existence on purpose. A job that belongs to another account looks exactly like a job that doesn't exist: both are job_not_found.
  • Two registries, one field name. internal_server_error is a top-level code on a 500: the request did not complete, and the job may not exist. internal_error is an error.code inside a 200 body: the job does exist, was accepted, and then failed while solving. Keeping one lookup table for both fields conflates the two.
  • Validate to shift errors left. POST /v2/jobs/validate returns the same 400 shapes as a real submit, without queuing a job or triggering a solve.