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.5" }}meta.requestId: also returned as theX-Request-Idresponse header. Quote it in support requests.meta.apiVersion: the REST API version (1.5).
Methods and status codes
Section titled “Methods and status codes”| Method | Used for | Success |
|---|---|---|
GET | Reading anything | 200 |
POST | Creating resources or child items | 201; with a Location header for a new purchase order, plan or promotional event |
POST | Lifecycle actions (/status, /receive, /complete, …) | 200 |
PATCH | Partial updates to a header or a set of line items | 200 |
DELETE | Deleting resources or items | 200 (with a body, never an empty 204) |
Creates of child items (line items, plan items, locations, size groups, occurrences, planning
options) answer 201 without a Location. POST /purchase-order-address-profiles answers 200,
because the same call also updates an existing profile.
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. Ids come from the path (the purchase-order id, the plan id, the item id, …).
You may repeat one in the body, but if it differs from the path you get a
400; that combination always means a bug in the caller. - System-owned fields are refused, not ignored:
poNumberon purchase orders,planTypeon plans,eventId/occurrenceIdon promotional-event creates. Numbers and ids are always generated for you.
Purchase-order line-item endpoints are bulk
Section titled “Purchase-order line-item endpoints are bulk”Every purchase-order line-item endpoint takes (or returns) a list, even for one item:
# 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.
Inventory planning bodies and units
Section titled “Inventory planning bodies and units”Each plan collection fixes the plan type: /inventory-commitments, /new-product-plans and
/sales-plans take different bodies, and none of them accepts planType.
- Strict, per-collection bodies. A field that belongs to another plan type, an unknown or
internal field, or a string sent where a number or boolean is expected returns
400. - Not found is generic. A missing or deleted plan, an id from another collection and an item
or child of another plan all return the same
404 NOT_FOUND. - Lean reads. A plan
GETreturns the header with counts, never the items. Page items withGET /{collection}/{id}/items: up to 250 per page, or 50 withincludeCurves=true. - One item per
PATCH. Item edits address oneinventoryPlanItemId. Bulk item delete takes comma-separateditemIdsin the query and ignores any body. - Write results are lean too. Creates return the ids, numbers, counts and warnings the write produced; order actions return the created orders’ ids and numbers. Read the resource separately when you need its full state.
- Clearing values. On a commitment, an explicit
nullclears an optional header date; on an item,nullonprobabilityrestores the header’s value, andnullon a date clears that date: the item takes the header’s dates only once none of its own dates remain (a lone start or end is a one-day window). Leaving a field out keeps it. A sales-plan window can’t be cleared. On a sales-plan item,revenueCurve: nullrestores revenue as units × price.
Units and formats:
| What | Format |
|---|---|
Planning dates (targetDate, launchDate, startDate, curve days, event dates, asOf) | YYYY-MM-DD, a UTC day |
Timestamps (createdAt, updatedAt, …) | ISO 8601, UTC |
| Prices and revenue | Shop currency; the response carries currencyCode |
probability | Fraction from 0 to 1 |
Share fields (sharePercent, prelaunchSharePercent) | 0 to 100 |
attainmentPercent, revenueAttainmentPercent | Actual ÷ planned × 100, one decimal; above 100 when sales beat the plan, null when nothing is planned |
Promotional defaultUplift, uplift | Fraction from 0 to 10: 0.35 = +35% |
growthFactor, volume | Multiplier on last year’s sales (growthFactor) or on the similar products’ sales (volume): 1 = the same, 1.2 = +20% |
New-product curve | 1 to 180 daily units from the row’s launch date, rounded to 2 decimals |
coverageFromDay | Zero-based day index into the curve, not a date |
Forecast switches: a new product plan adds demand only when its header feedsForecast is true
(editable in Draft and Confirmed) and includedInForecast is true; includedInForecast is
the pause you set with POST …/forecast-feed, which also works while a plan is Locked. A
promotional event starts with feedsForecast: false; switch it on with PATCH.
Planning refusals are 400 INVALID_INPUT with a stable marker in error.data.code, for example
EMPTY_PLAN or DUPLICATE_NAME. A body that fails schema validation can come back with
error.data.issues and no marker. The full list is on the
Errors page.
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”Purchase-order write responses can 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.incoming.outcome:enqueued: the incoming quantity was queued for Shopify (not yet confirmed applied), or it was already up to date.deferred: it runs after the response, so this answer can’t say how it went.skipped: your shop has Enable Incoming Inventory Tracking off, or the order has no location to be incoming to. Nothing is sent to Shopify.failed: seeerror;recoverysays what to do.
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 to recover its stored final response. Expiry and storage failures limit replay, as described below.
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. A429, any5xxand any error withretryable: true(such asTARGET_BUSY) are not stored, so retrying really re-runs the request. That’s what you want: those are the failures where the outcome is genuinely unknown or can change. The one stored5xxisUNSYNC_FAILED: that write was already reversed in Shopify, so it is replayed rather than run twice. - Keys last 24 hours. After that the same key is treated as new.
- If saving the response fails, the key can remain in progress for five minutes, then the request can execute again. Read the resource before retrying an ambiguous create or order action.
- 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 |
PATCH …/line-items/schedules | Yes: the schedule you send replaces the stored one |
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 |
Planning create and order retries
Section titled “Planning create and order retries”Send an Idempotency-Key on every planning create and every order action
(POST …/purchase-orders, POST …/transfer-orders). Without one, a retry after a lost response
can create a second plan, event or order.
Reading a partial order result
Section titled “Reading a partial order result”POST /inventory-commitments/{id}/purchase-orders creates one purchase order per supplier and
POST /new-product-plans/{id}/purchase-orders one per location (or one combined order). Once the
first order exists, a later failure doesn’t turn the call into an error: you get 200 with
purchaseOrders[]: the orders that were created, withpurchaseOrderIdandpurchaseOrderNumber. Keep these ids.failed[]: the buckets that weren’t created, bysupplierId(commitments) orlocationId(new product plans), with acodeandmessage.linkFailures[]: orders that were created but couldn’t be linked to the plan (purchaseOrderId,code). The transfer-order action reports this as a singlelinkFailure.
If the very first order fails, nothing was created and the call returns an error.
Three cases need care:
- An entry with
warnings(an array of strings) means the order header exists but its lines may not all have been saved. The order stays created and linked. Open it and check its lines; never create that supplier or location again. New-product entries with warnings leave outlineCountandsuggestedQuantity, because the saved line count isn’t known. ORDER_METADATA_MISSINGinfailed[]means the order was created but no usable id came back. Look for it among your purchase orders and leave that bucket out of any retry.- A link failure means the order exists. Keep its id and contact support: there is no relink endpoint, and creating a replacement would duplicate the order.
Retrying
Section titled “Retrying”- To get the same answer again, replay the exact request with the same key. You get the stored
result, warnings and unresolved failures included, with
Idempotent-Replayed: true. - To try the failed buckets again, send a new request with a new key that names only the
safe failed buckets:
supplierIdsfor a commitment,locationIdsfor a new product plan. Never include a bucket that already has an order, has warnings or failed withORDER_METADATA_MISSING. - After a
5xxor a lost response, the order action may already have committed. Read the plan (purchaseOrderIds,linkedOrders) and your existing purchase orders before retrying. The storage limits above apply here too: a key can’t replay an answer that was never stored.
Other planning writes without a key
Section titled “Other planning writes without a key”| Write | Retry without a key |
|---|---|
| Plan, event, occurrence, option and size-group creates | Can create a second resource, or be refused as a duplicate. Use a key |
| Item and location adds, status changes | The second call can answer differently (skipped items, “already Confirmed”). Use a key |
Header, item and option PATCH, /forecast-feed, /growth | The values are absolute, so a repeat converges. Re-read after an unclear failure |
Size-group DELETE | Removes the whole family; a repeat can return 404 |
/rebuild, /apply-mix, /analyze… | Use current history and the current UTC day, so a repeat can differ. Use a key |
Pagination
Section titled “Pagination”List endpoints accept:
limit: page size. Lists allow up to 50. The purchase-order list and the three plan lists (inventory commitments, new product plans, sales plans) round it up to 10, 20 or 50 rows, solimit=15returns up to 20. Planning item pages allow up to 250, or 50 withincludeCurves=true; forecast-view rows allow up to 1000. A largerlimiton a planning read returns400.cursor: an opaque page token from the previous response. A planning item-page cursor is tied to its plan and itslocationId/variantIdfilters: send it back with the same filters, and URL-encode it (it can contain+,/and=). On every other list the cursor is the next page number: keeplimit, the filters andsortthe same on every page, and start again from the first page after a write that adds, removes or re-sorts rows (deleting the rows of page 1 and then asking for page 2 skips the rows that moved up into page 1).
and return them inside data:
{ "data": { "purchaseOrders": [ /* … */ ], "totalCount": 128, "cursor": "3" }, "meta": { "requestId": "…", "apiVersion": "1.5" }}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.