Inventory webhooks
A signed message when stock, an order or a product changes.
Add an endpoint URL, pick its events, and keep the signing secret to check each delivery.
Works todayWebhooks work today on every plan.
Inventory webhook events
Stock, orders and products.
Pick the events each endpoint receives. Each delivery is one event, posted as JSON.
| Event | When |
|---|---|
stock_level.updated | On hand, reserved or available changed at a location. |
stock_movement.created | A receipt, sale, transfer, count or adjustment posted. |
product.created | A product or variant was added. |
product.updated | A product or variant changed. |
purchase_order.created | A purchase order was raised. |
purchase_order.received | Stock on a purchase order was received. |
sales_order.created | A sales order arrived, from any channel. |
sales_order.updated | A sales order changed status or lines. |
sales_order.shipped | A sales order shipped in full or in part. |
invoice.created | An invoice was raised from a sales order. |
sales_order.returned | A customer return was recorded against a sales order. |
credit_note.created | A credit note was raised against an invoice. |
Webhook payload example
Each event carries the record that changed.
The id is unique per event and the same on every retry, so you can skip one you have already handled. The data is the record as the API returns it.
A stock level event carries on hand, reserved, and on hand less reserved, each with its previous value. It fires on each movement, and when a sales order changes what is reserved.
{
"id": "evt_demo_7c1e",
"type": "stock_level.updated",
"created_at": "2026-10-28T10:15:30Z",
"data": {
"variant_id": "demo-var-0001",
"sku": "LTT-SGE",
"location": {
"id": "demo-loc-wh",
"name": "Main warehouse"
},
"on_hand": 135,
"reserved": 24,
"available": 111,
"previous_on_hand": 139,
"previous_reserved": 28,
"previous_available": 111,
"cause": {
"kind": "shipment",
"movement_id": "demo-mv-006770",
"sales_order_id": "demo-so-10891",
"source": "scan"
}
}
}{
"id": "evt_demo_7c2f",
"type": "sales_order.created",
"created_at": "2026-10-28T10:02:00Z",
"data": {
"id": "demo-so-10891",
"number": "SO-10891",
"status": "confirmed",
"customer_id": "demo-cus-web",
"total": "336.00",
"currency": "CAD"
}
}Webhook signature verification
Check the signature before you trust a delivery.
Each endpoint has its own signing secret. The signature is an HMAC SHA-256 of the id, the timestamp and the raw body.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
Webhook-Id: evt_demo_7c1e
Webhook-Timestamp: 1793182530
Webhook-Signature: v1,Zm9yIGV4YW1wbGUgb25seSwgbm90IGEgcmVhbCBzaWduYXR1cmU=import { createHmac, timingSafeEqual } from "node:crypto";
// secret: the endpoint's signing secret, "whsec_" and base64, shown once when you add the endpoint
export function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
return headers["webhook-signature"]
.split(" ")
.map((part) => part.replace(/^v1,/, ""))
.some((sig) => sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}Webhook retries
A failed delivery is tried again, with longer gaps.
Reply with any 2xx within 10 seconds. Every delivery and retry shows in the integration activity log in the app.
| Retry | Time after the first failure |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
| 6 | 1 day |
Webhook test event
See a real signed payload before real events arrive.
Send test event, on the endpoint in the app or through the API, signs one sample of an event it takes. The body carries "test": true, and the activity log keeps the exact body and headers.
The sample is your newest matching record, or a fixed one when you have none. Leave out "event" to use the endpoint's first event. It is sent once with no retries, and the demo records it without sending.
POST /api/v1/webhook_endpoints/{id}/test
Authorization: Bearer <your API key>
Content-Type: application/json
{ "event": "sales_order.created" }{
"id": "evt_demo_7d40",
"endpoint_id": "3f6c1b8e-0d2a-4c5e-9f71-2b8a6d4e1c90",
"event": "sales_order.created",
"test": true,
"body": "{\"id\":\"evt_demo_7d40\",\"type\":\"sales_order.created\",\"created_at\":\"2026-10-28T10:20:00.000Z\",\"test\":true,\"data\":{\"id\":\"demo-so-10891\",\"number\":\"SO-10891\",\"status\":\"confirmed\"}}",
"payload": {
"id": "evt_demo_7d40",
"type": "sales_order.created",
"created_at": "2026-10-28T10:20:00.000Z",
"test": true,
"data": {
"id": "demo-so-10891",
"number": "SO-10891",
"status": "confirmed"
}
},
"headers": {
"Content-Type": "application/json",
"User-Agent": "webhooks/1",
"Webhook-Id": "evt_demo_7d40",
"Webhook-Timestamp": "1793182800",
"Webhook-Signature": "v1,Zm9yIGV4YW1wbGUgb25seSwgbm90IGEgcmVhbCBzaWduYXR1cmU="
},
"result": {
"sent": true,
"recorded_only": false,
"status": 200,
"error": null
},
"activity_entry_id": "8a2d4f60-71c3-4b9e-a5d2-0e6f9c3b7a14"
}Inventory management API pricing
The API, webhooks and the MCP server come with every plan.