Premium Apparel API — Error Reference
All errors share a single response shape:
{ "error": "<code>", "message": "...", "details": { } }
details is optional and its shape varies per error (see per-code sections below).
| HTTP | error | When | Client action |
|---|---|---|---|
| 401 | invalid_token |
Missing, malformed, or expired bearer token | Refresh the token and retry once. Do not loop. |
| 403 | forbidden |
Token is valid, but this operation is not available to you | Do not retry. Contact the API provider if you believe you should have access. |
| 404 | not_found |
Resource does not exist, or is not accessible to your account | Do not retry. Verify the identifier. The 404 is shape-identical for "doesn't exist" and "not yours" — by design. |
| 409 | duplicate_reference |
order_no already exists on your account |
Treat the prior order as already created. Look it up with GET /v1/orders?order_no=... to recover the stored our_ref. Do not retry with the same payload. |
| 409 | conflict |
The request is valid but the record's current state refuses the change | Depends on the cause — read the message. One cause means stop syncing this PO permanently; the others are per-product. Never blind-retry. |
| 422 | validation_error |
Request body, query, or params failed schema validation | Fix the payload. Do not retry the same body. details.validation (when present) pinpoints the failing field. |
| 502 | upstream_error |
The upstream system returned an unexpected or non-standard response | Do not auto-retry — usually indicates a data or processing issue. Escalate / alert. |
| 503 | upstream_unavailable |
The upstream system is temporarily unreachable | Retry with exponential backoff (e.g. 1s, 4s, 16s). Back off aggressively — a herd of retries makes things worse, not better. |
401 invalid_token
The Authorization: Bearer <token> header is missing, malformed, or the token was rejected (expired, revoked, or never valid).
{
"error": "invalid_token",
"message": "Bearer token is invalid or expired"
}
Client action: obtain a fresh token and retry the request once. Do not loop — a second 401 after a token refresh indicates a deeper auth problem and should escalate.
403 forbidden
Your token is valid, but this operation is not available to you. This is distinct from 401 (which means the token itself is missing or invalid): a 403 means you are authenticated, but cannot perform this particular action.
{
"error": "forbidden",
"message": "You do not have permission to perform that action"
}
Client action: do not retry — the same token will keep being refused. Contact the API provider if you believe you should have access.
404 not_found
The requested resource does not exist on your account. The API does not distinguish between "the resource doesn't exist" and "the resource exists but belongs to another account" — both return this same shape. This prevents reference-space enumeration across accounts.
{
"error": "not_found",
"message": "Order not found"
}
Client action: verify the identifier (our_ref for GET /v1/orders/{id}, order_no for GET /v1/orders?order_no=...). Do not retry blindly.
On POST /v1/purchase-orders/lines, this means no purchase order matches the supplied po_no. The message is deliberately generic and identical whether the PO does not exist or simply is not yours — no detail is returned either way, so the reference space cannot be probed.
PUT /v1/products is the exception: an unknown product is NOT a 404. It reports not_found as a per-item result inside a 200, because one unknown product_id must not fail the other 24 products in the batch. A 404 on that endpoint means the route itself was wrong, never that a product was missing:
{
"product_id": "NEWPROD003",
"status": "error",
"error": "not_found",
"reason": "product_not_found",
"message": "No product exists with this product_id"
}
Naming the product back is not enumeration here — you supplied the id, so the response confirms nothing you had not already asserted.
On POST /v1/purchase-orders/shipments, the same applies to purchase_line_pk: an unknown line, a line that is not yours, and a line that is not on a purchase order you can see are all one response. It carries no details at all — not even which of your lines was the problem — for the same anti-enumeration reason. Validate the pk against a PO you have read rather than probing.
On PUT /v1/purchase-orders/receipt, an unknown po_no and an unknown purchase_line_pk are the same bare 404, on the same terms. A pk that exists but sits on a different PO is not a 404 - it is a 422 (see below), because you named both and the response confirms nothing new.
409 duplicate_reference
On POST /v1/orders, the supplied order_no already maps to an order on your account. The API is idempotent by order_no — the first successful create wins.
{
"error": "duplicate_reference",
"message": "Order with this order_no already exists"
}
Client action: treat the prior create as authoritative. Fetch the existing order via GET /v1/orders?order_no=<the-value> to recover the our_ref that was assigned the first time. Do not retry the create — it will only 409 again.
On the purchase-order endpoints
Three distinct cases, all of which make retrying safe rather than duplicating work:
POST /v1/purchase-orders — the supplied invoice_no is already used by another PO. It is the idempotency key: re-posting the same one never raises a second purchase order, and the message names the PO that already holds it.
{
"error": "duplicate_reference",
"message": "Purchase Order P22971 already exists with that invoice_no"
}
POST /v1/purchase-orders/lines — the product is already on that PO. Note this is checked per product, not per SKU: a purchase line holds one product across its size slots, so every size of a product belongs on a single line.
{
"error": "duplicate_reference",
"message": "Purchase order P22971 already has a line for product 5000B2AZ - send allow_duplicate_products:true to add another"
}
POST /v1/purchase-orders/shipments — the supplied shipment_ref has already been used. It is the idempotency key, stored on the shipment PO it created: no second purchase order is ever raised for the same ref, and details.po_no names the one that already exists.
{
"error": "duplicate_reference",
"message": "Shipment SH-4471 already exists",
"details": { "po_no": "P23176" }
}
Client action: send every size of a product in one request — they are consolidated onto a single line automatically — rather than one request per size. If you genuinely want a second, separate line for the same product, set allow_duplicate_products: true; be aware this splits that product across two lines, each holding part of the size grid and its own cost. Otherwise treat the 409 as confirmation that a previous attempt already succeeded.
For a shipment, reconcile against the po_no in details and treat the shipment as done. One exception: if your previous attempt on that ref ended in a 502 carrying details.resumable: true, the shipment is only half-written and this 409 is exactly what will keep happening — re-send with resume: true instead. See 502 upstream_error below.
409 conflict
Distinct from duplicate_reference, and the distinction is not cosmetic: duplicate_reference means this reference is taken, and the usual response is to treat a previous attempt as having succeeded. conflict means the request is fine but the record will not accept it in its current state. Retrying an identical payload cannot fix either, but the remediations are unrelated.
Raised by PUT /v1/purchase-orders/lines, POST /v1/purchase-orders/shipments, PUT /v1/purchase-orders/receipt and PUT /v1/orders/{our_ref}/line-items.
On PUT /v1/purchase-orders/lines
Four causes, and the client must not conflate them — one of them is terminal for the whole PO, the other three concern a single product.
| Cause | Scope | What it means | Client action |
|---|---|---|---|
| PO converted to an invoice | whole request | The PO is finished as far as the API is concerned. It will never accept another line change. | Quarantine the PO permanently. Stop syncing it and alert. Do not retry, now or later — no future call will succeed. |
| Line linked to a sales order | per product | This product's line is committed to a sales order and needs unlinking in the source system first. | Skip this product and alert a human. The rest of the batch is unaffected — under allow_partial: true the other products are written normally. |
| Would drop ordered below received | per product | Goods have already been received against this line; the requested grid would leave ordered short of received, or would remove a line carrying receipts. | Reconcile against actual receipts. Do not force the quantity down. |
| Line carries a size pack | per product | The line has a pack ratio, and any grid change on a packed line is refused — a written grid that contradicts a printed pack label is a defect the factory acts on. | A human must resolve it every time. Packed products are not syncable through this endpoint by design. |
{
"error": "conflict",
"message": "Purchase order P22971 has been converted to an invoice and no longer accepts line changes"
}
A 422 is not evidence the PO is still open. Converted-to-invoice previously came back as missing_data → 422 and now returns 409. Other non-Order records — stock adjustments and the like — still produce a 422, so the two must be branched on separately.
Under allow_partial: true the three per-product causes do not produce this response at all: the request returns 200 and the affected products appear as action: "failed" rows carrying the underlying code. Only the whole-request cause still surfaces as a 409, in both modes.
On POST /v1/purchase-orders/shipments
Three causes. All are whole-request — a shipment split is never partial — and, unlike the converted-to-invoice case above, none of them is terminal for the PO. Each is correctable.
| Cause | What it means | Client action |
|---|---|---|
| More asked for than is on order | The quantity for a size slot exceeds what that perpetual line still has on order. details.purchase_line_pk names the line; the message names the size and both quantities. |
Re-read the perpetual PO and send the quantity that is actually there. Do not retry unchanged. |
| Line already on the target PO | The line has already been moved onto this shipment. Almost always a plain retry of a shipment whose write pass failed part-way. | Do not re-send unchanged — send the identical payload with resume: true, which reports these lines as action: "already_moved" and finishes the rest. |
| Quantities already received | Something has been received against a perpetual line. That should not be possible — receipting only ever happens on a shipment PO — so the request is refused rather than guessed at. | A human must resolve it. Do not force the split. |
{
"error": "conflict",
"message": "line 319297B1622392262026T: L (ordered 4, asked for 99)",
"details": { "purchase_line_pk": "319297B1622392262026T" }
}
A converted-to-invoice PO also surfaces here, on the same terms as the table above: terminal, quarantine it.
On PUT /v1/purchase-orders/receipt
Two kinds, and the client must tell them apart. details.reason names the cause and details.retryable says which kind it is. Apart from the one case described under the table, nothing was written.
details.reason |
retryable |
What it means | Client action |
|---|---|---|---|
record_locked |
true |
Someone in GarmentWorks has the purchase line, its product or the PO open for editing, so the receipt was refused rather than half-applied. | Re-send the identical request shortly. The call is desired state, so a re-send can never receive twice. |
already_converted |
false |
The PO has been converted to an invoice (or stock adjustment). Receiving is over for it. | Stop. Do not retry, now or later. |
po_protected |
false |
The PO is flagged Protected - a standing (perpetual) order. Goods are received on the shipment PO split off it, never on the standing order. | Receive against the shipment PO instead. |
po_locked |
false |
The PO, or the named line, is some other non-Order record. | Stop and alert. |
{
"error": "conflict",
"message": "Product ZZTEST is being edited by someone else (FileMaker error 301) - nothing was received, try again shortly",
"details": { "stage": "lines", "purchase_line_pk": "321054O0843487132026K", "lines_written": 0,
"reason": "record_locked", "retryable": true }
}
A record_locked can also arrive after some lines have been received, if a record is opened in the moment between checking and writing. details.lines_written says how many; re-sending the identical request finishes the rest, and the lines already done come back changed: false.
On PUT /v1/orders/{our_ref}/line-items
One cause, and it is terminal for that order — the sales-order counterpart of the converted-to-invoice case above, on the same terms.
The order has moved past the point where its value may change: it has been converted from an Order to an Invoice, so the amount is posted and may already sit in the accounting system. The API will not amend it, now or later.
{
"error": "conflict",
"message": "Order 0999001 is a Invoice and can no longer accept line changes",
"details": { "type_desc": "Invoice", "reason": "order_not_amendable" }
}
details.reason names the specific cause and details.type_desc the state the order is actually in, so you can branch without parsing the message. As everywhere on this API, the top-level error is a member of the fixed taxonomy and the open-ended cause rides in details.
Client action: stop syncing that order and alert a human. Do not retry — no future call will succeed, and the correction has to happen as a credit note or adjustment through the normal accounting process, not through this endpoint.
This is deliberately a refusal rather than a partial apply. The whole call is rejected and nothing is written, so an order can never be left in a state neither side predicted. Nothing about the request was wrong; the order simply moved on.
On PATCH /v1/orders/{our_ref}
Two causes, and they are not alike — branch on details.reason.
order_not_amendable — terminal. The order has been converted to an Invoice, so it is posted and the header is no longer the API's to change. Same terms as the line endpoint above.
{
"error": "conflict",
"message": "Order 0999001 is a Invoice and can no longer be amended",
"details": { "type_desc": "Invoice", "reason": "order_not_amendable" }
}
Client action: stop and alert a human. No future call will succeed; the edit has to be made in GW.
conflict — transient. Someone has the order open for editing, so the record is locked and the update was refused rather than half-applied.
{
"error": "conflict",
"message": "Order 0999001 is open for editing by someone else (FileMaker error 301) - try again shortly",
"details": { "reason": "conflict" }
}
Client action: retry the identical payload after a short pause. This one really does clear on its own once the other user moves off the record — and the retry is safe: comments_append ignores text the message already holds, and every other field is a plain set.
Nothing is written in either case. The lock is taken before the first write precisely so that a refusal is never a partial update.
422 validation_error
The request payload, query string, or URL parameters failed JSON-Schema validation. The details object, when present, carries the underlying AJV validation list — useful for pinpointing the first failing field.
{
"error": "validation_error",
"message": "body must have required property 'line_items'",
"details": {
"validation": [
{ "instancePath": "", "keyword": "required", "params": { "missingProperty": "line_items" } }
]
}
}
Client action: fix the payload; do not retry the same body. Typical causes: missing order_no or line_items, empty line_items array (minItems: 1), integer SKUs, non-integer quantities, unknown top-level fields (additionalProperties: false is strict).
On PUT /v1/orders/{our_ref}/line-items
Line amendment is desired-state: the order ends up holding exactly the lines you send, and a SKU absent from the payload is removed from that order. Three causes are specific to it.
| Cause | Example message | Client action |
|---|---|---|
A line is being removed but moved_to was not supplied |
Line PS17190-PRX-OTAF-OTTS is being removed but no moved_to was supplied |
Name the order the line moved to. See below — this is a guard, not a formality. |
moved_to names an order that does not exist, or is not amendable |
moved_to order 0999002 does not exist |
Create the destination order first, then amend the source. |
| Two sizes of the same product quote different prices | Product PS01350-PRX-OTAF-OTTT has conflicting prices 28.5 and 31 - a sale line holds one price across all its sizes |
Send one price per product. A sale line holds a single price across all eleven of its size slots, so per-size pricing cannot be represented. |
Why moved_to is mandatory on removal. A line removed here is not deleted — it is moved to the order you name, keeping its identity so that any purchase order raised against it follows it instead of being orphaned. Without a destination there is nowhere for it to go, and guessing would silently break the link between the sale and the goods on order. The request is refused instead.
Nothing is written when any of these fire. The whole call is validated before a single change is applied, so a rejected request leaves the order exactly as it was.
On PATCH /v1/orders/{our_ref}
| Cause | Example message | Client action |
|---|---|---|
| No field to change was sent | Send at least one field to change: order_status, attention, shipping_address, required_by, comments_append |
app on its own asks for nothing. Include the field you mean to set. |
shipping_address without address1 or city |
body/shipping_address must have required property 'address1' |
shipping_address replaces the whole delivery address — anything omitted is cleared — so a half-filled object is refused rather than allowed to erase lines you meant to keep. Send every line you want kept. |
required_by is not an ISO date |
body/required_by must match pattern "^(\d{4}-\d{2}-\d{2})?$" |
Use YYYY-MM-DD. The empty string is the documented way to clear the date; any other shape is refused rather than guessed at. |
| An unknown field | body must NOT have additional properties |
Only the five fields above, plus app, are accepted. In particular there is no comments — the internal message is append-only, via comments_append. |
Nothing is written when any of these fire.
On the purchase-order endpoints
A 422 here can come from GWAPI's own schema check or from the upstream system's own validation — the shape is the same, but only the schema-level failures carry details.
Purchase-order specific causes:
| Cause | Example message |
|---|---|
Unknown supplier (supplier_id does not exist) |
No supplier found with supplier_id ZZNOPE |
| Unparseable or unknown SKU | Product '5000B2AZ' has no size 'ZZZ' |
quantity below 1 |
body/lines/0/quantity must be >= 1 |
Empty lines array |
No purchase lines are defined |
po_no matches more than one PO, or the record is not an Order |
PO not unique |
invoice_date not ISO yyyy-mm-dd |
body/invoice_date must match pattern |
Note the SKU format: sku is PRODUCT-SIZE, split on the last hyphen. The product must be active and the size must be one of its 11 size slots — so 5000B2AZ-YM is product 5000B2AZ, size YM. A bad size and an unknown product both surface here.
On POST /v1/purchase-orders/shipments
| Cause | Example message |
|---|---|
Missing shipment_ref |
body must have required property 'shipment_ref' |
Same purchase_line_pk twice in lines |
Purchase line '31929…' appears more than once in lines |
Same size_desc twice on one line |
Size 'M' appears more than once on purchase line '31929…' |
| A line whose sizes total zero | Purchase line '31929…' ships a total quantity of zero |
| Size label not on that line | Size 'ZZZ' is not on that line |
| Lines span two suppliers | Lines belong to more than one supplier |
ed_arrival / required_by not ISO yyyy-mm-dd |
body/ed_arrival must match pattern |
A validation failure has written nothing. Validation completes for every line before any record is touched, in this layer and upstream, so a 422 never leaves a partly-created shipment behind — there is no PO to clean up and nothing to reconcile.
On a bad size label, read details.valid_sizes. It lists the labels actually in force on that line, which is the fastest route to a correct payload:
{
"error": "validation_error",
"message": "line 319297B1622392262026T (product ZZTEST5-SEZ-DRY-SPLIT) has no size ZZZ",
"details": {
"purchase_line_pk": "319297B1622392262026T",
"valid_sizes": "XS, S, M, L, XL, 2XL, 3XL, 4XL, 5XL, 6XL, 7XL"
}
}
Note valid_sizes is a comma-separated string, not an array — split on ", " if you need a list.
Size labels are per line, not per product. A purchase line keeps its own snapshot of its size labels from when it was raised, so if the product's size group has changed since, the line still uses the old labels — and those are what size_desc is matched against. Do not source these from the current product record; use valid_sizes.
On PUT /v1/purchase-orders/receipt
| Cause | Example message |
|---|---|
| A size's total would exceed what was ordered for it | line 3210... size M: received 5 is more than the 3 ordered - send allow_over_receipt:true if that really arrived |
| The line is on a different PO | Line 3210... is not on purchase order P23629 |
| Size label not on that line | line 3210... (product ZZTEST) has no size XXL (carries details.valid_sizes) |
| A credit line, or the landing-cost line, was named | Line 3210... (LCOST) carries negative quantities - it is a credit or the landing-cost line and cannot be received |
| A serial- or batch-numbered product | ... is a Serial-numbered product - receive it in GarmentWorks so the numbers can be entered |
invoice_no empty once NULs/whitespace are removed, or Stock |
invoice_no cannot be "Stock" - GarmentWorks treats that as no invoice number |
invoice_date not a real yyyy-mm-dd date |
invoice_date '2026-02-30' is not a real date |
The same purchase_line_pk, or the same size on one line, twice |
Size 'M' appears more than once on purchase line '3210...' |
received negative or not a whole number |
body/lines/0/sizes/0/received must be >= 0 |
A validation failure has written nothing. Every line is checked before any record is touched.
Over-receipt carries the figures, so the caller can decide without a second read:
{
"error": "validation_error",
"message": "line 321054O0843487132026K size M: received 5 is more than the 3 ordered - send allow_over_receipt:true if that really arrived",
"details": { "purchase_line_pk": "321054O0843487132026K", "size": "M", "received": 5, "maximum": 3 }
}
maximum is what was ordered for that size (still on order + already received). Re-send with allow_over_receipt: true only if that quantity really arrived.
502 upstream_error
The upstream system responded, but the response was not the expected shape — e.g. a missing field, malformed JSON, an unknown error value, or an unexpected server-side exception. details may carry a diagnostic when one is available.
{
"error": "upstream_error",
"message": "Upstream system returned an unexpected response"
}
Client action: do not auto-retry. This almost always indicates a data or processing issue that won't self-heal. Alert / escalate. If the same payload works later, it was a one-off transient; if it repeats, report it to the API provider.
The exceptions where a 502 is actionable
PUT /v1/purchase-orders/lines — retry with the identical body. A write-phase failure there can leave earlier products written, and that endpoint's idempotency (it syncs desired state, so re-sending converges) is what makes the retry safe. Retry once; if it fails again, escalate.
PUT /v1/purchase-orders/receipt — check details.retry_same_payload. When it is true (upstream_code: "write_failed"), the write stopped part-way: details.lines_written lines were received, each completely (a line and its product counters are saved together), and the PO's invoice number may not be stored yet. Re-send the identical body: received quantities are totals, so the finished lines come back changed: false and the rest are written. Retry once; if it fails again, escalate. When it is false, do not retry.
POST /v1/purchase-orders/shipments — check details.resumable. A shipment split is not idempotent, so a blind retry is never correct; the flag tells you which of the two situations you are in.
{
"error": "upstream_error",
"message": "Record locked. Re-send the same payload with resume:true to finish it.",
"details": {
"upstream_code": "write_failed",
"resumable": true,
"po_no": "P23176",
"purchase_line_pk": "31929300124606262026I",
"lines_moved": 0
}
}
details.resumable |
What happened | Client action |
|---|---|---|
true |
The write pass failed part-way (usually a record lock). The shipment PO exists and lines_moved lines have already been split. There is no cross-record transaction upstream, so that state is real. |
Re-send the identical payload with resume: true. It continues onto the existing PO rather than refusing on the shipment_ref guard, and lines the first attempt already moved come back as action: "already_moved" — successes, not failures. Retry this once; if the resume also fails, escalate. |
false |
Either nothing was written at all, or — when details.upstream_code is write_failed_dirty — a rollback failed and a purchase line is in a state no retry can fix. |
Do not retry. write_failed_dirty in particular needs a human, and is worth alerting on separately from every other 502. |
details.po_no, details.purchase_line_pk and details.lines_moved are what let you tell a "nothing happened" 502 from a "half done" one. details.upstream_code is the raw upstream code, provided for diagnostics and alert routing — branch on resumable, not on the code string.
Without resume a half-finished shipment can never be completed through the API, because the shipment_ref guard refuses every subsequent attempt with a 409. This is the one and only case in which that flag should be set.
503 upstream_unavailable
The API could not reach the upstream system. Usually transient (restart, network blip), sometimes sustained (planned maintenance).
{
"error": "upstream_unavailable",
"message": "Upstream service is temporarily unavailable"
}
Client action: retry with exponential backoff — e.g. 1s, 4s, 16s, 64s, then give up and surface to the user. Do not retry-loop with no backoff; the upstream gets worse under contention, not better.
On POST /v1/orders: outcome_unknown
Creating an order is the one call where giving up early is not the same as nothing happening. When the upstream does not answer within the order-creation timeout, the API does not simply report a failure: it looks the order up by your order_no, and if the order was created you get the normal 201 with its our_ref. Only if it still cannot find it do you get:
{
"error": "upstream_unavailable",
"message": "The upstream system did not answer in time, so the order may or may not have been created. Look it up by order_no before retrying. Re-sending the same order_no is safe: it returns 409 if the order exists.",
"details": { "reason": "outcome_unknown" }
}
Client action: look the order up with GET /v1/orders?order_no=… before doing anything else. If it is there, use it. If not, re-send the identical request. Re-sending is safe either way, because a second order with the same order_no is refused with 409 duplicate_reference, and that message names the existing order.
An order placed on behalf of another contact (contact_id) may not be visible to your token, in which case the lookup cannot find it and you get outcome_unknown even though it was created. The 409 on re-send still protects you.
Exception: GET /api/products uses a legacy error shape
The product-catalogue endpoint GET /api/products is the one endpoint that does
not use the canonical envelope above. For backwards compatibility with existing
clients, it returns:
{ "error": true, "error_reason": "Unauthorised" }
erroris a boolean (true), not an error-code string.error_reasonis a human string. Observed values:Unauthorised(the bearer key is not recognised), andstyle parameter is no longer supported(the deprecated?style=parameter, which now returns 400).
This endpoint's auth model is also different: the Authorization: Bearer <key>
header is a customer token that returns that customer's pricing. No token
returns the default catalogue with RRP pricing. A success returns the catalogue
JSON directly, not a wrapped envelope.
Statuses this API does not emit
| HTTP | Why you will not see it |
|---|---|
429 Too Many Requests |
There is no enforced rate limit today, and no Retry-After or RateLimit-* headers are sent. If a limit is ever introduced it will be announced on the changelog before it takes effect and will use 429 with a Retry-After header giving seconds to wait — so handling 429 + Retry-After now is future-proofing, not dead code. See the Integration FAQ for the limits that do exist and for polling guidance. |
500 |
Unhandled failures surface as 502 upstream_error or 503 upstream_unavailable. A bare 500 means something escaped the error handler — report it. |
Last updated: 2026-09-15.