Errors
When a request fails, the body is an error envelope:
{ "error": { "code": "NOT_FOUND", "message": "Purchase order not found", "retryable": false, "requestId": "0f8c…" }}Besides code, message, retryable and requestId, an error can carry:
retryAfterMs, together with aRetry-Afterresponse header (in seconds), when waiting helps.action: a short, human-readable next step. Read it: on write endpoints it usually tells you exactly what to fix.data: structured detail. Most refusals put a stable marker indata.code(for exampleON_HAND_SYNC_IN_FLIGHTorDUPLICATE_NAME) plus the ids you need to react. Branch ondata.code, not on the message text.
Status codes
Section titled “Status codes”code | HTTP | Meaning | Retry-After |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing, invalid, or revoked token | Not applicable |
PLAN_REQUIRED | 403 | The shop isn’t on the Elevate plan (the REST API is Elevate-only) | Not applicable |
FORBIDDEN_SCOPE | 403 | The token’s scope can’t do this, e.g. a read key on a write endpoint | Not applicable |
INVALID_INPUT | 400 | A bad parameter, a bad body, or a change the resource’s current state doesn’t allow (the message names it) | if present |
NOT_FOUND | 404 | Unknown path, or the id doesn’t exist | Not applicable |
RATE_LIMITED | 429 | Per-shop rate limit reached | ✓ |
DO_OVERLOADED | 503 | The shop’s data store is busy | ✓ |
DO_UNAVAILABLE | 503 | The data store is temporarily unavailable | ✓ |
UPSTREAM_SHOPIFY | 502 | A Shopify-facing dependency failed | if present |
UPSTREAM_FETCH | 502 | Fetching a URL you supplied failed | if present |
TIMEOUT | 504 | The request timed out | if present |
OUTPUT_TOO_LARGE | 413 | The response exceeded the size cap | Not applicable |
INTERNAL | 500 | An unexpected error | Not applicable |
Sending a method an endpoint doesn’t support returns 405 (METHOD_NOT_ALLOWED) with an
Allow header.
The purchase-order cost-sync routes add their own codes; see Cost-sync refusal codes.
400 on writes: what the order wouldn’t let you do
Section titled “400 on writes: what the order wouldn’t let you do”Most write failures aren’t malformed requests; they’re changes the purchase order’s current state doesn’t permit. The guards are the same ones the app itself uses, so the message describes the real rule:
- “Completed” is never a target for
/status; usePOST …/complete. - A move that isn’t allowed from the current status: the message lists the ones that are. See Purchase-order lifecycle.
- Draft → Sent without a location: set
locationIdfirst withPATCH …/{id}. /completeon an order that isn’t Received or Partially Received: receive first, or usePOST …/quick-complete.- Receiving less than has already been synced to Shopify: use
POST …/unreceive. - A stock sync is still in flight (
ON_HAND_SYNC_IN_FLIGHT); wait a moment and retry the same call. - Variants that aren’t active in Shopify (
INVENTORY_GUARD_ISSUES): resend the same call with aresolutionsarray saying what to do with each one. See the lifecycle page.
Planning refusal codes
Section titled “Planning refusal codes”Inventory-planning, promotional-event and planning-option writes refuse with
400 INVALID_INPUT and a stable marker in error.data.code. A missing or deleted plan, an id from
another plan collection and an item, location or occurrence of another parent all return the same
404 NOT_FOUND, with no detail. A body that fails schema validation can return 400 with
data.issues and no marker.
{ "error": { "code": "INVALID_INPUT", "message": "An option named \"Trade show\" already exists for this type", "retryable": false, "data": { "code": "DUPLICATE_NAME", "optionId": "5b0f9c7e-2d4a-4e11-9c3b-8f6a1d2e7c40", "error": "An option named \"Trade show\" already exists for this type" }, "requestId": "4e2a…" }}data.code | When | What to do |
|---|---|---|
INVALID_STATUS_TRANSITION, INVALID_TRANSITION | The status change isn’t allowed from the current status, or the plan already has that status | Read the plan’s status. A retried status change that already went through answers this way |
EMPTY_PLAN | Confirming a Draft that has no items | Add items first |
PS_HEADER_DATE_REQUIRED | Confirming a commitment without a header date, or clearing the last date of a Confirmed or Locked one | Set targetDate, or startDate and endDate |
PS_NOT_EDITABLE | The header, or this action, can’t be changed in the plan’s status (data.status) | Move a Locked or Completed plan back to Confirmed first; a Cancelled plan can’t be reopened |
STATUS_RULE | Items can only be added, edited or removed in the statuses in data.allowed | Move the plan back to an allowed status |
PS_FORECAST_FEED_NOT_TOGGLEABLE | /forecast-feed on a Completed or Cancelled plan | Nothing to change: finished plans don’t feed the forecast |
ITEM_IDS_REQUIRED, ITEMS_NOT_FOUND | Bulk item delete without ids, or with ids the plan doesn’t have (data.missingIds) | Re-read the items and send existing ids |
ITEM_PAIR_COLLISION | An item edit would put a variant at a location that already has a row for it | Edit or delete the existing row instead |
PLAN_TOO_LARGE | The plan would exceed its capacity (data.capacity: maxProducts, currentProducts, locations, windowDays) | Use fewer products or locations, or a shorter window or horizon |
UNKNOWN_VARIANTS, UNKNOWN_VARIANT | Sales plans: some variantIds don’t exist (data.variantIds), so nothing was added. New product plan create: no valid variant | Remove the unknown ids and resend. Commitments and other new-product adds list unknown variants in skipped[] instead |
UNKNOWN_LOCATIONS, UNKNOWN_LOCATION, NO_ACTIVE_LOCATIONS | A location id doesn’t exist (data.locationIds), or no active location is left to plan for | Use location ids from your shop |
LOCATION_NOT_ON_PLAN, LOCATION_ALREADY_ON_PLAN, LAST_LOCATION | The location isn’t on the plan, is already on it, or is the last one and can’t be removed | Check the plan’s locations[]; end a plan with a status change, not by removing its last location |
SALES_PLAN_LOCATION_FIXED, SALES_PLAN_WINDOW_FIXED, SALES_PLAN_FIELD_FIXED | A sales-plan item edit tried to change its location, dates or probability | Use the location routes or the plan’s window instead |
SALES_PLAN_RESEED_REQUIRED | Changing a sales plan’s window while it has rows | Resend with reseedTargets: true (replaces hand edits and revenue overrides) |
SIZE_CURVE_AND_SHARE | A size’s item edit sent both curve and sharePercent | Send one of them |
MIX_GATE_NOT_MET | apply-mix with source: "actual" before the group has 14 days and 30 units of sales | Wait, or use prelaunch or an explicit mix |
PLAN_VS_ACTUAL_TOO_LARGE | The plan-vs-actual answer would be too large | Add locationId or collapse=variant |
INVALID_CURSOR | A cursor from another plan or other filters | Restart without a cursor |
LOCATION_REQUIRED, SAME_LOCATION, INVALID_REFERENCE | An order action is missing a location, uses the same location twice, or names an unknown location or variant (data.field, data.unknownIds) | Fix the body; nothing was created |
NO_ORDERABLE_ITEMS | No item has a positive order quantity for this basis, coverage or locationIds selection | Check quantities and the selection; nothing was created |
NO_SUPPLIER_ASSIGNMENTS | No planned variant has a supplier, or supplierIds matched none (data.unassignedVariantIds) | Assign suppliers to the variants, or fix supplierIds; nothing was created |
TRANSFER_ORDER_ALREADY_LINKED | The commitment already has a linked transfer order (data.transferOrderId, data.transferOrderNumber) | Use the existing transfer order |
DUPLICATE_NAME, NAME_REQUIRED | A planning option with that name already exists in the type (data.optionId), or the name is blank | Reuse the existing option, or choose another name |
UNKNOWN_CATALOGUE_ID | catalogueHolidayId isn’t in the holiday catalogue | Take the id from GET /promotional-event-catalogue |
OCCURRENCE_OVERLAP | The dates overlap another stored occurrence of the event (data.conflictingOccurrenceId where available) | Edit or delete the other occurrence |
INVALID_BASELINE_DAYS, OCCURRENCE_NOT_ENDED, OCCURRENCE_SKIPPED | An analysis with baselineDays outside 7–365, of an occurrence that hasn’t ended, or of one that was skipped | Fix baselineDays, or analyze an ended, unskipped occurrence |
OCCURRENCE_TOO_LONG | New or changed dates span more than 350 days: an event create with occurrences, an occurrence create or edit, or an analysis by dates that stores the year first. An occurrence stored earlier can still be analyzed up to 366 days | Shorten the dates (edit the event’s length if its projected years are too long); nothing was stored |
Once an order action has created its first order, it no longer refuses: later failures come back
inside the 200. failed[] entries carry ORDER_CREATE_FAILED, ORDER_METADATA_MISSING or the
underlying code; linkFailures[] entries carry ORDER_LINK_FAILED or the underlying code. See
Planning create and order retries.
502 on writes: Shopify said no
Section titled “502 on writes: Shopify said no”502 UPSTREAM_SHOPIFY means a Shopify-side call failed while your request was running.
On POST …/complete and POST …/quick-complete this matters, because it tells you where
things stopped:
/complete: the cost sync runs first, so a502leaves the order’s status unchanged. Cost updates that already succeeded are kept and skipped when you retry./quick-complete: a total sync failure leaves the quantities already received but the order not completed. Recover withPOST …/shopify-sync/resetfollowed byPOST …/shopify-sync, or simply retry/quick-complete.
Either way, re-read the order with GET /api/v1/purchase-orders/{id} before deciding.
Cost-sync refusal codes
Section titled “Cost-sync refusal codes”The purchase-order cost-sync routes (POST /purchase-orders/{id}/resync-costs,
POST /purchase-order-cost-targets/{targetKey}/adopt, POST /purchase-order-cost-updates/{opId}/cancel
and PATCH /purchase-orders/{id}/line-items with updateSupplier: true) refuse with their own
error.code. Details such as targetKey, reason, blockingOp and adoptEligibleAt are in
error.data.
error.code | HTTP |
|---|---|
PROVIDER_UNAVAILABLE, PROTOCOL_UNAVAILABLE | 503 |
REQUEST_KEY_REQUIRED, SPLIT_LINE_NOT_SETTLED, SPLIT_DUPLICATE_INVALID | 400 |
PO_NOT_FOUND | 404 |
TARGET_BUSY, TARGET_UNKNOWN, TARGET_GONE, ADOPT_COOLDOWN, ADOPT_REFUSED, CANCEL_REFUSED, LEGACY_HISTORY, LEGACY_INFLIGHT, UNSUPPORTED_RECEIPTS, WEIGHT_UNAVAILABLE, EPOCH_CHANGED, HEAD_CHANGED, SOURCE_CHANGED, PREPARATION_STALE, PO_ID_REUSED, REQUEST_CONFLICT, BASIS_CHANGED, PROPOSAL_CONFLICT | 409 |
Read retryable on each error: TARGET_BUSY, ADOPT_COOLDOWN, LEGACY_INFLIGHT, HEAD_CHANGED,
SOURCE_CHANGED, BASIS_CHANGED and the two …_UNAVAILABLE codes are retryable, and so are
ADOPT_REFUSED and CANCEL_REFUSED for some data.reason values. Only ADOPT_COOLDOWN sends a
Retry-After (the time until adoptEligibleAt). After a refusal, retry with a new body
requestKey.
The failure that arrives inside a 200
Section titled “The failure that arrives inside a 200”Write endpoints save your purchase order first, then do follow-on work, pushing stock to Shopify, updating incoming quantities. Once the order is saved that work can’t be undone, so a failure in it is reported, not thrown:
{ "data": { "purchaseOrder": { "purchaseOrderId": "po_7c3e", "status": "Received" }, "status": { "before": "In Progress", "after": "Received" }, "sideEffects": { "shopifySync": { "status": "failed", "detail": "Shopify returned 429 for 2 items" } }, "recovery": "Reset the sync bookkeeping and re-sync: POST /shopify-sync/reset then POST /shopify-sync" }, "meta": { "requestId": "c5a0…", "apiVersion": "1.5" }}Handling retries
Section titled “Handling retries”- On
429/503, waitRetry-Afterseconds (orretryAfterMs) and retry the same request. - On
5xxduring a write, you often can’t tell whether it went through. Send anIdempotency-Keyand retrying is always safe; a stored success is replayed instead of re-run, and a5xx(or any error withretryable: true) is never stored, so a genuine retry really re-executes. The one exception isUNSYNC_FAILED: that write was already reversed in Shopify, so its answer is stored and replayed rather than run twice. - A
400withretryable: falseis final: change the request before sending it again. A retryable400(a stock sync in flight, anIdempotency-Keystill in progress) carriesretryAfterMs; wait and send the same request again. - Don’t fan out parallel requests against one shop; the data store is single-threaded per shop, so concurrency doesn’t go faster, can trip the rate limit, and on writes can collide with an in-flight stock sync.