Tutorial 07

Receive a delivery end to end

Draft a purchase order, publish it, register what the supplier dispatched, process it into stock, then complete it. Covers the entity_id handle, the two identifiers, and the webhook that only fires at the end.

advanced ·22 min read ·curlPython ·Updated 6 Sep 2026

Goods arriving is not one event. A purchase order is drafted, published to the supplier, dispatched, and only then applied to stock. Each of those is a separate call, and the last one is asynchronous.

This walks the whole path, and finishes with a webhook so your integration is told when stock actually moves instead of polling for it.

1. Find the supplier

Purchase orders are always against a supplier, and a product has to be linked to that supplier before it can be ordered from them.

GET /suppliers
curl -s https://api.stockpilot.dev/suppliers \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"

Keep the id. If a product you want is not on that supplier yet, POST /suppliers/{supplier_id}/products/add links it.

2. Draft the order

POST /purchase-orders
curl -s -X POST https://api.stockpilot.dev/purchase-orders \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "supplier_id": 123,
    "items": [
      {"sku": "WIDGET-001", "quantity": 10, "purchase_price": 25.50},
      {"barcode": "123456789", "quantity": 5}
    ],
    "shipping_cost": 15.50
  }'
{
  "draft_id": "1042",
  "cart_id": "1042",
  "supplier_id": 123,
  "status": "DRAFT",
  "total_amount": 315.50,
  "items_count": 2,
  "delivery_warehouse_id": null,
  "created_at": "2026-02-19T14:30:00Z"
}

Three things to take from that response:

  • cart_id is a string, and draft_id is the same value.
  • There is no purchase_order_id yet. The purchase order does not exist until you publish.
  • delivery_warehouse_id is null. Publish sets it.

A draft is not an order. Nothing has been sent to the supplier, and these quantities are not counted in the product’s incoming_quantity. That is the point: you can build the order up over several calls, or park it for someone to approve.

Only supplier_id, items and shipping_cost are accepted here. order_note, expected_delivery, processed_by and delivery_warehouse_id belong to the purchase order rather than the draft, so they are set on publish. Sending them now does nothing at all, silently.

Identify lines by sku or barcode, never both on one line. You can mix the two across lines, which means a scanner app can send whatever it read without normalising first.

3. Publish it

POST /purchase-orders/{cart_id}/publish
curl -s -X POST https://api.stockpilot.dev/purchase-orders/1042/publish \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "order_note": "Weekly replenishment",
    "expected_delivery": "2026-02-28T10:00:00Z",
    "delivery_warehouse_id": 3,
    "send_email": true,
    "supplier_message": "Please confirm delivery date"
  }'
{
  "purchase_order_id": 48,
  "cart_id": "1042",
  "order_number": "PO-2026-001",
  "status": "ORDERED",
  "total_amount": 315.50,
  "delivery_warehouse_id": 3,
  "email_sent": true,
  "expected_date": "2026-02-28T10:00:00Z"
}

This is the call that creates the purchase order. Status becomes ORDERED, the lines start counting towards incoming_quantity, and you finally get the numeric purchase_order_id that every later call needs.

send_email must be the literal boolean true. A truthy string will not do, and the default is not to email at all, because sending is an outward action that cannot be undone.

A failed send does not fail the publish. The order still exists at ORDERED, email_sent comes back false, and the failure is recorded on the order’s activity log. Check the field rather than assuming.

Publishing twice returns 409. The body is optional, so publishing an untouched draft with {} is fine.

4. Register what the supplier dispatched

When the delivery turns up, register it as a parcel. One purchase order can have as many parcels as it takes to arrive in full.

POST /purchase-orders/{order_id}/parcels/create
curl -s -X POST https://api.stockpilot.dev/purchase-orders/48/parcels/create \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{"sku": "WIDGET-001", "quantity": 4}],
    "reference": "PACKSLIP-88213"
  }'

Omit items entirely, or send {}, and everything still outstanding is taken. A quantity cannot exceed what is outstanding on that line, and a product may only appear once.

Read remaining_quantity from GET /purchase-orders/48 first and pre-fill from that. It is the difference between a form that works and one that throws Item 1: cannot receive 11, only 6 remaining for WIDGET-001.

Only one parcel can be in flight per purchase order, including one a warehouse user started in the Stockpilot UI seconds ago. A 409 tells you so, and carries blocking_parcel_id and dispatched_lines.

Surface it as “a delivery is currently being processed, try again shortly” rather than a generic failure, and do not retry in a tight loop.

5. Process it into stock

Registering a parcel and applying it to stock are separate steps. This is the one that moves quantities.

POST /purchase-orders/{order_id}/parcels/{parcel_id}/process
curl -s -X POST https://api.stockpilot.dev/purchase-orders/48/parcels/603767/process \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"

No request body. Note the parcel_id is the six character string from parcel create, not the index_number.

{
  "status": "queued",
  "entity_id": "9f2c8e1a-5b7a-4e6f-9c22-0d4b1a8e3f70",
  "purchase_order_id": 48,
  "cart_id": "1042",
  "parcel_id": "603767"
}

Persist the entity_id before you do anything else. A 202 acknowledges the queue and nothing more: no stock has moved, and the response carries no quantities. That value is the only handle on the result, and without it the status endpoint returns 400.

Unlike parcel create, there is no synchronous mode here. It is always queued.

Errors are specific enough to act on without a second lookup:

CodeMeaning
400The parcel has no dispatched quantities
404No such purchase order or parcel on this account
409The parcel has already been processed
500Queueing failed, the parcel is left at ERROR and the call can be retried

400 and 409 both answer with error, parcel_id and status, so you can tell “already done” from “nothing to do” immediately.

6a. Collect the result by polling

GET /purchase-orders/{order_id}/parcels/{parcel_id}/status
curl -s "https://api.stockpilot.dev/purchase-orders/48/parcels/603767/status?entity_id=9f2c8e1a-5b7a-4e6f-9c22-0d4b1a8e3f70" \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"

A 202 means still running:

{
  "entity_id": "9f2c8e1a-5b7a-4e6f-9c22-0d4b1a8e3f70",
  "parcel_id": "603767",
  "status": "PROCESSING",
  "applied": false,
  "message": "Parcel is still being processed."
}

A 200 means the stock has moved:

{
  "purchase_order_id": 48,
  "entity_id": "9f2c8e1a-5b7a-4e6f-9c22-0d4b1a8e3f70",
  "parcel": {"parcel_id": "603767", "index_number": 1, "status": "COMPLETED"},
  "status": "COMPLETED",
  "applied": true,
  "processed_items": 2,
  "failed_items": 0,
  "failed": [],
  "totals": {"total_ordered": 5, "total_delivered": 2, "total_remaining": 3, "fully_delivered": false}
}

Read failed_items and failed. A parcel can partly succeed, and this endpoint is the only thing that will tell you which lines did not apply. Re-reading the purchase order shows you the totals but not the failures.

import time

def wait_for_parcel(client, order_id, parcel_id, entity_id, timeout=120):
    deadline, delay = time.time() + timeout, 1.0
    while time.time() < deadline:
        r = client.get(f"/purchase-orders/{order_id}/parcels/{parcel_id}/status",
                       params={"entity_id": entity_id})
        if r.status_code == 200:
            result = r.json()
            if result["failed_items"]:
                raise PartialDelivery(result["failed"])
            return result
        time.sleep(delay)
        delay = min(delay * 1.6, 15.0)
    raise TimeoutError(entity_id)

6b. Or let a webhook tell you

Polling tells you when the stock moved. A webhook tells you when the delivery is finished, which is not the same moment.

POST /webhooks/create
curl -s -X POST https://api.stockpilot.dev/webhooks/create \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "parcel applied to stock",
    "event": "purchase_orders.parcel_received",
    "target_url": "https://ops.example.com/hooks/parcels",
    "secret": "'"$WEBHOOK_SECRET"'"
  }'

Its payload carries the parcel: id, parcel_id, index_number, status, supplier_reference, and a nested purchase_order with id, purchase_order_id and status.

purchase_orders.parcel_received fires on step 7, not step 5. Processing moves the stock, but the event is emitted when the parcel is marked complete. A consumer that subscribes and skips the completion call waits forever.

Use the webhook to learn that a delivery finished, not as a replacement for driving the flow to its end.

Subscribe to one event per webhook. If you also want to know when the whole order is in, add a second subscription to purchase_orders.received, which fires when the purchase order reaches DELIVERED.

A handler that closes the loop, verifying the signature over the raw bytes as always:

import base64, hashlib, hmac, json, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()

@app.post("/hooks/parcels")
async def parcel_received(request: Request):
    raw = await request.body()
    digest = hmac.new(SECRET, raw, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    if not hmac.compare_digest(request.headers.get("X-Stockpilot-Signature", ""), expected):
        raise HTTPException(401, "bad signature")

    event = json.loads(raw)
    if seen_before(event["delivery_id"]):     # at-least-once delivery
        return {"ok": True}

    parcel = event["data"]
    close_receiving_task(
        purchase_order_id=parcel["purchase_order"]["id"],
        parcel_id=parcel["parcel_id"],
    )
    return {"ok": True}

Two transitions in quick succession can deliver twice with the same status. The payload is built at delivery time, so the second one has already caught up. That is a real second transition, not a duplicate. Dedupe on delivery_id, never by comparing statuses.

Test the endpoint before you rely on it, with POST /webhooks/{webhook_id}/test and GET /webhooks/{webhook_id}/deliveries . Receive your first webhook covers signature verification in full.

7. Complete the parcel

Receiving is three calls, not two. Processing moved the stock; completing closes the delivery.

POST /purchase-orders/{order_id}/parcels/{parcel_id}/complete
curl -s -X POST https://api.stockpilot.dev/purchase-orders/48/parcels/603767/complete \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"

No request body. The split is deliberate: the Stockpilot UI separates processing a delivery from marking it complete, and the API mirrors that.

A parcel sitting at PROCESSING after a successful poll is waiting for this call, not stuck. That is the single most likely reason a delivery looks half finished.

Order matters. Completing a parcel that still has dispatched quantities is a 409: process it first. Completing one that is already COMPLETED is also a 409, and nothing changes either way.

The response carries the parcel at its new status plus the purchase order’s totals, so you can tell whether this delivery finished the order without another call. Read fully_delivered.

8. Move the order on

A purchase order does not advance itself. When the goods are in, move it:

POST /purchase-orders/{cart_id}/update-status
curl -s -X POST https://api.stockpilot.dev/purchase-orders/1042/update-status \
  -H "X-CLIENT-ID: $SP_CLIENT_ID" \
  -H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"status": "DELIVERED"}'

This takes the cart_id string, not the numeric purchase_order_id. The parcel routes above take the numeric id. Two identifiers, two shapes, adjacent calls. Getting it wrong is a 404.

Statuses are ORDERED, SHIPPED, CONFIRMED, DELIVERED and COMPLETED. Transitions are not enforced: you can jump straight to DELIVERED or go backwards, because the endpoint mirrors what the UI allows. If your process needs a strict flow, enforce it on your side.

Setting a status the order already has is accepted and changes nothing. Compare previous_status with status in the response to tell a real transition from a no-op.

Two of the five do more than set a field:

StatusAlso does
DELIVEREDStamps delivered_at and distributes landed costs over the received units
COMPLETEDStamps delivered_at, recalculates order_total from what was actually delivered, closes every open parcel, and stops the order counting towards incoming stock

On a partial delivery, COMPLETED rewrites order_total from delivered quantities times purchase price, so it will not match what you saw when the order was placed. Anything caching the ordered total has to refresh after this call.

Drafts have no status to change and answer 404. Publish first.

9. Know when it is actually done

Do not read status to decide whether goods arrived. Nothing advances it for you: a fully delivered purchase order sits at ORDERED until you or a person moves it with the call above. delivered_date follows status too, so it stays null until then.

Read totals.fully_delivered and totals.total_remaining for the physical question, and treat status as the bookkeeping one.

order = client.get(f"/purchase-orders/{order_id}").json()
if order["totals"]["fully_delivered"]:
    ...

Also do not poll parcel.status after creating a parcel. It sits at PROCESSING and is not a completion signal. Use the status endpoint with your entity_id, or the webhook.

The whole thing

def receive_delivery(client, supplier_id, lines, warehouse_id):
    draft = client.post("/purchase-orders", json={
        "supplier_id": supplier_id,
        "items": lines,
    }).json()

    order = client.post(f"/purchase-orders/{draft['cart_id']}/publish", json={
        "order_note": "Weekly replenishment",
        "delivery_warehouse_id": warehouse_id,
        "send_email": True,
    }).json()

    if not order["email_sent"]:
        alert(f"PO {order['order_number']} published but not emailed")

    order_id = order["purchase_order_id"]

    parcel = client.post(f"/purchase-orders/{order_id}/parcels/create",
                         json={"reference": "PACKSLIP-88213"}).json()

    queued = client.post(
        f"/purchase-orders/{order_id}/parcels/{parcel['parcel_id']}/process"
    ).json()

    persist(order_id, queued["entity_id"])   # before anything else

    result = wait_for_parcel(client, order_id,
                             parcel["parcel_id"], queued["entity_id"])

    # stock has moved; the delivery is not closed until this runs
    client.post(f"/purchase-orders/{order_id}/parcels/{parcel['parcel_id']}/complete")

    if result["totals"]["fully_delivered"]:
        client.post(f"/purchase-orders/{draft['cart_id']}/update-status",
                    json={"status": "DELIVERED"})   # cart_id here, not order_id

    return result

Writing down the entity_id the moment you receive it is what makes a crashed run recoverable. Without it there is no way to ask what happened to that parcel.

The same flow from the terminal

Most of it has a CLI command, and parcels process --wait does the polling for you:

stockpilot purchase-orders create --supplier-id 123 \
  --item sku=WIDGET-001,quantity=10,purchase_price=25.50

stockpilot purchase-orders publish 1042 \
  --order-note "Weekly replenishment" \
  --delivery-warehouse-id 3 --send-email

stockpilot purchase-orders parcels create 48 --item sku=WIDGET-001,quantity=4
stockpilot purchase-orders parcels process 48 603767 --wait

--wait polls until the stock has been applied, with --interval and --timeout to tune it. Without it you get the entity_id back and check later with parcels status 48 603767 --entity-id ....

Landed costs go on the same order once you know them:

stockpilot purchase-orders costs add 1042 --type FREIGHT --amount 80.00
stockpilot purchase-orders costs distribute 1042

Steps 7 and 8 have no CLI command yet. Marking a parcel complete and moving the order’s status are API only for now, so a terminal driven flow still needs those two calls made directly.

Watch the identifiers as you switch between them: create, publish, costs and note take the cart id (1042), while parcels takes the numeric order id (48).

A Stockpilot engineer talking through an integration with a developer

Stuck on something

Ask a person, not a search box

The API surface is wide, and some of it only makes sense once someone explains why it works that way. If a payload is not doing what you expect, or you are weighing two approaches, say so and we will look at it with you.