Tutorial 02
Receive your first webhook
Register an endpoint, fire a test delivery, and verify the HMAC signature correctly, including the mistake that breaks almost every first attempt.
Polling /inventory on a timer works until you have a few thousand SKUs. Webhooks invert it: Stockpilot pushes to an HTTPS endpoint you control, within seconds of the change.
By the end of this page you will have a local endpoint receiving signed, verified deliveries.
What you can subscribe to
GET /webhooks/eventscurl -s https://api.stockpilot.dev/webhooks/events \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"
Today that returns two event types:
| Event | Fires when |
|---|---|
orders.completed | An order reaches the completed state, either by transitioning to it or by arriving already completed (a bol.com import, say). Once per order. |
inventory.stock_changed | A product’s stock quantity changes in any warehouse. |
Call this endpoint at runtime rather than hardcoding the list, so new event types appear without a redeploy on your side.
Expose a local endpoint
Stockpilot only delivers to publicly resolvable HTTPS URLs. Private ranges, loopback and cloud-metadata addresses are rejected, and redirects are not followed. For local development, tunnel:
cloudflared tunnel --url http://localhost:8000
# or
ngrok http 8000
Keep the https://… URL it prints.
Register the webhook
POST /webhooks/createcurl -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": "local dev",
"url": "https://your-tunnel.example.com/hooks/stockpilot",
"event": "inventory.stock_changed",
"secret": "a-secret-at-least-16-chars"
}'
Supply your own secret (minimum 16 characters) or omit it and Stockpilot generates one for you. Save the returned webhook_id, because you will need it to test, inspect and delete.
To watch only one warehouse, add warehouse_id. Note that scoping filters which changes trigger a delivery; a scoped webhook still receives the full product payload, not a trimmed one.
Write the receiver
This is the part worth getting right. Every delivery carries these headers:
| Header | Contents |
|---|---|
X-Stockpilot-Event | The event name |
X-Stockpilot-Delivery | Unique delivery ID, dedupe on this |
X-Stockpilot-Webhook | The webhook ID that produced it |
X-Stockpilot-Organization | The organization the event belongs to |
X-Stockpilot-Signature | base64(HMAC-SHA256(raw_body, secret)) |
import base64, hashlib, hmac, json, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["STOCKPILOT_WEBHOOK_SECRET"].encode()
@app.post("/hooks/stockpilot")
async def receive(request: Request):
raw = await request.body() # raw bytes, before any parsing
sent = request.headers.get("X-Stockpilot-Signature", "")
digest = hmac.new(SECRET, raw, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
if not hmac.compare_digest(sent, expected):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw) # parse only after verifying
enqueue(event) # hand off, return fast
return {"ok": True}
Compute the HMAC over the raw request body bytes, before any JSON parsing. Re-serialising a parsed body changes whitespace and key order, and the signature will never match. This is the single most common reason a first integration fails.
Use hmac.compare_digest rather than == so the comparison runs in constant time.
The Node equivalent, with Express. Note express.raw, not express.json:
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.STOCKPILOT_WEBHOOK_SECRET;
app.post('/hooks/stockpilot',
express.raw({ type: 'application/json' }),
(req, res) => {
const expected = crypto
.createHmac('sha256', SECRET)
.update(req.body) // Buffer, untouched
.digest('base64');
const sent = req.get('X-Stockpilot-Signature') || '';
const a = Buffer.from(sent);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString());
console.log(event.event, event.delivery_id);
res.json({ ok: true });
});
Fire a test delivery
You do not have to change stock to see traffic:
POST /webhooks/{webhook_id}/testcurl -s -X POST https://api.stockpilot.dev/webhooks/$WEBHOOK_ID/test \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"
The envelope your handler receives looks like this:
{
"webhook_id": "wh_7f3a91",
"organization_id": 4812,
"name": "local dev",
"event": "inventory.stock_changed",
"event_triggered_at": "2026-09-03T09:14:22.481Z",
"delivery_id": "dl_2c9b40e1",
"data": { }
}
Inspect what happened
If nothing arrived, the delivery log tells you why, with status codes, timings and response bodies:
GET /webhooks/{webhook_id}/deliveriescurl -s https://api.stockpilot.dev/webhooks/$WEBHOOK_ID/deliveries \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET"
Before you ship this
A test delivery is not a production consumer. Three rules carry most of the weight:
- Respond
2xxwithin 10 seconds. Do the work in a queue, not in the handler. - Be idempotent. Delivery is at-least-once. Dedupe on
delivery_id. - Expect retries. A failing endpoint is retried 15 times over roughly 17 hours, and after 5 fully-failed deliveries in 24 hours the webhook is automatically deactivated, and you then need POST /webhooks/{id}/reactivate to bring it back.
The full contract, covering the retry ladder, coalescing behaviour, multi-tenant patterns and the network note about worker egress addresses, is in the webhooks guide.
