Conventions
Success envelope
Section titled “Success envelope”Every success wraps the result in data, with a small meta block:
{ "data": { /* the resource(s) */ }, "meta": { "requestId": "0f8c…", "apiVersion": "1.1" }}meta.requestId— also returned as theX-Request-Idresponse header. Quote it in support requests.meta.apiVersion— the REST API version (1.1).
Methods and status codes
Section titled “Methods and status codes”| Method | Used for | Success |
|---|---|---|
GET | Reading anything | 200 |
POST | Creating a purchase order or line items | 201 (+ Location when a new PO is created) |
POST | Lifecycle actions (/status, /receive, /complete, …) | 200 |
PATCH | Partial updates to a header or a set of line items | 200 |
DELETE | Deleting a purchase order or a set of line items | 200 (with a body — never an empty 204) |
PUT is not used anywhere. Sending a method an endpoint doesn’t support returns 405
with an Allow header listing what it does support; an unknown path returns 404.
Request bodies
Section titled “Request bodies”- Bodies are JSON objects. Send
Content-Type: application/json— but we parse the body either way, so a wrong header still gets you a precise error rather than a confusing one. - No body is fine where an endpoint doesn’t need one (
DELETE /purchase-orders/{id},POST /purchase-orders/{id}/complete,POST /purchase-orders/{id}/shopify-sync/reset). An omitted body is treated as{}. - Bodies are capped at 1 MB. Anything larger, anything that isn’t valid JSON, and
anything that isn’t a JSON object (a bare array, string, or number) returns
400. - Unknown fields are rejected, not ignored — the error names the field. That’s deliberate: a typo in a field name would otherwise silently do nothing.
- The URL wins. The purchase-order id comes from the path. You may repeat it in the
body, but if it differs from the path you get a
400— that combination always means a bug in the caller.
Line-item endpoints are bulk
Section titled “Line-item endpoints are bulk”Every line-item endpoint takes (or returns) a list, even for one item — there are no single-item variants:
# add ONE line item: still an arraycurl -s -X POST ".../api/v1/purchase-orders/po_7c3e/line-items" \ -H "Authorization: Bearer $LSTK_TOKEN" -H "Content-Type: application/json" \ -d '{ "lineItems": [{ "variantId": "var_9a01", "quantityOrdered": 30, "unitCost": 12.5 }] }'Deleting takes its ids in the query string:
curl -s -X DELETE ".../api/v1/purchase-orders/po_7c3e/line-items?lineItemIds=pol_31aa,pol_31ab" \ -H "Authorization: Bearer $LSTK_TOKEN"Batch your changes into one call wherever you can — it’s faster and, because every call costs one token from your rate limit, a lot cheaper than a loop.
Absolute totals vs. deltas
Section titled “Absolute totals vs. deltas”This is the single most common mistake, so it’s worth stating plainly:
| Endpoint | Numbers mean |
|---|---|
/receive | The new total received for that line. Sending 30 twice leaves it at 30, not 60. |
/confirm | The new total confirmed for that line. 0 un-confirms. |
/unreceive | How much to take back — a delta. Sending 5 twice removes 10. |
/receive and /confirm are therefore safe to repeat. /unreceive is not; see
Retrying safely below.
Read the current totals from GET /api/v1/purchase-orders/{id}/line-items before you
compute a new one.
Reading sideEffects
Section titled “Reading sideEffects”Most write responses carry a sideEffects object. It reports work that happened
after your purchase order was saved — pushing stock to Shopify, updating incoming
quantities, syncing tags.
"sideEffects": { "shopifySync": { "status": "queued", "syncedLineItemCount": 3 }, "incoming": { "outcome": "applied" }}How to read it:
- Only the parts that actually ran appear. An endpoint that never touches Shopify has no
shopifySynckey at all — that’s not a failure. shopifySync.status:queued— accepted for delivery to Shopify. Not yet confirmed applied; pollGET /purchase-orders/{id}if you need certainty.disabled— your shop has auto-sync-on-receiving switched off. Nothing reaches Shopify until you callPOST /purchase-orders/{id}/shopify-sync.skipped— nothing needed syncing.failed— it did not go through. Arecoveryfield on the response tells you what to do (usually:POST …/shopify-sync/reset, thenPOST …/shopify-sync).
outcome: "failed"on any other slot works the same way — the order changed, the follow-on didn’t, andrecoverysays how to fix it.supplierUpdateis the one slot that isn’t about Shopify: it appears only onPATCH …/line-itemswithupdateSupplier: trueand reports, per line, whether the supplier’s catalogue entry was updated (applied/skipped/failed). See Editing a line can also update the supplier’s catalogue.
Retrying safely
Section titled “Retrying safely”With Idempotency-Key (recommended)
Section titled “With Idempotency-Key (recommended)”Send an Idempotency-Key header — any unique string up to 255 characters, a UUID is
ideal — on any POST, PATCH, or DELETE. Retry the exact same request with the same
key as often as you like: it will execute at most once.
KEY=$(uuidgen)curl -s -X POST "https://prod.logistified.app/api/v1/purchase-orders" \ -H "Authorization: Bearer $LSTK_TOKEN" \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{ "poName": "Q3 restock" }'# → 201, Location: /api/v1/purchase-orders/po_7c3e
# same key, same body — no second purchase order is createdcurl -s -X POST ".../api/v1/purchase-orders" -H "Idempotency-Key: $KEY" … -i# → 201, byte-identical body, plus: Idempotent-Replayed: trueWhat each situation gives you:
| You send… | You get |
|---|---|
| The same key with the same request | The original response, byte for byte — same status, same body, same meta.requestId — plus the header Idempotent-Replayed: true |
| The same key with a different request | 400 with "use a new key". Keys are one per request, not one per session |
| The same key while the first attempt is still running | 400 with retryable: true and Retry-After: 1. Wait a second, retry, and you’ll get the stored answer |
| A key you’ve never used (or one older than 24 hours) | The request runs normally and its answer is stored |
Details worth knowing:
- Keys are scoped to the API key that used them, so two integrations in the same shop can’t collide.
- “The same request” means the same method, path, query, and body. Field order and
1.0vs1don’t matter; the order of items in an array does. - Only final answers are stored — successes and
4xxclient errors. A429or any5xxis not stored, so retrying really re-runs the request. That’s what you want: those are the failures where the outcome is genuinely unknown. - Keys last 24 hours. After that the same key is treated as new.
- A replay is free — it doesn’t consume a rate-limit token.
- Using the header on a
GETis harmless; it’s simply ignored. - Browser clients:
Idempotency-Keyis an allowed request header andIdempotent-Replayedis an exposed response header, so cross-origin calls work.
Without a key
Section titled “Without a key”Some endpoints are naturally safe to repeat; some aren’t. If you don’t send a key:
| Endpoint | Safe to repeat? |
|---|---|
POST /purchase-orders | No — the PO number is generated for you; retry with a key |
PATCH /purchase-orders/{id} | Yes — last write wins |
DELETE /purchase-orders/{id} | Yes — the second call just returns 404 |
POST …/line-items, …/line-items/custom | No — you’ll add duplicate lines |
PATCH …/line-items | Yes — the values are absolute |
DELETE …/line-items | Yes |
POST …/status | No — the second call 400s (that status is no longer a valid move) |
POST …/confirm, …/receive | Yes — the totals are absolute, so they converge |
POST …/unreceive | Depends — see the warning below |
POST …/complete | Yes — the second call 400s, the order stays Completed |
POST …/quick-complete | Partly — on a failure the receipts are kept; retry is fine |
POST …/shopify-sync | Yes — already-synced lines are skipped |
POST …/shopify-sync/reset | Yes |
Pagination
Section titled “Pagination”List endpoints accept:
limit— page size (max 50).cursor— an opaque page token from the previous response.
and return them inside data:
{ "data": { "purchaseOrders": [ /* … */ ], "totalCount": 128, "cursor": "3" }, "meta": { "requestId": "…", "apiVersion": "1.1" }}Pass cursor back to fetch the next page. When cursor is null, you’ve reached the
last page.
# page 1curl -s -H "Authorization: Bearer $LSTK_TOKEN" \ "https://prod.logistified.app/api/v1/purchase-orders?limit=50"# page 2 (cursor from the previous response)curl -s -H "Authorization: Bearer $LSTK_TOKEN" \ "https://prod.logistified.app/api/v1/purchase-orders?limit=50&cursor=2"Filtering
Section titled “Filtering”List endpoints accept query filters (e.g. status, supplierId, search, date
ranges, sku). See each endpoint in the reference for its supported
filters.