Skip to main content

Subscribe to webhooks

A webhook is SKU.io telling your system something happened, instead of your system asking over and over. You give us an event and an HTTPS URL; when that event fires, we POST a signed JSON payload to it. This guide creates a subscription, verifies the signature, and sends a test delivery to prove the endpoint works.

Before you begin

  • You need somewhere to receive the POST — an HTTPS URL on a host that resolves publicly. A http:// address, a localhost address, or anything on a private network is refused at creation.
  • One subscription is one event to one URL. To receive three events, create three subscriptions; to fan one event out to two systems, create two.
  • The signing secret is shown once, when the subscription is created. Have your secret store open.
  • Doing this over the API instead needs a token carrying webhooks:manage. See the API scopes reference.
  • The screenshots below come from a demonstration account with sample subscriptions and a seeded delivery history. Your own events, URLs, and timings will differ.

Steps

1. Open the webhooks page

Go to Settings → Developer → Webhooks.

The Webhooks list showing five subscriptions with their event, target URL, source, status, failures, and last delivery

Every subscription on the account is listed here, whether you created it yourself or an app created it on your behalf during an OAuth authorization — the Source column tells you which.

2. Start a new subscription

Click Create Webhook.

The Create webhook dialog with an empty event picker and target URL field

The dialog states the deal up front: when the selected event fires on this tenant, SKU.io POSTs a signed payload to your target URL, and every attempt lands in the delivery log.

3. Pick an event

Open Event and choose from the catalog. Events are grouped by the part of the product they come from.

The event picker open, showing events grouped under Sales Orders, Purchase Orders, Inventory, Products, and Customers

There are 11 events in five groups:

GroupEventFires whenRead scope
Sales Orderssales_order.createdA new sales order is created, on any channel or manuallyorders:read
sales_order.shippedA sales order is fully shippedorders:read
sales_order.cancelledA sales order is cancelledorders:read
Purchase Orderspurchase_order.createdA new purchase order is createdpurchase-orders:read
purchase_order.submittedA purchase order is sent to the supplierpurchase-orders:read
purchase_order.approvedA purchase order is approvedpurchase-orders:read
purchase_order.receivedGoods are received against a purchase orderpurchase-orders:read
Inventoryinventory.adjustedStock is adjusted, transferred, or recountedinventory:read
Productsproduct.createdA new product is createdproducts:read
Customerscustomer.createdA new customer is createdcustomers:read

Once you pick one, a scope chip appears next to it — that's the read scope a token must carry to receive this event's payload. Creating the subscription in the browser doesn't require the scope, but a token creating it over the API does: asking for sales_order.created with a token that can't read orders is refused.

The dialog with Inventory Adjusted selected, showing the inventory scope chip and the View sample payload toggle

4. Check the sample payload

Click View sample payload to see the exact shape your endpoint will receive, built from a real record on your own account.

The expanded sample payload showing the event, delivery_id, occurred_at, and data envelope

Build your handler against this rather than against a guess. If your account has no matching records yet, the dialog says so — the subscription still works, there is nothing to preview yet.

Every payload uses the same envelope:

{
"event": "inventory.adjusted",
"delivery_id": "9f1c4a6e-2f4d-4b21-9a8e-6c0a1d3e5f70",
"occurred_at": "2026-07-31T14:02:11+00:00",
"data": { }
}

data carries the record itself and changes per event. The three fields around it never do. The preview shows event, occurred_at, and data; delivery_id is minted per delivery, so it only appears on a real one.

5. Enter your target URL

Type the endpoint into Target URL.

SKU.io checks it before saving, and again on every delivery, so a URL that stops being safe stops receiving:

RuleRefusal
Must be a valid URLTarget URL is not a valid URL.
Must use HTTPSTarget URL must use HTTPS.
Must include a hostTarget URL must include a host.
The host must resolveTarget URL host cannot be resolved: {host}.
It must not resolve to a private or reserved addressTarget URL must not resolve to a private or reserved IP.

The last one rules out localhost, 127.0.0.1, 10.x, 192.168.x, link-local addresses, and anything else inside your own network. It's re-checked at send time as well, so a public hostname that later points somewhere private is refused then too.

Testing from a laptop? Use a tunnelling service that gives you a public HTTPS hostname, and point the subscription at that.

6. Create it and copy the signing secret

Click Create webhook. The secret appears once.

The Webhook created dialog showing the signing secret, the event, the target URL, and a copy confirmation checkbox

Copy it into your secret store now — there's no way to reveal it again. Tick I have copied this signing secret and stored it safely to close the dialog.

A signing secret can't be re-shown or replaced in place. If you lose one, delete the subscription and create it again — you'll get a fresh secret, and the delivery history of the old one goes with it.

7. Send a test delivery

Open the new subscription from the list. Until a real event fires, the delivery log is empty.

The subscription drawer showing No deliveries yet and a Send test delivery button

Click Send test delivery. SKU.io sends it immediately — signed with your real secret, to your real URL, with the real headers — and shows you the result in the log without waiting for a queue.

A test payload is marked so your handler can tell it apart:

{
"event": "inventory.adjusted",
"delivery_id": "c8f0a2b4-7d31-4c8e-b6a9-2e5f7180d4c3",
"occurred_at": "2026-07-31T14:06:44+00:00",
"data": {
"_test": true,
"message": "This is a test delivery from SKU.io."
}
}

A failed test still records a row with the status code and the error, which is what you want — it's the fastest way to find a firewall rule or a bad path.

What SKU.io sends

Every delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
X-SKU-Signaturesha256= followed by the HMAC of the body
X-SKU-EventThe event name, for example inventory.adjusted
X-SKU-Delivery-IdA UUID unique to this delivery
User-AgentSKU.io-Webhook/1.0

Verify the signature

Treat any request without a valid signature as hostile. Your URL is reachable by anyone; the signature is what proves the payload came from SKU.io.

The signature is an HMAC-SHA256 of the raw request body, keyed with your signing secret. Compute it over the bytes you received — not over a re-encoded version of the parsed JSON, which will not match.

$body = file_get_contents('php://input');
$expected = 'sha256='.hash_hmac('sha256', $body, $signingSecret);

if (! hash_equals($expected, $_SERVER['HTTP_X_SKU_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
const crypto = require("crypto");

const expected =
"sha256=" +
crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex");

const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers["x-sku-signature"] ?? "")
);

Compare in constant time — hash_equals, timingSafeEqual, or your language's equivalent — rather than with ==.

What your endpoint should do

DoWhy
Respond 2xx within 15 secondsAnything slower is treated as a failure and retried. Acknowledge first, process afterwards.
Verify the signature before trusting the bodyThe URL is public; the signature isn't.
Deduplicate on delivery_idA retry re-sends the same delivery_id. Handlers must be idempotent.
Return 410 Gone to unsubscribeIt's the one status that makes SKU.io stop immediately and disable the subscription — no retries, no waiting for the failure count. Use it when the endpoint is permanently retired.

Anything else non-2xx is a failure: SKU.io retries up to three times, waiting 30 seconds and then 5 minutes. After 25 consecutive failures the subscription is disabled automatically and the account owner is emailed. Every attempt — success or failure — is written to the delivery log.

For reading that log, diagnosing failures, and re-enabling a disabled subscription, see Monitor webhook deliveries.

The webhooks guide on developer.sku.io covers the same contract from the receiving end, alongside the rest of the API reference.

Duplicates are blocked

The same event pointed at the same URL twice is refused, with a link to the subscription that already exists. If that one is disabled, the dialog says so and offers to open it — enable it rather than creating a second subscription alongside it.

This is deliberate: two live subscriptions on the same event and URL means double the deliveries, and the duplicate is almost always an accident.

Next steps

Last verified: