Skip to main content

Webhooks

Instead of polling, subscribe to events and SKU.io will POST signed JSON to your HTTPS endpoint when they happen.

Managing subscriptions requires the webhooks:manage scope; subscribing to an event additionally requires that event's read scope (e.g. orders:read for sales_order.created).

Event catalog

EventFires whenRequired scope
sales_order.createdA sales order is created (any channel or manual)orders:read
sales_order.shippedA sales order is fully fulfilled / shippedorders:read
sales_order.cancelledA sales order is cancelled (manual or channel-sync)orders:read
purchase_order.createdA purchase order is createdpurchase-orders:read
purchase_order.approvedA purchase order is approvedpurchase-orders:read
purchase_order.submittedA purchase order is submitted to the supplierpurchase-orders:read
purchase_order.receivedGoods are received against a purchase orderpurchase-orders:read
inventory.adjustedInventory levels are adjusted (manual or system)inventory:read
product.createdA product is created (UI, API, or import)products:read
customer.createdA customer is created (manual or first-seen-from-channel)customers:read

The catalog is also available live, including a real sample payload per event from your own tenant's data:

curl "https://acme.sku.io/api/webhook-events" \
-H "Authorization: Bearer $SKU_TOKEN" -H "Accept: application/json"

# Up to 3 recent records shaped exactly like the webhook payload:
curl "https://acme.sku.io/api/webhook-events/sales_order.created/sample" \
-H "Authorization: Bearer $SKU_TOKEN" -H "Accept: application/json"

Subscribing

One subscription = one event + one target URL. The target must be HTTPS and publicly resolvable (URLs resolving to private/reserved IPs are rejected).

curl -X POST "https://acme.sku.io/api/webhook-subscriptions" \
-H "Authorization: Bearer $SKU_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"event": "sales_order.created",
"target_url": "https://hooks.example.com/skuio/orders"
}'

Response (201 Created):

{
"data": {
"id": 12,
"event": "sales_order.created",
"target_url": "https://hooks.example.com/skuio/orders",
"source": "api",
"is_active": true,
"failure_count": 0,
"disabled_reason": null,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-07-06T18:20:00+00:00",
"updated_at": "2026-07-06T18:20:00+00:00",
"secret": "whsec_4f8a2c91d7e3b5a6f0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5"
}
}

secret is returned exactly once, here — store it; you need it to verify signatures. Creating the same event + URL pair again is idempotent: you get 200 with the existing subscription and no secret (delete and re-create if you lost it).

Other subscription operations:

# List subscriptions
curl "https://acme.sku.io/api/webhook-subscriptions" -H "Authorization: Bearer $SKU_TOKEN" -H "Accept: application/json"

# Inspect one
curl "https://acme.sku.io/api/webhook-subscriptions/12" -H "Authorization: Bearer $SKU_TOKEN" -H "Accept: application/json"

# Send a test delivery to your endpoint
curl -X POST "https://acme.sku.io/api/webhook-subscriptions/12/test" -H "Authorization: Bearer $SKU_TOKEN" -H "Accept: application/json"

# Paginated delivery log (status, response code, duration, payload per attempt)
curl "https://acme.sku.io/api/webhook-subscriptions/12/deliveries" -H "Authorization: Bearer $SKU_TOKEN" -H "Accept: application/json"

# Unsubscribe (idempotent — 204 even if already gone)
curl -X DELETE "https://acme.sku.io/api/webhook-subscriptions/12" -H "Authorization: Bearer $SKU_TOKEN"

What a delivery looks like

POST /skuio/orders HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: SKU.io-Webhook/1.0
X-SKU-Event: sales_order.created
X-SKU-Delivery-Id: 0197e4a2-1c3b-7f42-9d1e-8a5b6c7d8e9f
X-SKU-Signature: sha256=b7a4f0e1c2d3...
{
"event": "sales_order.created",
"delivery_id": "0197e4a2-1c3b-7f42-9d1e-8a5b6c7d8e9f",
"occurred_at": "2026-07-06T18:22:41+00:00",
"data": {
"id": 5011,
"order_number": "SO-5011",
"status": "pending",
"customer": { "id": 87, "name": "Jane Smith" },
"lines": [
{ "sku": "WDG-001", "quantity": 2, "price": 19.99 }
]
}
}

The envelope is always event, delivery_id, occurred_at, data. The data shape per event matches the sample endpoint above. delivery_id is unique per delivery — use it to deduplicate, since retries re-send the same delivery_id.

Verifying signatures

Every delivery is signed with HMAC-SHA256 over the raw request body, using your subscription's secret. The hex digest is sent in X-SKU-Signature prefixed with sha256=.

// Node.js
const crypto = require("crypto");

function verifySkuWebhook(rawBody, signatureHeader, secret) {
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
// PHP
function verifySkuWebhook(string $rawBody, string $signatureHeader, string $secret): bool
{
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);

return hash_equals($expected, $signatureHeader);
}

Verify against the raw bytes of the body — parse the JSON only after the signature checks out. Reject anything that fails verification with a 4xx.

Retries & automatic deactivation

  • Respond with any 2xx quickly (aim for under a couple of seconds; the delivery times out after 15). Do your processing async.
  • A failed delivery (non-2xx, timeout, or connection error) is retried up to 3 attempts total, with backoff of 30 seconds, then 5 minutes.
  • Responding 410 Gone deactivates the subscription immediately — the clean way for your endpoint to unsubscribe itself.
  • After 25 consecutive failed deliveries, the subscription is deactivated (disabled_reason: "max_failures") and the owning user is notified. There is no reactivate call — once your endpoint is healthy, POST the same event + URL again; deactivated rows don't count as duplicates, so you get a fresh subscription with a new secret.
  • Deliveries are subject to a per-tenant outbound cap (600 deliveries/minute by default). Deliveries over the cap are dropped, not queued — they appear in the delivery log marked as throttled. If you expect event bursts (e.g. inventory.adjusted during large syncs), design your consumer to reconcile via the REST API rather than assuming every event arrives.

Every attempt — success, failure, or throttled drop — is visible in the delivery log endpoint and in Settings → Developer → Webhooks.

Ordering

Deliveries are dispatched from a queue and retried independently; order is not guaranteed. Use occurred_at (and your own reads back through the REST API) to resolve state, not arrival order.