# Webhooks

> Signed Shoprocket webhooks for orders, customers, products, stock, discounts, categories, subscriptions and licence keys: events, payload, test mode, signatures and retries.

We POST an event to your server the moment something happens, so you can sync orders, trigger fulfilment or update your own systems without polling.

## Overview

Add an HTTPS endpoint in the dashboard under Settings > Webhooks and pick the events it should receive. Each endpoint gets its own signing secret, shown on that screen. Every delivery, its response and its retries are kept in a delivery log there too, and you can resend any of them.

Acknowledge fast

Answer with any `2xx` within 10 seconds, then do the slow work (emails, ERP sync) after you have answered. A slower answer counts as a failure and is retried.

## Event types

Some changes fire more than one event on purpose: a paid order fires both `order.paid` and `order.updated`. Subscribe to the specific one you act on.

| Field | Type | Description |
| --- | --- | --- |
| `order.created` | event | A new order was placed. |
| `order.updated` | event | An order changed - status, payment, fulfilment, items or addresses. |
| `order.paid` | event | An order was paid in full. Fires once, on the transition - subscribe to this rather than diffing order.updated. |
| `order.refunded` | event | Money went back to the buyer. Fires on EVERY refund including partial ones; read paymentStatus to tell which. |
| `order.fulfilled` | event | Every line on the order has shipped. Fires on the transition, so cancelling and re-shipping fires it again. |
| `order.partially_fulfilled` | event | Some but not all of the order has shipped. Fires when the rolled-up status changes, not once per shipment. |
| `order.deleted` | event | An order was cancelled or archived. Nothing is destroyed; its status changed. |
| `customer.created` | event | A new customer record was created. |
| `customer.updated` | event | A customer's details changed. |
| `customer.deleted` | event | A customer was archived or erased on request. |
| `product.created` | event | A new product was created. |
| `product.updated` | event | A product changed - details, price, media, variants or stock. |
| `product.deleted` | event | A product was archived. Nothing is destroyed; its status changed. |
| `inventory.updated` | event | A variant's on-hand stock moved. One event per variant, carrying the new count and why it moved. |
| `discount.created` | event | A discount or coupon code was created. |
| `discount.updated` | event | A discount changed - its rules, its value, or whether it is active. |
| `discount.deleted` | event | A discount was archived. Redemption history is preserved; its status changed. |
| `category.created` | event | A new category was created. |
| `category.updated` | event | A category changed - its name, parent, position or products. |
| `category.deleted` | event | A category was removed. Its children reparent and product links are dropped. |
| `subscription.created` | event | A subscription started: its first payment landed. Fires once per contract, not per checkout. |
| `subscription.renewed` | event | A renewal payment went through, including one that recovered on a retry after a failure. |
| `subscription.renewal_failed` | event | A renewal payment was declined. The subscription is past due and will be retried; failureCount says which attempt this was. |
| `subscription.paused` | event | The customer or the seller paused a subscription. Nothing bills while it is paused. |
| `subscription.resumed` | event | A paused subscription was resumed and is billing again. |
| `subscription.cancelled` | event | A subscription was cancelled by the customer or the seller. Its licence keys keep working to the end of the period already paid for. |
| `subscription.expired` | event | A subscription ended by itself: it made its last scheduled payment, ran out of retries, or its product was removed. cancelReason says which. |
| `license_key.assigned` | event | A licence key was handed to a buyer: generated by Shoprocket, from the pool, from the seller's own server, or typed in by hand. |
| `license_key.suspended` | event | A sold key stopped working because its subscription is past due or paused. It comes back on its own when that resolves. |
| `license_key.reinstated` | event | A suspended key works again: the subscription's payment recovered or it was resumed. |
| `license_key.expired` | event | A sold key reached its end date: its subscription ended, or a one-off sale's validity period ran out. Final. |
| `license_key.revoked` | event | A sold key was revoked by the seller, or disabled because its order was refunded in full. Final. |

## Payload

Every delivery has the same envelope. `data` is the object exactly as the seller API returns it (an order from `GET /orders/{id}`, a product from `GET /products/{id}`), so you read one set of field docs, not two. A licence key event adds the buyer's `customerEmail` to the key. `inventory.updated` is the one event with a shape of its own, below.

The payload is frozen when the event happens: a retry hours later still describes the order as it was then, not as it is now. `test` is true for an event about a test order, and for the Send test button.

Delivery

```
POST /your/endpoint
X-Shoprocket-Signature: t=1790000000,v1=5257a869e7...
X-Shoprocket-Delivery: whd_034Uj2...
X-Shoprocket-Event: order.paid
Content-Type: application/json

{
  "id": "whd_034Uj2...",
  "event": "order.paid",
  "createdAt": "2026-09-24T09:24:11.000Z",
  "test": false,
  "storeId": "str_033xotqIZ8haHilPe2fnCW",
  "data": { "id": "ord_...", "orderNumber": "1042", ... }
}
```

## Stock changes

`inventory.updated` fires once per variant whose stock on the shelf moved, so a twelve-line shipment is twelve events. Its `data` is not a product: it says which variant moved, by how much and why.

inventory.updated data

```
{
  "productId": "prod_...",
  "variantId": "var_...",
  "sku": "CACTUS-PINK-S",
  "onHand": 11,
  "change": -1,
  "reason": "fulfilment",
  "orderId": "ord_..."
}
```

| Field | Type | Description |
| --- | --- | --- |
| `productId` | string | The variant's product (prod\_...). |
| `variantId` | string | The variant whose stock moved (var\_...). |
| `sku` | string \| null | The variant's SKU, when it has one. |
| `onHand` | integer | Units physically on the shelf after the move. Not the sellable count, which also subtracts units held for open orders. |
| `change` | integer | Signed: negative when units left the shelf, positive when they came back. |
| `reason` | string | Why it moved: fulfilment, fulfilment_cancelled, return_restocked, order_cancelled, manual. manual is a seller's own correction, so a stock sync can recognise the echo of its own write. |
| `orderId` | string \| null | The order that caused it. Null for a manual correction. |

## Test mode

Each endpoint is either live or test, set when you add it with the **Test mode endpoint** switch. An event about a test order goes only to test endpoints, and a live endpoint never receives one, so the endpoint that runs your real fulfilment stays untouched while you build against a second one. See [Test mode](https://v3.shoprocket.io/docs/test-mode.md) for how a store makes test orders.

Every endpoint, live or test, also has a **Send test** button. It posts a realistic sample `order.created` straight away, with obviously fake values such as `ord_EXAMPLE` and `test` set to true, and shows you what your server answered. A test send never counts towards the endpoint's failures.

## Verifying signatures

Each request carries `X-Shoprocket-Signature: t=<unix time>,v1=<signature>`. The signature is a hex HMAC-SHA256, keyed with the endpoint's secret, of the timestamp, a full stop, and the raw request body. This is the same scheme Stripe uses, so code you have written for Stripe webhooks carries over.

Recompute it from the raw body (before parsing the JSON), compare in constant time, and reject a timestamp more than a few minutes old. The timestamp is inside the signed material, so a captured delivery cannot be replayed later.

Verify (Node)

```
import crypto from "node:crypto";

// rawBody: the exact bytes received, before any JSON parsing.
function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec;
  const given = Buffer.from(parts.v1 ?? "");
  const want = Buffer.from(expected);
  // timingSafeEqual throws RangeError on a length mismatch instead of returning false - check first.
  return fresh && given.length === want.length && crypto.timingSafeEqual(want, given);
}
```

## Retries and duplicates

A delivery that does not get a `2xx` is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 16 hours: 7 attempts across about a day, enough to ride out an overnight outage. A `4xx` other than `408` or `429` is not retried, because the same body will get the same answer.

Delivery is at least once: a retry can arrive after an attempt you did receive. Deduplicate on the delivery `id` (also sent as `X-Shoprocket-Delivery`).

An endpoint that keeps failing is switched off after 20 failures in a row, and the store is emailed what your server last answered. Any success resets the count.

Turning it back on sends the deliveries that were already queued for it when it went off. Events that happen while it is off are never queued for it, so after an outage reconcile from the API instead (for example `GET /orders?from=` the day it went off) rather than waiting for them to arrive.

---

- HTML version: https://v3.shoprocket.io/docs/webhooks
- Language: English (en)
