Guide
Webhook delivery contract
All eight events, the envelope, HMAC signature verification, the retry ladder, coalescing, auto deactivation and multi-tenant patterns. Everything a production consumer needs.
Stockpilot pushes events to an HTTPS endpoint you control. You register an endpoint with POST /webhooks/create, verify the signature on every request, and respond 2xx quickly. Delivery is at-least-once, so your handler must be idempotent.
Available events
| Event | Fires when |
|---|---|
orders.created | A new order arrives. |
orders.updated | An order changes. Chattier than it looks, see below. |
orders.completed | An order reaches the completed state, either by transitioning to it or by arriving already completed, such as a bol.com import. Fires once per order. |
inventory.stock_changed | A product’s stock quantity changes in any warehouse. |
purchase_orders.status_changed | A purchase order moves between DRAFT, ORDERED, SHIPPED, CONFIRMED, DELIVERED and COMPLETED. |
purchase_orders.received | A purchase order reaches DELIVERED. |
purchase_orders.parcel_status_changed | A parcel moves between CREATED, PROCESSING and COMPLETED. |
purchase_orders.parcel_received | A parcel has been applied to stock. |
One event per webhook. Subscribe to each one you want with its own POST /webhooks/create.
GET /webhooks/events returns the same list at runtime, so an integration can discover new event types without a redeploy. Call it rather than hardcoding.
The envelope
{
"webhook_id": 42,
"organization_id": 1874,
"name": "Stock changes to ERP",
"event": "inventory.stock_changed",
"event_triggered_at": "2026-09-02T14:03:11+00:00",
"delivery_id": "9c1f0f8e5b7a4c2d8e3f1a2b3c4d5e6f",
"data": { }
}
Headers
| Header | Contents |
|---|---|
X-Stockpilot-Event | Event name |
X-Stockpilot-Delivery | Unique delivery ID, use it to dedupe |
X-Stockpilot-Webhook | The webhook that produced this delivery |
X-Stockpilot-Organization | Organization the event belongs to |
X-Stockpilot-Signature | base64(HMAC-SHA256(raw_request_body, webhook_secret)) |
User-Agent | Stockpilot-Webhooks/1.0 |
webhook_id and organization_id are mirrored into headers as well as the body, so you can route a request before parsing it.
Verifying the signature
import base64, hashlib, hmac
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(signature, expected)
Compute the HMAC over the raw body bytes, before any JSON parsing. Re-serialising a parsed body changes whitespace and key order and will not match.
Use hmac.compare_digest rather than == so the comparison is constant time.
Secrets
Supply your own secret at creation (minimum 16 characters) or omit it and Stockpilot generates one. Store it wherever you store your other secrets, and use the same one across every webhook you register if you want a single verification path.
Payload for inventory.stock_changed
{
"id": 34897,
"sku": "201156",
"name": "Vichy Homme Structure Force 50 ml",
"barcode": "3337875647212",
"offered_stock": 3,
"total_on_hand": 4,
"incoming": 12,
"backorder": 0,
"quantities": [
{"warehouse": "Main", "warehouse_id": "WH1", "is_default": true,
"sums_onto_offered": true, "available": 3, "reserved": 1, "on_hand": 4, "inbound": 12},
{"warehouse": "Amazon FBA", "warehouse_id": "WH2", "is_default": false,
"sums_onto_offered": false, "available": 1, "reserved": 0, "on_hand": 1, "inbound": 6}
]
}
| Field | Meaning |
|---|---|
offered_stock | What Stockpilot actually offers to sales channels: the sum of warehouses with sums_onto_offered: true, minus buffer stock. This is the number most integrations want. |
total_on_hand | On hand across contributing warehouses, including reserved |
incoming | Units on open purchase orders, at product level, not attributed to a warehouse |
backorder | Units currently on backorder |
quantities[].available | On hand in that warehouse excluding reserved |
quantities[].on_hand | On hand in that warehouse including reserved |
quantities[].reserved | Units allocated to open orders |
quantities[].inbound | Stock in transit into that warehouse |
quantities[].sums_onto_offered | Whether this warehouse contributes to offered_stock |
quantities[].is_default | Whether this is the default warehouse |
Do not sum inbound across warehouses. On the default warehouse, per warehouse inbound repeats the product level incoming, because purchase orders land there. Summing inbound across warehouses and comparing the result to incoming double counts.
There are no deltas
No before and after pair is sent. Deliveries are coalesced, so a previous and new pair would be misleading. Treat every payload as current state, and write setters rather than adjusters in your handler.
Payloads for the purchase_orders events
purchase_orders.status_changed and purchase_orders.received carry the purchase order:
| Field | Meaning |
|---|---|
id | Purchase order ID, the numeric one used by GET /purchase-orders/{order_id} |
purchase_order_id | The cart ID string, as used by publish, update and delete |
status | DRAFT, ORDERED, SHIPPED, CONFIRMED, DELIVERED or COMPLETED |
supplier, warehouse, currency | Who it is with, where it lands, what it is priced in |
order_total, shipping_total | Money on the order |
expected_at, delivered_at | Expected and actual delivery |
items[] | Each line with ordered, incoming, dispatched, delivered and invoiced quantities |
purchase_orders.parcel_status_changed and purchase_orders.parcel_received carry the parcel instead: 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 when a parcel is marked complete, not when its stock is processed. Those are two different calls, so it is not a drop-in replacement for polling the parcel status endpoint. Receive a delivery end to end walks the difference.
purchase_orders.status_changed fires on every status move, and a COMPLETED order also emits parcel events for each parcel it closes.
Two transitions in quick succession can deliver twice with the same status. The body is built at delivery time, not at the moment the change happened, so the second payload has already caught up. That second delivery is a real transition, not a duplicate to discard. Dedupe on delivery_id, never by comparing statuses.
orders.updated is chattier than it looks
It fires on internal churn: backorder flags, forwarding and other bookkeeping, not only on edits a person would recognise. Expect deliveries that carry no change you care about, and filter on the fields you actually watch rather than treating every delivery as a user action.
Warehouse scoping
Omit warehouse_id to receive changes from all warehouses; set it to filter. A scoped webhook still receives the full product payload. Scoping filters which changes trigger a delivery; it does not trim the body.
The consumer contract
Four obligations, and the behaviour you get in return.
Respond 2xx within 10 seconds
Do the real work in a queue. An ERP write, a marketplace call or anything touching a database under load will blow the budget.
Expect retries
A failed delivery is retried 15 times over roughly 17 hours:
10s · 30s · 1m · 2m · 5m · 10m · 20m · 30m · 1h · 1h · 2h · 2h · 3h · 3h · 3h
Expect auto deactivation
After five fully failed deliveries in 24 hours, the webhook is automatically deactivated. Bring it back with POST /webhooks/{webhook_id}/reactivate , and monitor for it:
for hook in client.get("/webhooks").json():
if not hook["is_active"]:
client.post(f"/webhooks/{hook['id']}/reactivate")
alert(f"reactivated {hook['id']}")
Dedupe on delivery_id
Delivery is at-least-once. The same delivery_id can arrive more than once, and your handler must treat the second arrival as a no-op.
Coalescing
inventory.stock_changed is coalesced over roughly 5 seconds per product per warehouse. A burst of changes to one SKU in one warehouse produces one delivery carrying the final state. Coalescing does not merge across warehouses, so a product that moved in two warehouses can produce two deliveries.
Endpoint requirements
- HTTPS only, and publicly resolvable. Private, loopback, link local and cloud metadata addresses are rejected.
- Redirects are not followed. Register the final URL.
Multi-tenant integrations
If you are a platform receiving webhooks for many Stockpilot organizations:
- Register one webhook per customer organization, using your own shared secret.
- Store the returned
webhook_idagainst that customer’s record. - Verify with the constant secret, then route on
X-Stockpilot-WebhookorX-Stockpilot-Organization.
A shared secret is visible to every organization admin who has it configured. If your customers need to be mutually distrustful, use a distinct secret per organization instead, look the customer up by X-Stockpilot-Webhook first, then verify with that customer’s secret.
Networking
Deliveries do not come from the same addresses as the API. If you IP allowlist inbound traffic, ask us for the delivery egress ranges rather than allowlisting the API’s.
Debugging
| Endpoint | Use |
|---|---|
| POST /webhooks/{webhook_id}/test | Fire a delivery on demand, without changing data |
| GET /webhooks/{webhook_id}/deliveries | Recent deliveries with status codes and responses |
| GET /webhooks/{webhook_id} | Current configuration and active state |
All webhooks are scoped to your organization. A webhook ID belonging to another organization returns 404, the same as one that does not exist.
Walk through a working setup in Receive your first webhook.
