Tutorial 04
Build a low stock reorder bot
Find items running out, ask Stockpilot for purchase order recommendations, and draft the PO for a buyer to approve. Covers the async task pattern and the heavy rate limit bucket.
Stockpilot already knows your sales velocity, lead times and inbound stock. Rather than reimplementing that maths, you can ask it for recommendations and turn the answer into a purchase order.
Find what is running low
Start with the inventory list. Thresholds are expressed as either units (5u) or weeks of cover (33w), so an item is “low” relative to its own threshold rather than a global number.
def low_stock(client, page_size=1000):
page, out = 1, []
while True:
data = client.get("/inventory", params={"page": page, "page_size": page_size}).json()
out.extend(i for i in data["results"] if i["quantity"] <= threshold_units(i))
if len(data["results"]) < page_size:
return out
page += 1
One paginated call at page_size=1000 costs a single read-list request. Looping \/inventory\/get per SKU would cost hundreds of read-single requests for the same data, so prefer the list.
List your suppliers
GET /purchase-orders/suppliers/listcurl -s https://api.stockpilot.dev/purchase-orders/suppliers/list \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"
Recommendations are generated per supplier, because lead time and minimum order quantities are supplier properties.
Ask for recommendations
This is a long running job, so the API splits it into “start” and “poll”.
POST /purchase-orders/recommendationscurl -s -X POST https://api.stockpilot.dev/purchase-orders/recommendations \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"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 PO and stock landing |
durability | Days of cover the resulting order should provide |
scope_days | How far back to look when measuring demand |
include_flagged | Include items flagged for review |
include_inbound | Subtract stock already on its way |
You get a task_id back, not results.
Poll the task
GET /purchase-orders/recommendations/status/{task_id}import time
def wait_for_recommendations(client, task_id, timeout=180):
deadline, delay = time.time() + timeout, 2.0
while time.time() < deadline:
data = client.get(f"/purchase-orders/recommendations/status/{task_id}").json()
if data["status"] == "completed":
return data["results"]
if data["status"] == "failed":
raise RuntimeError(data.get("error", "recommendation task failed"))
time.sleep(delay)
delay = min(delay * 1.5, 15.0)
raise TimeoutError(task_id)
Both \/purchase-orders\/recommendations and the analytics endpoints sit in the heavy bucket: 20 requests per minute. Backing off between polls is not politeness, it is the difference between one integration and twenty of them coexisting on the same key. See rate limits.
Draft the purchase order
Raising an order is two calls. POST /purchase-orders
creates a draft, which is exactly what you want for a bot: nothing is sent to the supplier, and the quantities do not count towards incoming_quantity until someone publishes it.
lines = [
{"sku": r["sku"], "quantity": r["recommended_quantity"]}
for r in recommendations
if r["recommended_quantity"] > 0
]
draft = client.post("/purchase-orders", json={
"supplier_id": 312,
"items": lines,
}).json()
print("drafted", draft["cart_id"], "with", len(lines), "lines")
cart_id is a string, and there is no purchase_order_id yet. Only supplier_id, items and shipping_cost are accepted at this point.
Publish it, or leave it for a human
POST /purchase-orders/{cart_id}/publish
turns the draft into a real order at status ORDERED and returns the numeric purchase_order_id.
order = client.post(f"/purchase-orders/{draft['cart_id']}/publish", json={
"order_note": "Nightly replenishment",
"expected_delivery": "2026-10-01T10:00:00Z",
"delivery_warehouse_id": 3,
}).json()
The draft step is what makes an unattended bot safe. Leave it unpublished and a buyer reviews it in the app; drafts are listed with GET /purchase-orders?status=DRAFT, and an unwanted one is removed with POST /purchase-orders/{cart_id}/delete
.
Neither call is idempotent. If either times out, do not resend. Check GET /purchase-orders first, filtering by status=DRAFT for the create and by supplier for the publish, then decide.
Publishing does not email the supplier unless you pass "send_email": true. Think hard before a scheduled job sends supplier email on its own.
Receive the delivery
When the goods arrive, book them in as parcels against the PO:
POST /purchase-orders/{order_id}/parcels/createcurl -s -X POST https://api.stockpilot.dev/purchase-orders/$PO_ID/parcels/create \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"items": [{"product_id": 90211, "quantity": 120}]}'
Booked a parcel by mistake? An unprocessed parcel can be removed with POST /purchase-orders/{order_id}/parcels/{parcel_id}/delete .
Running it on a schedule
The whole thing is a cron job:
def nightly_replenishment(client, suppliers):
for supplier_id in suppliers:
task = client.post("/purchase-orders/recommendations", json={
"supplier_id": supplier_id,
"lead_time": 21,
"durability": 60,
"scope_days": 90,
"include_flagged": True,
"include_inbound": True,
}).json()
recs = wait_for_recommendations(client, task["task_id"])
lines = [
{"product_id": r["product_id"], "quantity": r["recommended_quantity"]}
for r in recs if r["recommended_quantity"] > 0
]
if not lines:
continue
draft = client.post("/purchase-orders", json={
"supplier_id": supplier_id,
"items": lines,
}).json()
notify_buyer(draft) # a human publishes it
Send the draft to a human rather than committing it automatically. Recommendations are good; a buyer who knows a supplier is about to change their minimum order quantity is better.
