Webhooks let SKULabs push a notification to your own server the moment something happens in your account — an order ships, a batch is created, a purchase order is received. Instead of polling the SKULabs API on a timer, your endpoint gets called.
This guide covers every available event, the exact payload you will receive, how headers and authentication work, and how to inspect and debug a live webhook.
Before you start
You need three things:
The Webhooks feature on your plan. If you do not see the Webhooks section, or the API returns "Webhooks feature not enabled on this account", contact support to have it added.
A publicly reachable HTTPS endpoint. The URL must be a public HTTPS address that accepts requests from the internet.
An account in good standing. Webhook delivery is paused for accounts with an outstanding balance.
Creating a webhook
Go to Settings → Advanced → API, scroll to the Webhooks section, and click Add webhook.
Field | What to enter |
Active | Leave checked. Unchecking it stops delivery without deleting the webhook. |
Name | Any label that helps you recognize it later, for example |
Type | The event you want to subscribe to. See Available events below. |
URL | Your HTTPS endpoint, for example |
Method |
|
Headers (JSON) | Optional. A JSON object of headers to send with every request, for example |
Give it up to 10 minutes. SKULabs caches the list of active subscriptions per event type. A newly added webhook can take up to 10 minutes to begin firing. Editing an existing webhook takes effect immediately.
One webhook subscribes to one event type. To listen for several events, create several webhooks — they can all point at the same URL, and you can tell them apart using the type field in the payload.
Available events
Orders
Type | Fires when |
| Any order status change. Recommended — see the tip below. |
| Order is reset to Not started |
| Order is opened |
| Order moves to In progress |
| Order is cleared |
| Order moves to Awaiting fulfillment |
| Order is partially shipped |
| Order is fully shipped |
| Order is marked Delayed |
| Order is cancelled |
Note that the status names contain spaces, not underscores — the event type is order.status.partially shipped, not order.status.partially_shipped.
Tip: subscribe to order.status instead of the individual statuses. One subscription covers every transition, and you switch on data.status in your own code. This is fewer webhooks to manage and it will not miss statuses that are not in the dropdown, such as archived.
Batches
Type | Fires when |
| A picking batch is created |
Purchase orders
Type | Fires when |
| Any purchase order transition. Recommended. |
| A PO is created |
| A PO is edited |
| Stock is received against a PO |
| A PO is closed |
Two dropdown options behave differently than their labels suggest.
Purchase order - Edited (
purchase_order.status.edited) does not fire when you edit a PO in the UI. Editing emitspurchase_order.status.updateinstead. The.editedevent only fires if a PO is created through the API withstatus: "edited"explicitly set.Purchase order - Deleted (
purchase_order.status.deleted) is not currently emitted by any action.
For both cases, subscribe to purchase_order.status and read data.status — that catch-all fires on every transition, including edits.
Events that do not exist
Some earlier API reference examples mentioned the event names below. These are not currently available — a webhook using one will save, but it will never be called:
item.updated, item.inventory, order.shipped, order.tracking, order.tags, shipment.created, shipment.voided, transfer_order.*
We add events on request. If you need one that is not in the tables above, contact support and tell us what you are trying to build.
What the request looks like
For POST, PUT, and DELETE
SKULabs sends a JSON body with exactly two top-level keys, type and data:
POST /hooks/skulabs HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
Content-Length: 271
User-Agent: SKULabsWebhook/1.0 (https://www.skulabs.com) (Account ID:aaaaaaaaaaaaaaaaaaaaaaaa)
Authorization: Bearer your-secret-token
{
"type": "order.status.shipped",
"data": {
"store_id": "aaaaaaaaaaaaaaaaaaaaaaaa",
"order_number": "1002458",
"original_status": "in progress",
"status": "shipped",
"note": "Shipped from batch 1042",
"user_id": "bbbbbbbbbbbbbbbbbbbbbbbb",
"time": "2026-08-31T14:31:55.008Z"
}
}
For GET
There is no body. type and data are appended to the query string, and data is a URL-encoded JSON string that you will need to parse:
GET /hooks/skulabs?type=order.status.shipped&data=%7B%22store_id%22%3A%22aaaa...%22%2C%22order_number%22%3A%221002458%22%7D
Any query parameters already present in your configured URL are preserved.
Headers
Header | Value |
|
|
|
|
| Byte length of the body |
Your custom headers | Everything from the Headers (JSON) field, sent verbatim |
Authenticating the request
Your endpoint is a public URL, so you should assume anyone can send requests to it and verify every one before acting on it. SKULabs authenticates itself using the headers you configure, so treat that header as a shared secret.
Generate a long random secret and put it in the Headers (JSON) field:
{ "Authorization": "Bearer a-long-random-string-you-generate" }Then reject any request to your endpoint that does not carry it:
const crypto = require('crypto');
// Load your secret from your own configuration or secret manager.
const EXPECTED = Buffer.from(`Bearer ${WEBHOOK_SECRET}`);
function is_authorized(header) {
const received = Buffer.from(header || '');
// Constant-time compare so the check cannot be probed byte by byte.
return received.length === EXPECTED.length && crypto.timingSafeEqual(received, EXPECTED);
}
app.post('/hooks/skulabs', (req, res) => {
if (!is_authorized(req.get('authorization'))) {
return res.sendStatus(401);
}
const { type, data } = req.body;
// Acknowledge first, process afterward.
res.sendStatus(200);
enqueue_for_processing(type, data);
});Guidelines:
Use the
Authorizationheader for the secret. It is encrypted at rest on our side and masked asREDACTEDwhen the webhook is displayed back in the UI. Other headers are stored as plain text.Rotate the secret by editing the webhook and saving a new value. The change takes effect immediately.
Never treat the
User-Agentheader as authentication. Any client can send any User-Agent, so it identifies nothing on its own.Validate the payload before you use it. Treat every field as untrusted input: check types, and never interpolate a value straight into a SQL query, a shell command, or rendered HTML.
What SKULabs expects back
Return any 2xx status code. The response body is ignored.
Anything else — 3xx, 4xx, 5xx, a connection error, or a timeout — is treated as a failure and queued for retry.
Acknowledge quickly. Do the real work in a background job rather than holding the connection open while you write to your database or call another API.
Retries and automatic disabling
The retry schedule
A failed delivery is retried with a growing backoff of attempt² × 10 minutes, capped at 72 hours:
Attempt | Next retry after |
1 | 10 minutes |
2 | 40 minutes |
3 | 1.5 hours |
4 | 2.7 hours |
5 | 4.2 hours |
6 | 6 hours |
10 | 16.7 hours |
21+ | 72 hours (maximum) |
A delivery is abandoned after 30 days of unsuccessful retries.
Failures that are never retried
These status codes mean the request will never succeed, so SKULabs stops immediately and disables the webhook:
400, 404, 405, 410, 422
A 404 is the most common one to hit by accident. If your endpoint is not deployed yet, or the path has a typo, the webhook is disabled on the very first delivery.
Extra throttling
To avoid hammering an endpoint that is already struggling, SKULabs widens the gap between attempts further once a webhook has accumulated a long run of failures. A webhook that has been failing for a while may be retried only a few times a day until it succeeds again.
Automatic disabling
A webhook is automatically set to inactive when both are true:
It has failed 10 or more times, and
It has had no successful delivery in the last 3 days
When this happens we email your account's billing contact with the webhook name, type, URL, and method. All of its pending deliveries are cancelled.
To re-enable it: fix your endpoint, then go to Settings → Advanced → API, click the pencil icon on the webhook, check Active, and save. This clears the disabled state and resets the failure count.
Inspecting and debugging payloads
See a real payload in under a minute
The fastest way to find out exactly what an event sends:
Open webhook.site and copy the unique URL it gives you.
In SKULabs, create a webhook with that URL, method POST, and the event type you want to inspect.
Wait up to 10 minutes for the subscription to activate.
Trigger the event in SKULabs — ship a test order, create a batch, receive a PO line.
The full request, headers and body, appears on the webhook.site page.
Once you know the shape, point the webhook at your real endpoint.
Check delivery health in SKULabs
The webhook table at Settings → Advanced → API has a Last Used column showing:
Last Success — when a delivery last returned 2xx
Last Failed — when a delivery last failed
Failed Count — consecutive failures, reset to 0 on any success
Disabled at — shown in red if the webhook was automatically disabled
A webhook with a Failed Count climbing and no recent success means your endpoint is rejecting or timing out.
See why deliveries failed
Go to Settings → Advanced → Sync Reports and look for entries of type webhook. Each failure records the webhook name, event type, HTTP status returned, target URL, and attempt number — for example:
Webhook "Ship notifications to ERP" (order.status.shipped) failed with HTTP 500 at https://api.yourcompany.com/hooks/skulabs — attempt #3.
This is the first place to look when you know an event happened but nothing arrived.
Troubleshooting
Symptom | Cause and fix |
Nothing arrives from a brand new webhook | Wait 10 minutes for the subscription cache to refresh, then re-trigger the event. |
Nothing ever arrives, no failures logged | You are subscribed to an event that is not emitted. Check the Available events tables — |
Webhook was disabled after one attempt | Your endpoint returned 400, 404, 405, 410, or 422. These are treated as permanent. Fix the endpoint and re-enable. |
Deliveries stopped and you got an email | 10+ failures with no success in 3 days. Fix the endpoint, then re-enable the webhook via the pencil icon. |
The same event arrives more than once | Expected. Delivery is at-least-once, and |
Order events arrive where | The status was re-applied without changing. Skip these if you only care about real transitions. |
Cannot save the webhook | The URL must be a public HTTPS address. Internal, local, and non-routable addresses are not accepted. |
| The Headers field must be a single valid JSON object, for example |
Some events arrive out of order | Ordering is not guaranteed, especially after a retry. Use the |
Payload reference
Fields with no value are omitted rather than sent as null, so treat every field as optional in your parser.
batch.create
{
"type": "batch.create",
"data": {
"number": 1042,
"batch_id": "cccccccccccccccccccccccc",
"status": "opened",
"created_date": "2026-08-31T14:22:07.412Z",
"user_id": "bbbbbbbbbbbbbbbbbbbbbbbb"
}
}order.status and order.status.*
{
"type": "order.status.shipped",
"data": {
"store_id": "aaaaaaaaaaaaaaaaaaaaaaaa",
"order_number": "1002458",
"original_status": "in progress",
"status": "shipped",
"note": "Shipped from batch 1042",
"user_id": "bbbbbbbbbbbbbbbbbbbbbbbb",
"time": "2026-08-31T14:31:55.008Z"
}
}Field | Notes |
| The SKULabs store the order belongs to |
| The order number as shown in SKULabs |
| Status before the change. Equal to |
| New status |
| Log note, when one was recorded. Often absent. |
| User who made the change. Absent for automated changes. |
| When the change was recorded |
The payload does not include line items. If you need them, call the order API using store_id and order_number.
purchase_order.status.created
{
"type": "purchase_order.status.created",
"data": {
"number": "PO-1043",
"po_id": "dddddddddddddddddddddddd",
"status": "created",
"created_date": "2026-08-31T10:15:00-05:00",
"user": "Dana Whitfield",
"items": [
{
"item_id": "eeeeeeeeeeeeeeeeeeeeeeee",
"type": "Item",
"name": "Blue Widget",
"sku": "BW-001",
"quantity": 100
}
],
"dropshipped": false,
"store_id": "aaaaaaaaaaaaaaaaaaaaaaaa",
"order_number": ""
}
}order_number is populated only for dropship POs, where it is the sales order the PO was raised for. type on each item is Item or Kit.
purchase_order.status.update (a PO was edited)
{
"type": "purchase_order.status.update",
"data": {
"number": "PO-1043",
"po_id": "dddddddddddddddddddddddd",
"status": "edited",
"created_date": "2026-08-25T10:15:00-05:00",
"update_date": "2026-08-31T11:40:22-05:00",
"user": "Dana Whitfield",
"items": [ ... ],
"dropshipped": false,
"store_id": "aaaaaaaaaaaaaaaaaaaaaaaa",
"order_number": "",
"log": ["Changed quantity of BW-001 from 100 to 120"]
}
}items is the full list after the edit, in the same shape as the created event. log is a human-readable list of what changed.
purchase_order.status.receiving
This event fires once per receiving action, and the payload differs slightly depending on what was received.
Receiving an item or a kit — items contains only the line that was received, with a received running total:
{
"type": "purchase_order.status.receiving",
"data": {
"number": "PO-1043",
"po_id": "dddddddddddddddddddddddd",
"status": "receiving",
"user": "Dana Whitfield",
"items": [
{
"item_id": "eeeeeeeeeeeeeeeeeeeeeeee",
"type": "Item",
"name": "Blue Widget",
"sku": "BW-001",
"quantity": 120,
"received": 40
}
],
"dropshipped": false
}
}store_id and order_number are included when a kit is received, and omitted when a plain item is received.
Receiving a custom item — there is no items array. A single custom_item object is sent instead:
{
"type": "purchase_order.status.receiving",
"data": {
"number": "PO-1043",
"po_id": "dddddddddddddddddddddddd",
"status": "receiving",
"user": "Dana Whitfield",
"custom_item": {
"name": "Pallet wrap",
"quantity": 10,
"received": 4
}
}
}Handle both shapes: check for items first, then fall back to custom_item.
purchase_order.status.closed
{
"type": "purchase_order.status.closed",
"data": {
"number": "PO-1043",
"po_id": "dddddddddddddddddddddddd",
"status": "closed",
"created_date": "2026-08-25T10:15:00-05:00",
"closed_date": "2026-08-31T16:02:11-05:00",
"user": "Dana Whitfield",
"items": [ ... with a final "received" on each line ... ],
"dropshipped": false,
"store_id": "aaaaaaaaaaaaaaaaaaaaaaaa",
"order_number": ""
}
}Every item on the PO is included, each with its final received quantity.
Managing webhooks through the API
Everything in the UI is available on the API, which is useful for subscribing to purchase_order.status.update since it is not in the dropdown.
Create: POST /webhook/add_handler
{
"type": "purchase_order.status.update",
"name": "PO edits to ERP",
"handler": {
"url": "https://api.yourcompany.com/hooks/skulabs",
"method": "POST",
"headers": { "Authorization": "Bearer your-secret-token" }
}
}List: GET /webhook/get_handlers — returns every webhook on the account, including last_success, last_failed, and failed_count.
Update: POST /webhook/update_handler with { "_id": "...", "update": { ... } }. Setting active: true also clears the automatically-disabled state.
Delete: POST /webhook/remove_handler with { "_id": "..." }.
Copy event names exactly from the tables above. An event name that does not match a supported type saves successfully but never fires, which is easy to mistake for a broken endpoint.
Limits and other things worth knowing
Webhooks are billed like a normal API call. Deliveries count toward your account's daily API call allowance, so a high-volume subscription such as
order.statuson a busy account consumes a meaningful share of it. If you run out of API calls, the delivery is not lost — it is retried later.Delivery is at-least-once, not exactly-once. Design your endpoint to be idempotent.
Order is not guaranteed. A retried delivery can arrive after a newer event.
A healthy webhook normally arrives within a few seconds of the event.
Payloads are a summary, not the full record. They carry identifiers such as
store_id,order_number, andpo_id. Use those to fetch full details from the API when you need them.Webhooks are not an inventory sync mechanism.
purchase_order.status.receivingreports PO receipts only — it does not fire for sales, manual adjustments, transfers, or cycle counts. Use the inventory API for stock levels.
Still stuck?
Contact support with:
The webhook name and event type
The approximate time of an event you expected but did not receive
What your endpoint returned, if you have logs
We can look up the delivery attempts on our side and tell you exactly what happened.

