StockroomAPIOpen the app
Guide

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 asid in 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 own updated_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 .updated event carrying the latest state, rather than one per change. Lifecycle events likepurchase_order.ordered and .received never 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

CallDoes
POST /v2/webhooks/{id}/testSends a webhook.test event now. Prove the endpoint and the signature check before you rely on either.
GET /v2/webhooks/{id}/deliveriesThe attempt log: event, attempt number, status code, duration, error, when the next attempt is due.
POST /v2/webhooks/{id}/deliveries/{delivery_id}/redeliverSends that one event again.
POST /v2/webhooks/{id}/redeliverCatch 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.createdA purchase order was created, in any state.
purchase_order.updatedAnything about it changed. Folds inside five seconds.
purchase_order.orderedPlaced with the supplier. The incoming quantity is on its way to Shopify.
purchase_order.in_transitMarked on the way.
purchase_order.receipt_recordedA delivery was received. Fires for a partial receipt too.
purchase_order.receivedFully received.
purchase_order.closedClosed, and whatever was still outstanding was retracted.
purchase_order.reopenedReopened after being closed.
purchase_order.archivedArchived.
purchase_order.deletedA draft was deleted. Only drafts can be.
purchase_order.overduePast its expected date, from the daily scan.
receipt.voidedA booked receipt was undone. Its stock was reversed.

Suppliers

supplier.createdA supplier was created.
supplier.updatedA supplier changed. Folds inside five seconds.
supplier.archivedA supplier was archived. Nothing in Stockroom deletes a supplier.
supplier.mergedTwo or more suppliers became one. The payload is the survivor.

Stock

stock_adjustment.updatedA draft adjustment changed. Folds inside five seconds.
stock_adjustment.appliedAn adjustment was applied. Stock moved.
stock_adjustment.cancelledA draft adjustment was cancelled. Nothing moved.
item.low_stockAn item crossed the store's low-stock threshold.
item.out_of_stockAn item reached zero.
item.reorder_pointAn item hit its reorder point.
item.overstockAn item went above its maximum.

Housekeeping

webhook.testOnly 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.