Skip to content

Conventions

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 the X-Request-Id response header. Quote it in support requests.
  • meta.apiVersion: the REST API version (1.5).
MethodUsed forSuccess
GETReading anything200
POSTCreating resources or child items201; with a Location header for a new purchase order, plan or promotional event
POSTLifecycle actions (/status, /receive, /complete, …)200
PATCHPartial updates to a header or a set of line items200
DELETEDeleting resources or items200 (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.

  • 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: poNumber on purchase orders, planType on plans, eventId / occurrenceId on 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:

Terminal window
# add ONE line item: still an array
curl -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:

Terminal window
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.

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 GET returns the header with counts, never the items. Page items with GET /{collection}/{id}/items: up to 250 per page, or 50 with includeCurves=true.
  • One item per PATCH. Item edits address one inventoryPlanItemId. Bulk item delete takes comma-separated itemIds in 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 null clears an optional header date; on an item, null on probability restores the header’s value, and null on 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: null restores revenue as units × price.

Units and formats:

WhatFormat
Planning dates (targetDate, launchDate, startDate, curve days, event dates, asOf)YYYY-MM-DD, a UTC day
Timestamps (createdAt, updatedAt, …)ISO 8601, UTC
Prices and revenueShop currency; the response carries currencyCode
probabilityFraction from 0 to 1
Share fields (sharePercent, prelaunchSharePercent)0 to 100
attainmentPercent, revenueAttainmentPercentActual ÷ planned × 100, one decimal; above 100 when sales beat the plan, null when nothing is planned
Promotional defaultUplift, upliftFraction from 0 to 10: 0.35 = +35%
growthFactor, volumeMultiplier on last year’s sales (growthFactor) or on the similar products’ sales (volume): 1 = the same, 1.2 = +20%
New-product curve1 to 180 daily units from the row’s launch date, rounded to 2 decimals
coverageFromDayZero-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.

This is the single most common mistake, so it’s worth stating plainly:

EndpointNumbers mean
/receiveThe new total received for that line. Sending 30 twice leaves it at 30, not 60.
/confirmThe new total confirmed for that line. 0 un-confirms.
/unreceiveHow 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.

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 shopifySync key at all; that’s not a failure.
  • shopifySync.status:
    • queued: accepted for delivery to Shopify. Not yet confirmed applied; poll GET /purchase-orders/{id} if you need certainty.
    • disabled: your shop has auto-sync-on-receiving switched off. Nothing reaches Shopify until you call POST /purchase-orders/{id}/shopify-sync.
    • skipped: nothing needed syncing.
    • failed: it did not go through. A recovery field on the response tells you what to do (usually: POST …/shopify-sync/reset, then POST …/shopify-sync).
  • outcome: "failed" on any other slot works the same way; the order changed, the follow-on didn’t, and recovery says 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: see error; recovery says what to do.
  • supplierUpdate is the one slot that isn’t about Shopify: it appears only on PATCH …/line-items with updateSupplier: true and 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.

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.

Terminal window
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 created
curl -s -X POST ".../api/v1/purchase-orders" -H "Idempotency-Key: $KEY" … -i
# → 201, byte-identical body, plus: Idempotent-Replayed: true

What each situation gives you:

You send…You get
The same key with the same requestThe original response, byte for byte (same status, same body, same meta.requestId) plus the header Idempotent-Replayed: true
The same key with a different request400 with "use a new key". Keys are one per request, not one per session
The same key while the first attempt is still running400 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.0 vs 1 don’t matter; the order of items in an array does.
  • Only final answers are stored, successes and 4xx client errors. A 429, any 5xx and any error with retryable: true (such as TARGET_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 stored 5xx is UNSYNC_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 GET is harmless; it’s simply ignored.
  • Browser clients: Idempotency-Key is an allowed request header and Idempotent-Replayed is an exposed response header, so cross-origin calls work.

Some endpoints are naturally safe to repeat; some aren’t. If you don’t send a key:

EndpointSafe to repeat?
POST /purchase-ordersNo: 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/customNo: you’ll add duplicate lines
PATCH …/line-itemsYes: the values are absolute
PATCH …/line-items/schedulesYes: the schedule you send replaces the stored one
DELETE …/line-itemsYes
POST …/statusNo: the second call 400s (that status is no longer a valid move)
POST …/confirm, …/receiveYes: the totals are absolute, so they converge
POST …/unreceiveDepends: see the warning below
POST …/completeYes: the second call 400s, the order stays Completed
POST …/quick-completePartly: on a failure the receipts are kept; retry is fine
POST …/shopify-syncYes: already-synced lines are skipped
POST …/shopify-sync/resetYes

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.

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, with purchaseOrderId and purchaseOrderNumber. Keep these ids.
  • failed[]: the buckets that weren’t created, by supplierId (commitments) or locationId (new product plans), with a code and message.
  • linkFailures[]: orders that were created but couldn’t be linked to the plan (purchaseOrderId, code). The transfer-order action reports this as a single linkFailure.

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 out lineCount and suggestedQuantity, because the saved line count isn’t known.
  • ORDER_METADATA_MISSING in failed[] 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.
  • 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: supplierIds for a commitment, locationIds for a new product plan. Never include a bucket that already has an order, has warnings or failed with ORDER_METADATA_MISSING.
  • After a 5xx or 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.
WriteRetry without a key
Plan, event, occurrence, option and size-group createsCan create a second resource, or be refused as a duplicate. Use a key
Item and location adds, status changesThe second call can answer differently (skipped items, “already Confirmed”). Use a key
Header, item and option PATCH, /forecast-feed, /growthThe values are absolute, so a repeat converges. Re-read after an unclear failure
Size-group DELETERemoves 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

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, so limit=15 returns up to 20. Planning item pages allow up to 250, or 50 with includeCurves=true; forecast-view rows allow up to 1000. A larger limit on a planning read returns 400.
  • cursor: an opaque page token from the previous response. A planning item-page cursor is tied to its plan and its locationId / variantId filters: 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: keep limit, the filters and sort the 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.

Terminal window
# page 1
curl -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"

List endpoints accept query filters (e.g. status, supplierId, search, date ranges, sku). See each endpoint in the reference for its supported filters.