Webhooks
Stockroom POSTs a signed JSON body to your URL when something happens, so nothing has to poll. The body carries the whole object, not just an id.
Create a subscription
POST /v2/webhooks
{
"url": "https://erp.example.com/hooks/stockroom",
"description": "ERP order feed",
"events": ["purchase_order.ordered", "purchase_order.received"]
}Pass ["*"] for every event, including ones added later. Needs thewebhooks scope.
HTTP/1.1 201 Created
{
"data": {
"id": 3,
"url": "https://erp.example.com/hooks/stockroom",
"description": "ERP order feed",
"events": ["purchase_order.ordered", "purchase_order.received"],
"status": "active",
"api_version": 2,
"consecutive_failures": 0,
"last_delivered_at": null,
"secret": "whsec_..."
}
}The secret is in that response and nowhere else, ever. Store it before you close the connection. Stockroom cannot show it again - to get a new one, delete the endpoint and create it afresh.
A URL must be https and must resolve to a public address. Private ranges, loopback, link-local and our own hostnames are refused at creation and re-checked at every delivery, so a hostname that later points somewhere private stops being delivered to. Redirects are never followed: the URL you give is the URL that gets the POST. Ten endpoints per store.
What a delivery looks like
POST /hooks/stockroom HTTP/1.1
Content-Type: application/json
User-Agent: Stockroom-Webhooks/2
X-Stockroom-Event: purchase_order.received
X-Stockroom-Event-Id: evt_01J9Z8...
X-Stockroom-Delivery-Id: 8241-3-1
X-Stockroom-Webhook-Id: 3
X-Stockroom-Timestamp: 1757673000
X-Stockroom-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a...
{
"id": "evt_01J9Z8...",
"event": "purchase_order.received",
"api_version": 2,
"occurred_at": "2026-09-12T10:30:00Z",
"shop": { "id": 12, "domain": "acme.myshopify.com" },
"data": { "id": 1024, "number": "PO-1024", "status": "received", "...": "the full purchase order" }
}data is the same object the API would return for that record, so a consumer can act without a follow-up read. Answer with any 2xx and an empty body. Anything else counts as a failure.
Three payloads carry more than the bare object, because the event is about a relationship:purchase_order.receipt_recorded is the order with a receipt beside it,receipt.voided is the receipt with its purchase_order, and the fouritem.* events carry the item, its location, the level and the threshold that was crossed rather than any stored record.
Answer fast: the timeout is 10 seconds. Queue the work on your side and reply immediately rather than doing it inside the request.
Verify the signature
The signature is sha256= followed by an HMAC-SHA256 oftimestamp + "." + body, keyed with your endpoint's secret. The timestamp is part of what is signed, so a captured delivery cannot be replayed against you later without the age giving it away - reject anything more than five minutes old.
Hash the raw request bytes. Parsing the JSON and re-encoding it changes whitespace and key order, and the signature will never match.
Node
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// body must be the RAW request bytes, before any JSON parsing.
export function verify(secret, headers, body) {
const signature = headers['x-stockroom-signature'];
const timestamp = Number(headers['x-stockroom-timestamp']);
if (!signature || !Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body, or the signature can never match.
// app.post('/hooks/stockroom', express.raw({ type: 'application/json' }), handler)
PHP
<?php
const TOLERANCE_SECONDS = 300;
function stockroom_webhook_verify(string $secret, array $headers, string $body): bool
{
$signature = $headers['X-Stockroom-Signature'] ?? '';
$timestamp = (int) ($headers['X-Stockroom-Timestamp'] ?? 0);
if ($signature === '' || $timestamp === 0) {
return false;
}
if (abs(time() - $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
return hash_equals($expected, $signature);
}
// Laravel: $request->getContent() is the raw body. Exclude the route from
// CSRF and read headers with $request->header('X-Stockroom-Signature').
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(secret: str, headers, body: bytes) -> bool:
signature = headers.get("X-Stockroom-Signature", "")
try:
timestamp = int(headers.get("X-Stockroom-Timestamp", ""))
except ValueError:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask: request.get_data() is the raw body. Django: request.body.
# Read it before anything parses the JSON.
Retries, and when an endpoint switches off
A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, then once a day three more times. After about three and a half days of failures the subscription's status becomes disabled and the store owner gets one email.
A disabled endpoint keeps its history and its secret. The merchant re-enables it inSettings, then API, or you canPATCH /v2/webhooks/{id} with {"status": "active"}. The same endpoint also accepts "paused", which is the right state for a planned outage on your side - events still happen, they just are not delivered.
A paused or disabled endpoint also refuses tests and redeliveries, with422 webhook_not_active. Make it active again first.
Delivery is at-least-once, and unordered
- Deduplicate on
X-Stockroom-Event-Id(the same value asidin the body). A delivery can arrive twice - a timeout on your side that we count as a failure but your code finished processing is the common way. - Do not rely on order. Two events about the same record can arrive out of sequence. Compare
occurred_at, or the object's ownupdated_at, and discard what is older than what you already have. - Rapid changes fold. The same record changing many times inside five seconds - a 300-line receive, a bulk import - produces one
.updatedevent carrying the latest state, rather than one per change. Lifecycle events likepurchase_order.orderedand.receivednever fold: each one is news. - A burst is slowed, not dropped. Past 1,000 events in an hour a store's deliveries move to a slower lane, behind nothing a person is waiting on. An overnight import still reaches you in full; it arrives over a longer stretch.
Test and inspect
| Call | Does |
|---|---|
POST /v2/webhooks/{id}/test | Sends a webhook.test event now. Prove the endpoint and the signature check before you rely on either. |
GET /v2/webhooks/{id}/deliveries | The attempt log: event, attempt number, status code, duration, error, when the next attempt is due. |
POST /v2/webhooks/{id}/deliveries/{delivery_id}/redeliver | Sends that one event again. |
POST /v2/webhooks/{id}/redeliver | Catch up in bulk. Takes since (an ISO 8601 timestamp) or oneevent_id. |
since resends everything this endpoint subscribes to from that moment, newest 500, each as a fresh first attempt. That is the call to make after fixing your side - pick the moment your handler started failing, not the moment you noticed.
POST /v2/webhooks/3/redeliver
{ "since": "2026-09-12T02:00:00Z" }Events and the delivery log are both kept 30 days, so that is as far back as a catch-up can reach. Nothing you have not consumed within a month can be resent.
The merchant sees the same delivery log, with the same buttons, under Settings, then API.
The event catalogue
Purchase orders
purchase_order.created | A purchase order was created, in any state. |
purchase_order.updated | Anything about it changed. Folds inside five seconds. |
purchase_order.ordered | Placed with the supplier. The incoming quantity is on its way to Shopify. |
purchase_order.in_transit | Marked on the way. |
purchase_order.receipt_recorded | A delivery was received. Fires for a partial receipt too. |
purchase_order.received | Fully received. |
purchase_order.closed | Closed, and whatever was still outstanding was retracted. |
purchase_order.reopened | Reopened after being closed. |
purchase_order.archived | Archived. |
purchase_order.deleted | A draft was deleted. Only drafts can be. |
purchase_order.overdue | Past its expected date, from the daily scan. |
receipt.voided | A booked receipt was undone. Its stock was reversed. |
Suppliers
supplier.created | A supplier was created. |
supplier.updated | A supplier changed. Folds inside five seconds. |
supplier.archived | A supplier was archived. Nothing in Stockroom deletes a supplier. |
supplier.merged | Two or more suppliers became one. The payload is the survivor. |
Stock
stock_adjustment.updated | A draft adjustment changed. Folds inside five seconds. |
stock_adjustment.applied | An adjustment was applied. Stock moved. |
stock_adjustment.cancelled | A draft adjustment was cancelled. Nothing moved. |
item.low_stock | An item crossed the store's low-stock threshold. |
item.out_of_stock | An item reached zero. |
item.reorder_point | An item hit its reorder point. |
item.overstock | An item went above its maximum. |
Housekeeping
webhook.test | Only ever sent by Send test or the test endpoint, and only to the one endpoint being tested. Never fires on its own, and a catch-up bysince skips it. |
The four item.* events come from the store's own alert rules, so a store with no rules set up will never send them. They are per item and per location, and they fire on the crossing rather than every time the stock level moves.
Stocktakes, transfers, builds, lots and pick lists get events when those resources reach the API. New events are additive - a consumer subscribed to ["*"] starts receiving them, which is worth knowing if your handler throws on an event it does not recognise.