Guide
Webhook delivery contract
Events, 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.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. |
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": "wh_7f3a91",
"organization_id": 4812,
"name": "erp stock sync",
"event": "inventory.stock_changed",
"event_triggered_at": "2026-09-03T09:14:22.481Z",
"delivery_id": "dl_2c9b40e1",
"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 |
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
{
"product_id": 90211,
"sku": "TSHIRT-BLK-M",
"quantity": 60,
"incoming": 120,
"offered_stock": 38,
"quantities": [
{ "warehouse_id": 1, "name": "Main", "quantity": 44, "inbound": 120, "sums_onto_offered": true },
{ "warehouse_id": 7, "name": "Amazon FBA", "quantity": 16, "inbound": 0, "sums_onto_offered": false }
]
}
| Field | Meaning |
|---|---|
quantity | Total physical units across all warehouses |
incoming | Units on inbound purchase orders, at product level |
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. |
quantities[] | Per warehouse breakdown |
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.
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 originate from Stockpilot’s worker infrastructure, not from the API gateway. Customers who IP allowlist inbound traffic need the worker egress ranges. Allowlisting the gateway’s addresses will not work.
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.
