Skip to main content

SKULabs Webhooks: Complete Guide

Every available webhook event, exact payload examples, headers, authentication, retry and auto-disable behavior, and how to inspect a live webhook payload.

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:

  1. 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.

  2. A publicly reachable HTTPS endpoint. The URL must be a public HTTPS address that accepts requests from the internet.

  3. 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 Ship notifications to ERP.

Type

The event you want to subscribe to. See Available events below.

URL

Your HTTPS endpoint, for example https://api.yourcompany.com/hooks/skulabs.

Method

POST is recommended. GET, PUT, and DELETE are also supported.

Headers (JSON)

Optional. A JSON object of headers to send with every request, for example { "Authorization": "Bearer your-secret-token" }.

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

order.status

Any order status change. Recommended — see the tip below.

order.status.unstarted

Order is reset to Not started

order.status.opened

Order is opened

order.status.in progress

Order moves to In progress

order.status.cleared

Order is cleared

order.status.awaiting fulfillment

Order moves to Awaiting fulfillment

order.status.partially shipped

Order is partially shipped

order.status.shipped

Order is fully shipped

order.status.delayed

Order is marked Delayed

order.status.cancelled

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

batch.create

A picking batch is created

Purchase orders

Type

Fires when

purchase_order.status

Any purchase order transition. Recommended.

purchase_order.status.created

A PO is created

purchase_order.status.update

A PO is edited

purchase_order.status.receiving

Stock is received against a PO

purchase_order.status.closed

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 emits purchase_order.status.update instead. The .edited event only fires if a PO is created through the API with status: "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

User-Agent

SKULabsWebhook/1.0 (https://www.skulabs.com) (Account ID:<your account id>) — always sent, and always contains your account ID

Content-Type

application/json on POST, PUT, and DELETE. Not sent on GET.

Content-Length

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 Authorization header for the secret. It is encrypted at rest on our side and masked as REDACTED when 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-Agent header 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:

  1. Open webhook.site and copy the unique URL it gives you.

  2. In SKULabs, create a webhook with that URL, method POST, and the event type you want to inspect.

  3. Wait up to 10 minutes for the subscription to activate.

  4. Trigger the event in SKULabs — ship a test order, create a batch, receive a PO line.

  5. 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 — purchase_order.status.edited, purchase_order.status.deleted, and anything under "Events that do not exist" will never fire.

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.status also fires when a status is re-applied without changing. Dedupe on your side.

Order events arrive where original_status equals status

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.

Invalid JSON in headers

The Headers field must be a single valid JSON object, for example { "Authorization": "Bearer abc" }.

Some events arrive out of order

Ordering is not guaranteed, especially after a retry. Use the time field on order events, or re-read the record from the API.


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

store_id

The SKULabs store the order belongs to

order_number

The order number as shown in SKULabs

original_status

Status before the change. Equal to status when a status was re-applied.

status

New status

note

Log note, when one was recorded. Often absent.

user_id

User who made the change. Absent for automated changes.

time

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 kititems 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.status on 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, and po_id. Use those to fetch full details from the API when you need them.

  • Webhooks are not an inventory sync mechanism. purchase_order.status.receiving reports 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.

Did this answer your question?