Skip to content

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 a Retry-After response 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 in data.code (for example ON_HAND_SYNC_IN_FLIGHT or DUPLICATE_NAME) plus the ids you need to react. Branch on data.code, not on the message text.
codeHTTPMeaningRetry-After
UNAUTHORIZED401Missing, invalid, or revoked tokenNot applicable
PLAN_REQUIRED403The shop isn’t on the Elevate plan (the REST API is Elevate-only)Not applicable
FORBIDDEN_SCOPE403The token’s scope can’t do this, e.g. a read key on a write endpointNot applicable
INVALID_INPUT400A bad parameter, a bad body, or a change the resource’s current state doesn’t allow (the message names it)if present
NOT_FOUND404Unknown path, or the id doesn’t existNot applicable
RATE_LIMITED429Per-shop rate limit reached✓
DO_OVERLOADED503The shop’s data store is busy✓
DO_UNAVAILABLE503The data store is temporarily unavailable✓
UPSTREAM_SHOPIFY502A Shopify-facing dependency failedif present
UPSTREAM_FETCH502Fetching a URL you supplied failedif present
TIMEOUT504The request timed outif present
OUTPUT_TOO_LARGE413The response exceeded the size capNot applicable
INTERNAL500An unexpected errorNot 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; use POST …/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 locationId first with PATCH …/{id}.
  • /complete on an order that isn’t Received or Partially Received: receive first, or use POST …/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 a resolutions array saying what to do with each one. See the lifecycle page.

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.codeWhenWhat to do
INVALID_STATUS_TRANSITION, INVALID_TRANSITIONThe status change isn’t allowed from the current status, or the plan already has that statusRead the plan’s status. A retried status change that already went through answers this way
EMPTY_PLANConfirming a Draft that has no itemsAdd items first
PS_HEADER_DATE_REQUIREDConfirming a commitment without a header date, or clearing the last date of a Confirmed or Locked oneSet targetDate, or startDate and endDate
PS_NOT_EDITABLEThe 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_RULEItems can only be added, edited or removed in the statuses in data.allowedMove the plan back to an allowed status
PS_FORECAST_FEED_NOT_TOGGLEABLE/forecast-feed on a Completed or Cancelled planNothing to change: finished plans don’t feed the forecast
ITEM_IDS_REQUIRED, ITEMS_NOT_FOUNDBulk item delete without ids, or with ids the plan doesn’t have (data.missingIds)Re-read the items and send existing ids
ITEM_PAIR_COLLISIONAn item edit would put a variant at a location that already has a row for itEdit or delete the existing row instead
PLAN_TOO_LARGEThe 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_VARIANTSales plans: some variantIds don’t exist (data.variantIds), so nothing was added. New product plan create: no valid variantRemove the unknown ids and resend. Commitments and other new-product adds list unknown variants in skipped[] instead
UNKNOWN_LOCATIONS, UNKNOWN_LOCATION, NO_ACTIVE_LOCATIONSA location id doesn’t exist (data.locationIds), or no active location is left to plan forUse location ids from your shop
LOCATION_NOT_ON_PLAN, LOCATION_ALREADY_ON_PLAN, LAST_LOCATIONThe location isn’t on the plan, is already on it, or is the last one and can’t be removedCheck 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_FIXEDA sales-plan item edit tried to change its location, dates or probabilityUse the location routes or the plan’s window instead
SALES_PLAN_RESEED_REQUIREDChanging a sales plan’s window while it has rowsResend with reseedTargets: true (replaces hand edits and revenue overrides)
SIZE_CURVE_AND_SHAREA size’s item edit sent both curve and sharePercentSend one of them
MIX_GATE_NOT_METapply-mix with source: "actual" before the group has 14 days and 30 units of salesWait, or use prelaunch or an explicit mix
PLAN_VS_ACTUAL_TOO_LARGEThe plan-vs-actual answer would be too largeAdd locationId or collapse=variant
INVALID_CURSORA cursor from another plan or other filtersRestart without a cursor
LOCATION_REQUIRED, SAME_LOCATION, INVALID_REFERENCEAn 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_ITEMSNo item has a positive order quantity for this basis, coverage or locationIds selectionCheck quantities and the selection; nothing was created
NO_SUPPLIER_ASSIGNMENTSNo 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_LINKEDThe commitment already has a linked transfer order (data.transferOrderId, data.transferOrderNumber)Use the existing transfer order
DUPLICATE_NAME, NAME_REQUIREDA planning option with that name already exists in the type (data.optionId), or the name is blankReuse the existing option, or choose another name
UNKNOWN_CATALOGUE_IDcatalogueHolidayId isn’t in the holiday catalogueTake the id from GET /promotional-event-catalogue
OCCURRENCE_OVERLAPThe 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_SKIPPEDAn analysis with baselineDays outside 7–365, of an occurrence that hasn’t ended, or of one that was skippedFix baselineDays, or analyze an ended, unskipped occurrence
OCCURRENCE_TOO_LONGNew 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 daysShorten 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 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 a 502 leaves 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 with POST …/shopify-sync/reset followed by POST …/shopify-sync, or simply retry /quick-complete.

Either way, re-read the order with GET /api/v1/purchase-orders/{id} before deciding.

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.codeHTTP
PROVIDER_UNAVAILABLE, PROTOCOL_UNAVAILABLE503
REQUEST_KEY_REQUIRED, SPLIT_LINE_NOT_SETTLED, SPLIT_DUPLICATE_INVALID400
PO_NOT_FOUND404
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_CONFLICT409

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.

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" }
}
  • On 429 / 503, wait Retry-After seconds (or retryAfterMs) and retry the same request.
  • On 5xx during a write, you often can’t tell whether it went through. Send an Idempotency-Key and retrying is always safe; a stored success is replayed instead of re-run, and a 5xx (or any error with retryable: true) is never stored, so a genuine retry really re-executes. The one exception is UNSYNC_FAILED: that write was already reversed in Shopify, so its answer is stored and replayed rather than run twice.
  • A 400 with retryable: false is final: change the request before sending it again. A retryable 400 (a stock sync in flight, an Idempotency-Key still in progress) carries retryAfterMs; 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.