Webhooks
Register an endpoint once and Skuflo calls you the moment a piece or an order changes state, signed, so your own systems never have to poll.
Creating an endpoint
In the app, go to Settings, then Webhooks and API keys, or call POST /v1/webhooks with your factory API key. The signing secret is shown once, in the response: store it, Skuflo only keeps a hash of it too.
curl -X POST https://api.skuflo.io/v1/webhooks \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-shop.com/hooks/skuflo",
"events": ["order.created", "item.dispatched", "order.dispatched"]
}'
# { "id": "wh_2a9c", "url": "...", "events": [...], "active": true, "secret": "whsec_..." }Pass a single "*" instead of a list to receive every event type. The URL must be https, except localhost/127.0.0.1 outside production, for local testing.
What gets delivered
Every delivery is a POST with a JSON body and four headers:
| Header | Meaning |
|---|---|
x-webhook-id | Stable across every retry of the same event. Use it to dedupe. |
x-webhook-event | The event type, e.g. item.dispatched. |
x-webhook-timestamp | Unix seconds when this attempt was sent. Part of the signature. |
x-webhook-signature | sha256=HMAC_SHA256(secret, "<timestamp>.<raw body>") |
The body itself carries the same id at the top level, alongside the event and its data:
{
"id": "9a2e5c11-0f3b-4d7a-8c5e-2b1a9d4f7e33",
"type": "item.dispatched",
"created_at": "2026-10-06T07:02:51.000Z",
"data": {
"barcode": "OAK 7F3K9QZ2",
"order_no": "#1042"
}
}Events
Every payload below is the real shape the API builds, taken from the code that fires it.
{
"id": "2f0b6b5a-7e21-4a3a-9d1e-8b7c6a1f9c02",
"type": "order.created",
"created_at": "2026-10-02T08:03:11.000Z",
"data": {
"order_id": "ord_7e21f0",
"order_no": "#1042",
"retailer_id": "ret_9c1a4e",
"status": "awaiting_production",
"pieces_total": 3,
"pieces_made": 0,
"pieces_in_stock": 0,
"pieces_dispatched": 0,
"production_date": "2026-10-05",
"source": "api"
}
}{
"id": "9a2e5c11-0f3b-4d7a-8c5e-2b1a9d4f7e33",
"type": "label.printed",
"created_at": "2026-10-02T08:05:44.000Z",
"data": {
"order_id": "ord_7e21f0",
"order_no": "#1042",
"pieces": 3,
"barcodes": [
"OAK 7F3K9QZ2",
"OAK 4H1M8YXR",
"OAK 2Q9J5WCB"
]
}
}{
"id": "c4d1a8e2-5b6f-4a19-9c3d-7e8f0a1b2c34",
"type": "item.produced",
"created_at": "2026-10-05T09:12:03.000Z",
"data": {
"item_id": "itm_2c9f11",
"barcode": "OAK 7F3K9QZ2",
"order_no": "#1042",
"retailer": "Bedstore Co",
"sku": "OTT4FT6",
"colour": "Grey Plush",
"unit": "1/1",
"piece": "1/3",
"piece_name": "Base",
"status": "produced",
"location": null,
"produced_at": "2026-10-05T09:12:03.000Z",
"in_stock_at": null,
"dispatched_at": null,
"station": {
"id": "stn_produce_1",
"name": "Bench 1"
}
}
}{
"id": "e7b3f905-1a2c-4e8d-9b0f-3c5d7e9a1b56",
"type": "item.in_stock",
"created_at": "2026-10-05T15:40:22.000Z",
"data": {
"item_id": "itm_2c9f11",
"barcode": "OAK 7F3K9QZ2",
"order_no": "#1042",
"retailer": "Bedstore Co",
"sku": "OTT4FT6",
"colour": "Grey Plush",
"unit": "1/1",
"piece": "1/3",
"piece_name": "Base",
"status": "in_stock",
"location": "A3",
"produced_at": "2026-10-05T09:12:03.000Z",
"in_stock_at": "2026-10-05T15:40:22.000Z",
"dispatched_at": null,
"station": {
"id": "stn_stock_1",
"name": "Rack scanner"
}
}
}{
"id": "a1c9d802-6e4f-4b1a-8d2c-9f0e1a3b4c78",
"type": "item.dispatched",
"created_at": "2026-10-06T07:02:51.000Z",
"data": {
"item_id": "itm_2c9f11",
"barcode": "OAK 7F3K9QZ2",
"order_no": "#1042",
"retailer": "Bedstore Co",
"sku": "OTT4FT6",
"colour": "Grey Plush",
"unit": "1/1",
"piece": "1/3",
"piece_name": "Base",
"status": "dispatched",
"location": null,
"produced_at": "2026-10-05T09:12:03.000Z",
"in_stock_at": "2026-10-05T15:40:22.000Z",
"dispatched_at": "2026-10-06T07:02:51.000Z",
"station": {
"id": "stn_dispatch_1",
"name": "Loading bay 1"
}
}
}{
"id": "f5d2b611-3a7c-4e0f-9b1d-2c4e6a8f0b91",
"type": "order.produced",
"created_at": "2026-10-05T16:02:10.000Z",
"data": {
"order_id": "ord_7e21f0",
"order_no": "#1042",
"retailer_id": "ret_9c1a4e",
"status": "produced",
"pieces_total": 3,
"pieces_made": 3,
"pieces_in_stock": 0,
"pieces_dispatched": 0,
"produced_at": "2026-10-05T16:02:10.000Z"
}
}{
"id": "b8e4f123-9c5d-4a2e-8f1b-3d5e7a9c0b12",
"type": "order.dispatched",
"created_at": "2026-10-06T07:03:02.000Z",
"data": {
"order_id": "ord_7e21f0",
"order_no": "#1042",
"retailer_id": "ret_9c1a4e",
"status": "dispatched",
"pieces_total": 3,
"pieces_made": 3,
"pieces_in_stock": 0,
"pieces_dispatched": 3,
"dispatched_at": "2026-10-06T07:03:02.000Z"
}
}{
"id": "d6c3a904-8b1e-4f7a-9c2d-5e7f9a1b3c45",
"type": "item.remade",
"created_at": "2026-10-05T11:20:00.000Z",
"data": {
"old_barcode": "OAK 7F3K9QZ2",
"new_barcode": "OAK 5T8N2VXQ",
"order_id": "ord_7e21f0",
"order_no": "#1042"
}
}{
"id": "7c9e2a15-4d6f-4b8a-9e1c-2f4a6b8d0c67",
"type": "load.closed",
"created_at": "2026-10-06T07:10:00.000Z",
"data": {
"load_id": "ld_4b1c9e",
"ref": "L8K2QW",
"orders": 4,
"pieces_loaded": 11,
"short_orders": 1
}
}{
"id": "3e1f8b02-7c9d-4a5e-8b0f-1c3e5a7b9d89",
"type": "order.short_loaded",
"created_at": "2026-10-06T07:10:00.000Z",
"data": {
"order_id": "ord_7e21f0",
"order_no": "#1042",
"pieces_loaded": 2,
"pieces_total": 3,
"load_id": "ld_4b1c9e",
"ref": "L8K2QW"
}
}{
"id": "4a2c6e91-3b5d-4f7a-8c9e-0b2d4e6a8c01",
"type": "retailer.updated",
"created_at": "2026-10-01T10:00:00.000Z",
"data": {
"retailer_id": "ret_9c1a4e",
"change": "settings"
}
}Verifying a signature
Never trust a webhook you have not verified: recompute the signature from the raw body and compare it to the header. Both samples below use the timing safe comparison their language provides.
import crypto from 'node:crypto';
// req.rawBody must be the exact bytes Skuflo sent, before any JSON parsing:
// the signature is computed over the raw request body, not the parsed object.
export function verifyWebhook(req, secret) {
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
return signature === expected;
}Retries and dedupe
A delivery that does not get a 2xx response is retried with a growing gap between attempts, for around a day in total, then given up on. Every retry of the same event carries the same x-webhook-id, so storing ids you have already processed (even just for a day) is enough to make your handler idempotent against retries.
Factory endpoints vs retailer endpoints
A factory endpoint (registered with your API key) receives every event for every retailer you supply. A retailer’s own endpoint, registered from inside their portal, only ever receives events for their own orders. Both are signed the same way, with their own secret.