Errors & Rate Limits¶
Last updated October 20, 2026
Error response shape¶
Every error is JSON with a stable machine-readable error code and a human-readable message. Switch on error; log message. Never parse message.
{
"error": "not_found",
"message": "The requested fulfilment order does not exist."
}
Validation errors add an errors array with one entry per problem, so you can surface all of them at once instead of fixing them one request at a time.
{
"error": "validation_error",
"message": "One or more parcel updates are invalid.",
"errors": [
{
"parcel_id": "OMN-KONW-00042-1-1",
"field": "tracking_number",
"message": "tracking_number is required"
},
{
"parcel_id": "OMN-KONW-00042-1-2",
"field": "currency",
"message": "currency is required when shipping_cost is supplied"
}
]
}
On the shipment-level tracking endpoint each entry is keyed by parcel_id; on the order-level endpoint it is keyed by tracking_number, because parcels do not exist yet at that point. If the key field is itself missing, the key is null and the message gives the entry's index, e.g. "tracking_number is required (parcels[0])". On POST /v1/inventory each entry carries sku and warehouse_code.
Error code reference¶
| HTTP | Code | When it happens | Retryable |
|---|---|---|---|
| 401 | missing_api_key |
No Authorization header, not a Bearer token, or the token does not start with omni_sk_. |
No — fix the request |
| 401 | invalid_api_key |
The key is not recognised or has been revoked. | No — get a new key |
| 400 | validation_error |
Bad or unknown query parameter, malformed JSON body, or one or more invalid parcel or inventory entries. Includes an errors array where parcels or inventory entries are involved. On GET /v1/fulfilment-orders, an unknown parameter is rejected with a hint naming the supported ones. |
No — fix the request |
| 404 | not_found |
Unknown endpoint, or the order / shipment / parcel does not exist for this seller. Another seller's record is indistinguishable from a missing one, and an order waiting on an Omnishelf-supplied label returns 404 until the label is attached (see Authentication). | No |
| 405 | method_not_allowed |
The path exists but not for this HTTP method. The Allow header lists the methods the path accepts. |
No — use a listed method |
| 409 | label_not_ready |
No label has been generated yet for the requested parcel, shipment or order. | Yes — poll again shortly |
| 409 | omnishelf_oms_order_missing |
Tracking submission on an order that is not yet linked to the fulfilment system. | Yes — retry later, or contact Omnishelf |
| 409 | request_pending |
Tracking submission while a cancellation or SKU change request is open on the order. Nothing is saved or sent to the fulfilment system. | Yes — once Omnishelf has decided the request |
| 409 | idempotency_conflict |
POST /v1/inventory: the Idempotency-Key was already used within 24 hours with a different request body. |
No — use a new key, or resend the original body |
| 413 | too_many_parcels |
A label request covers more than 20 parcels. Drop to shipment or parcel level. | No — split the request |
| 429 | rate_limit_exceeded |
More than 100 requests in the current 60-second window for this key. | Yes — after Retry-After |
| 502 | label_fetch_failed |
The label exists but could not be retrieved from the carrier system. | Yes — retry shortly |
| 502 | omnishelf_oms_push_failed |
Tracking could not be written through to the fulfilment system. Nothing was saved on the Omnishelf side. | Yes — safe to retry as-is |
| 500 | internal_error |
An unexpected failure. Logged on the Omnishelf side. | Yes — with backoff |
| 503 | service_unavailable |
A tracking submission could not be checked against the order. Nothing was saved or sent. | Yes — retry shortly |
Retry only what is retryable¶
Retrying a 400 or 404 will never succeed and just burns rate limit. Retry 409 label_not_ready, 429, 502, 500 and 503 with exponential backoff. Both tracking submission endpoints are idempotent, so a retry after a network timeout is always safe — resubmitting a tracking number that is already stored returns 200 with "result": "unchanged" and writes nothing.
Rate limits¶
100 requests per minute per API key, counted in a fixed 60-second window. The limit is per key, not per endpoint or per order.
| Header | On | Meaning |
|---|---|---|
X-RateLimit-Limit |
Every authenticated response | Requests allowed per window — currently 100. |
X-RateLimit-Remaining |
Every authenticated response | Requests left in the current window. 0 on a 429. |
X-RateLimit-Reset |
Every authenticated response | Unix epoch seconds at which the window resets. |
Retry-After |
429 only | Whole seconds to wait before retrying. |
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please retry after the reset window."
}
Back off using the reset header rather than a fixed sleep:
response=$(curl -s -D /tmp/h -o /tmp/b -w '%{http_code}' \
"$BASE_URL/v1/fulfilment-orders" \
-H "Authorization: Bearer $OMNI_KEY")
if [ "$response" = "429" ]; then
reset=$(grep -i '^x-ratelimit-reset:' /tmp/h | tr -d '\r' | awk '{print $2}')
now=$(date +%s)
sleep $(( reset - now + 1 ))
fi
If the limiter itself is unavailable the API fails open rather than blocking a valid key, so an occasional burst above 100 may succeed. Do not design around that.