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). You’ll see the same status, with a detail saying so, when the setting couldn’t be read for that receive: nothing was sent, so call shopify-sync on its own; don’t reset first.
  • 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 400 with data.code: "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 400 (data.code: "INVALID_TRANSITION") 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 400 (data.rule: "CONFIRMATION_REQUIRED") naming the unconfirmed lines in data.lineItemIds 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 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" }
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 else: 400. 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). 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; use POST …/unreceive instead, 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.

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.