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.

Shell
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:

HeaderMeaning
x-webhook-idStable across every retry of the same event. Use it to dedupe.
x-webhook-eventThe event type, e.g. item.dispatched.
x-webhook-timestampUnix seconds when this attempt was sent. Part of the signature.
x-webhook-signaturesha256=HMAC_SHA256(secret, "<timestamp>.<raw body>")

The body itself carries the same id at the top level, alongside the event and its data:

JSON
{
  "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.

order.created
An order lands on a production day, from a sheet upload, the retailer portal or POST /v1/orders/import.
JSON
{
  "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"
  }
}
label.printed
The labels for an order are printed for the first time (GET /v1/labels only fires this once per piece).
JSON
{
  "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"
    ]
  }
}
item.produced
A produce station scans a piece as made (POST /v1/scan).
JSON
{
  "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"
    }
  }
}
item.in_stock
A stock station books a piece onto a rack bay (POST /v1/scan with a valid location).
JSON
{
  "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"
    }
  }
}
item.dispatched
The loading bay scans a piece onto a vehicle (POST /v1/scan on a dispatch station).
JSON
{
  "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"
    }
  }
}
order.produced
Every piece on the order has been made (the roll up recomputes after every scan).
JSON
{
  "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"
  }
}
order.dispatched
Every piece on the order has been loaded.
JSON
{
  "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"
  }
}
item.remade
A label is voided and reprinted as a replacement (POST /v1/items/{barcode}/remake).
JSON
{
  "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"
  }
}
load.closed
A load (vehicle trip) is closed out (POST /v1/loads/{id}/close).
JSON
{
  "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
  }
}
order.short_loaded
A load is closed with an order still missing pieces (one event per short order, alongside load.closed).
JSON
{
  "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"
  }
}
retailer.updated
A retailer's settings change (PATCH /v1/retailers/{id}): name, email, delivery days, ordering or price visibility.
JSON
{
  "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;
}
Use the raw request body, not a value you have serialised again from the parsed JSON. Even a different key order will produce a different signature, and a correct payload will look like a forgery.

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.