Guide
Purchasing and inbound deliveries
Suppliers, drafts and publishing, forecast driven recommendations, and booking goods in with parcels. Includes the two fields that actually tell you whether stock arrived, and why status is not one of them.
Purchase orders track what you asked a supplier for and what has physically turned up. Those two move independently, and reading one as the other is the most common bug here.
Suppliers
GET /purchase-orders/suppliers/list lists who you can order from. Recommendations are generated per supplier, because lead time and minimum order quantities are supplier properties.
Recommendations
Stockpilot already knows your sales velocity, lead times and inbound stock, so ask it rather than recomputing:
POST /purchase-orders/recommendations{
"supplier_id": 312,
"lead_time": 21,
"durability": 60,
"scope_days": 90,
"include_flagged": true,
"include_inbound": true
}
| Field | Meaning |
|---|---|
lead_time | Days between raising the order and stock landing |
durability | Days of cover the resulting order should provide |
scope_days | How much sales history to base the forecast on |
include_flagged | Include products flagged for review |
include_inbound | Subtract stock already on its way |
You get a task_id, not results. Poll GET /purchase-orders/recommendations/status/{task_id}
until it reports COMPLETED or FAILED, backing off between attempts. This route is in the heavy rate limit bucket at 20 per minute.
Recommendations are the one place in purchasing that uses a task_id. The parcel flow uses an entity_id instead, and the two are not interchangeable.
Drafts and publishing
Raising an order is two calls, and the split matters.
POST /purchase-orders
creates a draft. A draft is not yet an order: nothing is sent to the supplier, and its quantities do not count towards a product’s incoming_quantity. Use one to assemble an order over several calls, or to let someone approve it before it goes out.
It returns a cart_id, and draft_id is the same value. Both are strings. There is no purchase_order_id yet, because the purchase order does not exist until you publish.
Only supplier_id, items and shipping_cost are accepted at draft time. order_note, expected_delivery, processed_by and delivery_warehouse_id belong to the purchase order, so they are set on publish. Sending them to the draft has no effect.
Lines are identified by sku or barcode, and you can mix identifier types in one request. A warehouse app can send whatever the scanner produced without normalising first. The product must be active and linked to that supplier, and minimum order quantities are applied automatically.
POST /purchase-orders/{cart_id}/publish
turns the draft into a real purchase order at status ORDERED, starts the lines counting towards incoming_quantity, and returns the numeric purchase_order_id you need for everything downstream.
The body is optional and every field in it is optional. Publishing an untouched draft with no body at all is a valid call.
Publishing does not email the supplier unless you ask. Send "send_email": true, the literal boolean, and the order sheet goes to the supplier’s order_email with your supplier_message in the body.
A failed send does not fail the publish: the order still exists at ORDERED, email_sent comes back false, and the failure lands on the order’s activity log. Asking to email a supplier with no order_email is rejected up front with a 400 and nothing is published.
A draft can only be published once. A second call returns 409. An unpublished draft can be removed with POST /purchase-orders/{cart_id}/delete
, and drafts are listed with GET /purchase-orders?status=DRAFT.
Neither create nor publish is idempotent. On an ambiguous failure, list purchase orders and check whether it already landed before resending.
Landed costs and notes
POST /purchase-orders/{cart_id}/update
is one endpoint with four actions. action decides which other fields are read, and anything else in the body is ignored.
action | Send | Does |
|---|---|---|
add_cost | cost_type, amount, optionally description and reference | Records a landed cost and logs it |
remove_cost | cost_id | Deletes that cost and logs it |
add_note | message, optionally msg_type | Writes a note to the activity log |
distribute_costs | nothing | Spreads recorded costs over the units received so far |
cost_type is one of FREIGHT, DUTIES, CUSTOMS, INSURANCE, HANDLING or OTHER, and amount must be above zero.
Adding a cost changes nothing on its own. distribute_costs is what divides the total over the units actually received and writes each product’s landed_cost_per_unit. Add every cost first, then distribute once.
Shipping paid at publish counts towards the total alongside anything added here. Notes cap at 280 characters, and msg_type is a free label for grouping log entries.
Every action returns the order’s current state whichever one you sent: landed_costs with their cost_ids, landed_cost_total, documents, and the 20 most recent activity entries. So a write reads back without a second call, and the cost_id you need for a later remove_cost comes from here.
From the terminal:
stockpilot purchase-orders costs add 1042 --type FREIGHT --amount 80.00 \
--description "Air freight" --reference CARRIER-INV-4471
stockpilot purchase-orders costs distribute 1042
stockpilot purchase-orders note 1042 --message "Two cartons short, supplier notified"
Note the cart_id again, not the numeric order id.
Reading delivery progress
GET /purchase-orders
carries total_delivered, total_remaining and fully_delivered on every row, which is enough to render a progress bar without a detail fetch per order.
It filters on status, supplier_id, search (cart ID or supplier name) and created_after / created_before, which are dates and inclusive on both boundaries: an order created at any time on created_before is included.
Sort with created_at, updated_at, expected_date, delivered_date, order_total, status, cart_id or supplier, - prefixed for descending. With ?status=DRAFT only created_at, updated_at, order_total, cart_id and supplier are accepted, because a draft has no purchase order behind it and the other three fields do not exist on one. Sorting a draft list by expected_date is a 400.
Listing drafts
?status=DRAFT returns drafts in the same envelope as any other row, with a few fields necessarily empty:
idisnull. Usecart_idas the identifier, and pass it to publish or delete.processed_by,expected_dateanddelivered_datearenull, since publish sets them.total_deliveredis0andfully_deliveredisfalse.
supplier, order_total, shipping_total, items_count and total_units are populated as usual, so a draft renders alongside published orders.
status is not a delivery signal. Nothing advances it for you. A fully delivered purchase order sits at ORDERED until someone moves it, in the app or with the call below.
To tell whether goods arrived, read totals.fully_delivered and totals.total_remaining. Never status.
delivered_date follows status too, so it stays null on a fully delivered order until then.
Moving the status yourself
POST /purchase-orders/{cart_id}/update-status
takes one of ORDERED, SHIPPED, CONFIRMED, DELIVERED or COMPLETED.
It takes the cart_id string, while the parcel routes take the numeric purchase_order_id. Two identifiers on adjacent calls, and the wrong one is a 404.
Transitions are not enforced. You can jump to DELIVERED or move backwards, because the endpoint mirrors what the UI allows. Enforce a strict flow on your side if you need one. Re-setting the current status is accepted and does nothing; compare previous_status with status to spot a real transition.
DELIVERED stamps delivered_at and distributes landed costs over the received units. COMPLETED does that, recalculates order_total from what was actually delivered, closes every open parcel and stops the order counting towards incoming stock. On a partial delivery that total will not match the one you saw at publish, so refresh anything caching it.
Every change fires purchase_orders.status_changed. DELIVERED also fires purchase_orders.received, and COMPLETED fires parcel events for each parcel it closes.
Drafts have no status and answer 404.
On GET /purchase-orders/{order_id} , each line carries four quantities:
| Field | Meaning |
|---|---|
delivered_quantity | Cumulative quantity actually delivered into stock |
remaining_quantity | Still outstanding, ordered minus delivered, floored at zero |
dispatched_quantity | Claimed by an in-flight parcel, not yet applied to stock |
invoiced_quantity | Quantity on the supplier invoice |
inbound_tracking is an object, not a list. It rolls the order up into total_ordered, total_delivered, total_remaining and total_parcels, then carries an items array per product and a parcels array of every delivery registered. inbound_tracking.parcels is where you get a parcel_id.
Booking in a delivery
POST /purchase-orders/{order_id}/parcels/createAsynchronous by default, returning 202. A 202 means the parcel was queued and the stock has not moved yet: applied is false, delivered_quantity is still the pre-parcel number, and the projected_delivered_quantity and projected_remaining_quantity fields are optimistic UI values rather than facts.
What you need from the 202 is the parcel_id, which the next call takes:
{
"purchase_order_id": 48,
"cart_id": "1042",
"applied": false,
"parcel": {"parcel_id": "748219", "index_number": 3, "status": "PROCESSING"},
"totals": {"projected_total_delivered": 4, "projected_fully_delivered": false}
}
Do not poll parcel.status. It stays PROCESSING and is not a completion signal. Re-fetch GET /purchase-orders/{order_id} instead and read the confirmed quantities.
Pass "async": false and the request blocks until stock has actually moved, returning 200 with applied: true and real quantities. It is slower, so prefer async for large orders.
The flow that works:
GET /purchase-orders/{order_id}and pre-fill the parcel inputs fromremaining_quantity.POST .../parcels/createwith the adjusted quantities.- On
202, show theprojected_*numbers in a processing state. - Re-fetch the purchase order a few seconds later for confirmed
delivered_quantity. fully_deliveredturnstrueonce nothing is outstanding.
Delivering goods does not change the purchase order’s status. See above.
The three step delivery
Registering what the supplier dispatched, applying it to stock, and closing the delivery are three separate calls.
POST /purchase-orders/{order_id}/parcels/create
registers the parcel. POST /purchase-orders/{order_id}/parcels/{parcel_id}/process
moves a parcel sitting at DISPATCHED into stock. That second call is what actually changes quantities, it takes no request body, and unlike parcel create it has no synchronous mode.
It answers 202 with an entity_id. Keep it: this is the same queue and collect shape as requesting a shipping label, which hands back an entity_id too.
{
"status": "queued",
"entity_id": "9f2c8e1a-5b7a-4e6f-9c22-0d4b1a8e3f70",
"purchase_order_id": 52,
"cart_id": "000001",
"parcel_id": "603767"
}
parcel_id is the six character string from parcel create, not the index_number.
You then collect the outcome one of two ways:
- Poll GET /purchase-orders/{order_id}/parcels/{parcel_id}/status
with
?entity_id=...until it answers200. It also reports which lines failed, which re-reading the purchase order cannot tell you. Omittingentity_idis a400. - Or subscribe to
purchase_orders.parcel_receivedand skip polling entirely.
A 202 from the status endpoint means still running. A 200 carries applied: true, processed_items, failed_items, a failed array and the order totals.
Then close it with POST /purchase-orders/{order_id}/parcels/{parcel_id}/complete , no body.
purchase_orders.parcel_received fires on complete, not on process. A parcel sitting at PROCESSING after a successful poll is waiting for this call, not stuck, and a consumer waiting on that webhook will wait forever if it never runs.
Completing a parcel that still has dispatched quantities is a 409, as is completing one already COMPLETED. The response carries the parcel plus the order totals, so fully_delivered tells you whether this delivery finished the order.
Process errors are specific enough to act on without a second lookup: 400 when the parcel has no dispatched quantities, 409 when it was already processed, each answering with error, parcel_id and status. A 500 leaves the parcel at ERROR and the call can be retried.
Receive a delivery end to end walks the whole path.
When a parcel is stuck
A non-zero dispatched_quantity means a parcel is mid-flight. If it never clears, the parcel is stuck, and POST /purchase-orders/{order_id}/parcels/{parcel_id}/delete
releases it.
Do not reach for this on every 409. A 409 from parcels/create normally means a parcel really is in flight, quite possibly one a warehouse user started in the UI seconds ago. Removing it throws away their work.
- On
409, noteblocking_parcel_id, wait a few seconds, re-fetch the purchase order. - If
delivered_quantitymoved, the parcel completed. Nothing to remove. - If the same
dispatched_quantityvalues are still there well after the fact, the parcel never completed. Only then delete it. - Re-create the delivery.
Two more things about deletion. It does not reverse stock: only quantities never applied are released, anything a completed parcel already delivered stays delivered, and a COMPLETED parcel is refused with a 409. And the release is scoped to the purchase order, not the parcel: every line with a non-zero dispatched_quantity is reset, whichever parcel claimed it. In practice only one parcel is in flight at a time, so it is the same set, but do not use this to prune one of several pending parcels.
Removal is permanent. The parcel is deleted rather than marked failed, so it disappears from total_parcels and from the order’s parcel history.
