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). You’ll see the same status, with adetailsaying so, when the setting couldn’t be read for that receive: nothing was sent, so callshopify-syncon its own; don’tresetfirst.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 a400withdata.code: "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 400
(data.code: "INVALID_TRANSITION") 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
400(data.rule: "CONFIRMATION_REQUIRED") naming the unconfirmed lines indata.lineItemIdsand 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 400 (data.rule: "RESTORE_TARGET_MISMATCH"). 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). Don’t use it after a "disabled" receive:
a reset offers every received unit again, including the ones Shopify already has. While a
queued sync is still running it returns 400 ON_HAND_SYNC_IN_FLIGHT: wait for it to settle
and retry.
When another system books the stock into Shopify
Section titled “When another system books the stock into Shopify”If a fulfiller or an ERP puts received stock into Shopify itself (for example with
Block sync to Shopify switched on) and your integration only reports the receipts,
add "alreadyInShopify": true to the receive:
POST /purchase-orders/{id}/receive{ "lines": [{ "lineItemId": "…", "quantityReceived": 29 }], "alreadyInShopify": true }Logistified then records those units as already in Shopify:
- They stop counting as incoming in the forecast straight away, instead of being counted twice (in Shopify’s on-hand and as incoming) until the order is completed.
- The call sends nothing to Shopify’s on-hand, whatever your sync settings say, it runs no automatic sync at all, for any line in it. Virtual materials in the same call reach Assemblified with its regular catch-up sync instead of right away.
- Un-receiving or deleting the line later never takes these units out of Shopify.
- Quantities are totals, so sending a line’s current total with the flag also covers what was received earlier without it. Sending it again changes nothing.
- A line’s received total can’t drop below the units counted as in Shopify. A receive
that lowers it is refused with
400 BELOW_SYNCED; usePOST …/unreceiveinstead, which writes nothing to Shopify for these units.
The receive response says what was recorded, in sideEffects.shopifySync
(alreadyInShopifyQuantity, alreadyInShopifyLineItemCount). Lines that never stock a
Shopify location (supplier-held, direct-ship, virtual materials and custom items) are
received as usual and listed in alreadyInShopifyNotApplicableLineItemIds. A dropship or
deliver-to-supplier order is refused with 400 ALREADY_IN_SHOPIFY_NOT_APPLICABLE.
If a stock sync of the same order is still running, the receive answers
400 ON_HAND_SYNC_IN_FLIGHT with retryable: true and nothing saved: wait a couple of
seconds and send it again. If the response’s recovery says the flag wasn’t recorded,
send the same request again with a new Idempotency-Key (or none); the old key would
only replay this answer.
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.