Skip to content

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.

POST /purchase-orders → Draft
POST /purchase-orders/{id}/status { "status": "Sent" } → Sent
POST /purchase-orders/{id}/confirm { "confirmAll": true } → In Progress
POST /purchase-orders/{id}/receive { "lines": [ … ] } → Partially Received / Received
POST /purchase-orders/{id}/complete { } → Completed

Two optional steps:

  • POST /purchase-orders/{id}/shopify-sync between receiving and completing — needed only if your shop has auto-sync-on-receiving switched off (you’ll see sideEffects.shopifySync.status: "disabled" on the receive response).
  • POST /purchase-orders/{id}/quick-complete replaces 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 a 409 (NOTHING_TO_RECEIVE) telling you to confirm lines first.

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 goUse
Partially Confirmed / Confirmed (from Sent or Partially Confirmed)POST …/confirm (it records confirmed quantities)
Partially Received / ReceivedPOST …/receive (it records quantities)
CompletedPOST …/complete (it runs the cost sync)
Everything elsePOST …/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.

FromYou can move to
DraftSent
SentCancelled, On Hold, Disputed, Draft (to confirm, use /confirm)
Partially ConfirmedCancelled, On Hold, Disputed (to Confirmed, use /confirm)
ConfirmedIn Progress (needs every line confirmed), Cancelled, On Hold, Disputed, Sent
In ProgressConfirmed, Cancelled, On Hold, Disputed (to receive, use /receive)
Partially ReceivedCancelled, On Hold, Disputed (to Received use /receive, to Completed use /complete)
ReceivedPartially Received, In Progress, On Hold, Disputed (to Completed use /complete)
CompletedReceived, Partially Received, In Progress — this is how you reopen a completed order
Cancelled / On Hold / DisputedBack 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 400 that 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 409 naming the unconfirmed lines and pointing you at /confirm.

To pause or stop an order, move it to On Hold, Disputed, or Cancelled:

Terminal window
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" }
Terminal window
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" }'

DELETE /api/v1/purchase-orders/{id} behaves differently by status:

  • Draft — deleted outright.
  • Cancelled — soft-deleted (hidden, but retained). The response’s mode tells you which happened.
  • Anything else400. Cancel it first.

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 recovery hint instead.
  • skipped lines 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.
  • failed usually 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.

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 an fxRateOverride with PATCH /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.

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" }
]
}
  • itemId is the line item’s id, not the variant id.
  • activate re-activates the variant in Shopify; skip proceeds without it.
  • On purchase orders there is no replaceWith option, and a deleted variant can only be skipped.

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:

statusMeans
verifiedEverything landed in Shopify.
partialSome lines landed; confirmation.lines[] says which and why.
failedNone landed. skipped[] and confirmation.lines[] carry the reasons.
timed-outNot 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.

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.