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.

beginner ·12 min read ·PythonNode ·Updated 3 Sep 2026

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/events
curl -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:

EventFires when
orders.completedAn 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_changedA 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/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": "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:

HeaderContents
X-Stockpilot-EventThe event name
X-Stockpilot-DeliveryUnique delivery ID, dedupe on this
X-Stockpilot-WebhookThe webhook ID that produced it
X-Stockpilot-OrganizationThe organization the event belongs to
X-Stockpilot-Signaturebase64(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}/test
curl -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}/deliveries
curl -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 2xx within 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.

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.