Purchase-order lifecycle
A purchase order moves through statuses, and each move has its own endpoint. This page is the map: the happy path, the full table of what’s allowed from where, and the handful of rules that trip people up.
Every endpoint below needs a write-scope key — see Authentication.
The happy path
Section titled “The happy path”POST /purchase-orders → DraftPOST /purchase-orders/{id}/status { "status": "Sent" } → SentPOST /purchase-orders/{id}/confirm { "confirmAll": true } → In ProgressPOST /purchase-orders/{id}/receive { "lines": [ … ] } → Partially Received / ReceivedPOST /purchase-orders/{id}/complete { } → CompletedTwo optional steps:
POST /purchase-orders/{id}/shopify-syncbetween receiving and completing — needed only if your shop has auto-sync-on-receiving switched off (you’ll seesideEffects.shopifySync.status: "disabled"on the receive response).POST /purchase-orders/{id}/quick-completereplaces receive → sync → complete with a single call from In Progress: it receives everything outstanding that was confirmed, syncs it, and completes the order. It needs at least one line with a confirmed quantity — otherwise it’s a409(NOTHING_TO_RECEIVE) telling you to confirm lines first.
Which endpoint moves which way
Section titled “Which endpoint moves which way”POST /purchase-orders/{id}/status is the general-purpose move. Some transitions are
deliberately not available there, because they need work that a bare status change
can’t do:
| Where you want to go | Use |
|---|---|
| Partially Confirmed / Confirmed (from Sent or Partially Confirmed) | POST …/confirm (it records confirmed quantities) |
| Partially Received / Received | POST …/receive (it records quantities) |
| Completed | POST …/complete (it runs the cost sync) |
| Everything else | POST …/status |
Sending { "status": "Completed" } to /status is always a 400 telling you to use
/complete. Sending { "status": "Partially Confirmed" } or { "status": "Confirmed" }
from Sent or Partially Confirmed gets the same treatment — a 409 pointing you at
/confirm instead, since those statuses have to be backed by actual confirmed quantities.
What /status allows, from each status
Section titled “What /status allows, from each status”| From | You can move to |
|---|---|
| Draft | Sent |
| Sent | Cancelled, On Hold, Disputed, Draft (to confirm, use /confirm) |
| Partially Confirmed | Cancelled, On Hold, Disputed (to Confirmed, use /confirm) |
| Confirmed | In Progress (needs every line confirmed), Cancelled, On Hold, Disputed, Sent |
| In Progress | Confirmed, Cancelled, On Hold, Disputed (to receive, use /receive) |
| Partially Received | Cancelled, On Hold, Disputed (to Received use /receive, to Completed use /complete) |
| Received | Partially Received, In Progress, On Hold, Disputed (to Completed use /complete) |
| Completed | Received, Partially Received, In Progress — this is how you reopen a completed order |
| Cancelled / On Hold / Disputed | Back to where it came from, and nowhere else — see below. |
Two more rules the guards enforce:
- You can’t walk an order backwards past work that’s been done. Once any line has received or synced quantity, moving back to Draft / Sent / Partially Confirmed / Confirmed is refused.
- Moving to the status it’s already in is a
400that lists the moves that are available — a handy way to discover them. - Confirmed → In Progress needs every line confirmed. If any line still has no
confirmed quantity (and isn’t cancelled), the call is a
409naming the unconfirmed lines and pointing you at/confirm.
Pausing and reopening
Section titled “Pausing and reopening”To pause or stop an order, move it to On Hold, Disputed, or Cancelled:
curl -s -X POST ".../api/v1/purchase-orders/po_7c3e/status" \ -H "Authorization: Bearer $LSTK_TOKEN" -H "Content-Type: application/json" \ -d '{ "status": "On Hold" }'To bring it back, send the status it was in before — this is enforced: any other target
is a 409. The order tells you which one that was: read previousStatus from
GET /api/v1/purchase-orders/{id} (it’s also on every list row).
{ "purchaseOrderId": "po_7c3e", "status": "On Hold", "previousStatus": "In Progress" }curl -s -X POST ".../api/v1/purchase-orders/po_7c3e/status" \ -H "Authorization: Bearer $LSTK_TOKEN" -H "Content-Type: application/json" \ -d '{ "status": "In Progress" }'Deleting an order
Section titled “Deleting an order”DELETE /api/v1/purchase-orders/{id} behaves differently by status:
- Draft — deleted outright.
- Cancelled — soft-deleted (hidden, but retained). The response’s
modetells you which happened. - Anything else —
400. Cancel it first.
The rules that trip people up
Section titled “The rules that trip people up”Receive takes totals, un-receive takes amounts
Section titled “Receive takes totals, un-receive takes amounts”POST …/receive quantities are the new total received for each line — not an amount
to add. That’s what makes it safe to repeat: sending the same call twice leaves the same
totals.
{ "lines": [{ "lineItemId": "pol_31aa", "quantityReceived": 30 }] }POST …/unreceive is the opposite: its quantities are how much to take back. Sending
5 twice removes 10. Only retry it after re-reading the order.
Constraints on /receive: received + rejected + cancelled can’t exceed the confirmed
quantity (or the ordered quantity if the line was never confirmed) unless you pass
allowOverReceipt: true; and you can’t reduce a line below what’s already been synced to
Shopify — use /unreceive for that.
Editing a line can also update the supplier’s catalogue
Section titled “Editing a line can also update the supplier’s catalogue”PATCH …/line-items edits the lines on this order. Pass updateSupplier: true and it
additionally writes the supplier-related values you sent back to the supplier’s catalogue
entry for that variant — the same thing the app asks about with “Update Supplier
Values?” when a merchant edits a line by hand. The next order you build from that
supplier then starts from the new cost, SKU, MOQ, pack size or lead time.
{ "lines": [{ "lineItemId": "pol_31aa", "unitCost": 7.5, "unitCostCurrencyCode": "EUR", "supplierSku": "ACME-991" }], "updateSupplier": true}The fields that carry over are unitCost, unitCostCurrencyCode, supplierSku,
supplierBarcode, supplierProductName, supplierProductUrl, supplierVariantDetails,
moq, packSize and leadTimeDays. Anything else you send (quantities, dates) only
touches the line. Note that supplierVariantDetails is write-only over REST: you can send it
on a line edit, but it is never returned in any response.
Only lines the server actually updated are written back — on a partial (207-style) result the
rejected lines appear as skipped with "line edit was not applied". packSize must be ≥ 1.
The result is in sideEffects.supplierUpdate, per line:
"supplierUpdate": { "outcome": "applied", "lines": [ { "lineItemId": "pol_31aa", "variantId": "gid://shopify/ProductVariant/1", "outcome": "applied" }, { "lineItemId": "pol_31ab", "variantId": "CSTM-VAR-9f2", "outcome": "skipped", "error": "custom item — no supplier catalogue row" } ]}- Your line edit is saved first, so a write-back problem never undoes it — it is
reported here with a
recoveryhint instead. skippedlines are normal: custom (CSTM-) lines have no catalogue entry, an order without a supplier has nowhere to write, and a line that only changed quantities or dates has nothing to carry over.failedusually means the supplier has no catalogue entry for that variant yet. We never create one implicitly — add it deliberately first.- Omit the flag and the slot is absent: line edits alone never touch supplier data.
Out of scope for now: choosing individual fields, applying a lead time to all of a supplier’s variants, and creating a missing catalogue entry.
Confirm takes lines or confirmAll, never both
Section titled “Confirm takes lines or confirmAll, never both”{ "confirmAll": true }{ "lines": [{ "lineItemId": "pol_31aa", "quantityConfirmed": 25 }] }Sending both is a 400. Confirmed quantities are totals (0 un-confirms) and can’t
exceed what was ordered. confirmAll is the shortcut: it accepts everything as ordered
and lands the order on In Progress in one call. Partial confirmations land on
Partially Confirmed or Confirmed instead — the response’s status.autoTransitioned tells
you the order moved on its own.
Completing needs received goods
Section titled “Completing needs received goods”POST …/complete works from Received or Partially Received only. From In Progress
it returns a 400 telling you to receive first or use /quick-complete.
Completing a Partially Received order is allowed — the outstanding quantities are simply left behind. That surprises people who expect a guard; there isn’t one.
Completing runs the cost sync first, which is why it can fail in two specific ways:
- A currency-conversion problem →
400. Set anfxRateOverridewithPATCH /purchase-orders/{id}and retry. - A Shopify or supplier write failure →
502, with the order’s status unchanged. Costs that already synced are kept and skipped on retry.
A second /complete on an already-completed order is a 400, not a no-op.
When a variant blocks the call
Section titled “When a variant blocks the call”Before touching stock, we check that the variants involved still exist and are active in
Shopify. If some aren’t, the call fails with 400 and an INVENTORY_GUARD_ISSUES
message listing them. This can happen on /status (moving to Sent), /confirm,
/receive, /quick-complete, and /shopify-sync.
Resend the same call with a resolutions array saying what to do with each one:
{ "lines": [{ "lineItemId": "pol_31aa", "quantityReceived": 30 }], "resolutions": [ { "itemId": "pol_31aa", "action": "activate" }, { "itemId": "pol_31ab", "action": "skip" } ]}itemIdis the line item’s id, not the variant id.activatere-activates the variant in Shopify;skipproceeds without it.- On purchase orders there is no
replaceWithoption, and a deleted variant can only be skipped.
Syncing stock to Shopify
Section titled “Syncing stock to Shopify”POST …/shopify-sync pushes received quantities to Shopify. It works from Sent through
Received. Omit lineItemIds to sync every line that needs it.
By default it waits for confirmation (up to about 30 seconds) and reports
confirmation.status:
status | Means |
|---|---|
verified | Everything landed in Shopify. |
partial | Some lines landed; confirmation.lines[] says which and why. |
failed | None landed. skipped[] and confirmation.lines[] carry the reasons. |
timed-out | Not an error — still in progress. Re-read the order in a moment. |
Pass awaitConfirmation: false to return as soon as the work is queued.
POST …/shopify-sync/reset clears the sync bookkeeping only — it never touches Shopify —
and is safe to repeat from any status. It’s the first half of the standard recovery from
a failed sync (reset, then shopify-sync). While a queued sync is still running it
returns 400 ON_HAND_SYNC_IN_FLIGHT: wait for it to settle and retry.
Two integrations at once
Section titled “Two integrations at once”There’s no locking to arrange. Each shop’s data is handled one request at a time, so
concurrent writes are applied in order. What you may see is a 400 saying a stock sync is
in flight — wait a moment and send the same request again.