Webhooks
Webhooks tell your integration when to look. When a subscribed moment occurs in Pickware, an order is shipped completely, a goods receipt is completed, a product runs out of stock, we send a small signed HTTP POST to a URL you registered. The notification carries the ids of the affected records, never their data: fetch the current state through the API, which may already be newer than the moment that woke you.
Getting started
- Your token needs the
webhook_subscriptions:writescope to manage subscriptions, plus the read scope of the subject type of every topic you subscribe to. Subscribing toproduct/out-of-stockneedsproducts:read, because that is what dereferencing the ids in the notification requires. - You create a subscription for one topic and one destination URL. The response contains the signing secret exactly once.
- Before anything is delivered, your endpoint has to consent to receiving webhooks. Until it does, delivery is suspended and notifications wait within their retry window.
- Pickware delivers notifications, retries failures, and eventually gives up on a notification or disables a subscription whose endpoint keeps failing. The subscription's status tells you what happened.
Topics
A topic names a moment as <subject>/<moment>, both parts in kebab-case. The subject is the GraphQL type of the records the notification is about. The moment is some specific event happening to the subject, such as an order being shipped. Topics are always bound to a specific business event, there are not topics for generic entity updates.
These are the topics you can subscribe to in API version 2026-06-unstable:
| Topic | Subject | Required scope | Fires when |
|---|---|---|---|
goods-receipt/completed | GoodsReceipt | goods_receipts:read | A goods receipt is completed and its quantities are booked into stock. |
order/completely-shipped | Order | orders:read | A shipment closes the last open quantity of the order. |
order/partially-shipped | Order | orders:read | A shipment leaves quantities of the order open. |
product/out-of-available-stock | Product | products:read | The available stock of a product — what customers can still buy — drops from positive to zero or below. A product whose warehouses are empty fires product/out-of-stock as well. A product that is restocked and runs out again fires again. |
product/out-of-stock | Product | products:read | The physical stock of a product — the units actually counted in the warehouses — drops from positive to zero. A product that is restocked and runs out again fires again. Stock that is still in the warehouse but no longer sellable is product/out-of-available-stock instead. |
The reference page of every subject type lists the topics it can be the subject of. Creating a subscription with an unknown topic fails with a BAD_REQUEST error that lists the reason.
Creating a subscription
Use createWebhookSubscription to create a subscription attached to your token.
mutation SubscribeToShippedOrders($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
id
topic
consentState
signingSecret
}
}
{
"input": {
"topic": "order/completely-shipped",
"destination": {
"http": { "url": "https://hooks.example.com/pickware" }
}
}
}
The URL must use https, carry no credentials, query string or fragment, and name a hostname whose DNS answers are all publicly routable. IP-address literals, loopback, private, link-local and other special-use hosts are rejected, and redirects are never followed.
The response is the only place the signingSecret is returned. Store it with your endpoint configuration, every later read of the subscription returns null for the field. A lost secret means deleting the subscription and creating it again.
A token holds at most 100 subscriptions. Creating one more fails with a CONFLICT error until you delete one.
Webhooks are part of the merchant's plan. On a plan without them, creating, re-activating and deleting subscriptions fails with a FORBIDDEN error whose requiredFeature extension names the missing feature.
Creating a subscription fails with an UPSTREAM_UNAVAILABLE error when Pickware cannot register it with its webhook delivery service, and the message tells the two causes apart. When it says the delivery service did not answer, retry with exponential backoff. When it says the shop is not connected to the webhook delivery service yet, the shop's registration with that service was refused: retry later, and contact Pickware support if it persists.
A new subscription always starts with consentState: Pending, because Pickware performs the consent handshake after the subscription is created, not within the request. Poll webhookSubscriptions until the state is Verified, which usually takes a minute or two. Refused or Lost means your endpoint did not consent, see Endpoint consent. Nothing is delivered before the state is Verified.
Further destination types like Pub/Sub are planned for the future.
What a notification looks like
Pickware sends a POST with a JSON body:
{
"eventId": "018f2c6e6f0f7c3e9a2b4d5e6f708192",
"topic": "order/completely-shipped",
"occurredAt": "2026-09-03T09:12:03.123Z",
"subjects": [
{ "type": "Order", "id": "T3JkZXI6MDE4ZjJjNmU2ZjBmN2MzZTlhMmI0ZDVlNmY3MDgxOTI" }
]
}
eventIdidentifies this occurrence. Deliveries are at-least-once, so deduplicate on it.subjects[].idis the global id of the record, ready for thenode(id:)query or the type's own lookup query.subjects[].typeis the GraphQL type name.
Every notification carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Pickware-Webhooks/1 (+https://pickware.dev/docs/webhooks) |
WebHook-Request-Origin | pickware.com |
X-Pickware-Webhook-Subscription-Id | The raw id of the subscription the request belongs to, 32 lowercase hex characters. It is not the global id the API returns for the subscription, which is the URL-safe base64 encoding of WebhookSubscription:<raw id> without padding. |
X-Pickware-Webhook-Event-Id | Same as eventId in the body. |
X-Pickware-Webhook-Topic | Same as topic in the body. |
X-Pickware-Timestamp | Unix time in milliseconds when the request was signed. |
X-Pickware-Signature | v1= followed by the HMAC described below. |
Answer with any 2xx status once you have stored the notification. Do the actual work afterwards, not inside the request: Pickware waits at most 10 seconds for a response.
Verifying the signature
Every request is signed with the subscription's signing secret, a 32-byte key encoded as 64 lowercase hex characters. Compute
HMAC-SHA256(key = hex-decoded secret, message = <timestamp> "." <METHOD> " " <path-and-query> "\n" <raw body>)
and compare v1=<lowercase hex digest> with the X-Pickware-Signature header using a constant-time comparison. <timestamp> is the X-Pickware-Timestamp header in milliseconds, <METHOD> the uppercase HTTP method, <path-and-query> the request path including the query string, and <raw body> the body bytes exactly as received (empty for the consent GET). Reject a timestamp outside a ±5 minute window before you compute anything, so a captured request cannot be replayed later.
The example below is for an Express app. It takes the body as the raw Buffer, for example from express.raw({ type: 'application/json' }), because a body that was parsed and serialized again no longer matches the signature; pass an empty Buffer for the consent GET. It signs req.originalUrl, which keeps the full path and query string even inside a mounted router, and it compares the byte lengths first, because timingSafeEqual throws on buffers of different length.
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_TIMESTAMP_SKEW_IN_MILLISECONDS = 5 * 60 * 1000;
export function verifyPickwareWebhook(req, rawBody, signingSecret) {
const timestamp = req.get('x-pickware-timestamp') ?? '';
if (!/^\d+$/.test(timestamp)
|| Math.abs(Date.now() - Number(timestamp)) > MAX_TIMESTAMP_SKEW_IN_MILLISECONDS) {
return false;
}
const expected = Buffer.from(`v1=${createHmac('sha256', Buffer.from(signingSecret, 'hex'))
.update(`${timestamp}.${req.method} ${req.originalUrl}\n`)
.update(rawBody)
.digest('hex')}`);
const actual = Buffer.from(req.get('x-pickware-signature') ?? '');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
Retries and failures
| Your endpoint answers | What Pickware does |
|---|---|
Any 2xx | The notification is delivered. |
410 Gone | The subscription is retired for good (active: false, disabledReason: EndpointGone) and its pending notifications are dropped. Use it to say "stop sending this", and create a new subscription for a new endpoint. |
429 Too Many Requests | The next attempt honours your Retry-After header, in seconds or as an HTTP date, clamped to between 1 second and 1 hour. Without the header, the notification is retried like any other failure. |
| Any other status, a timeout, or a connection error | The notification is retried: one minute after the first failure, then doubling up to once per hour, for up to 72 hours after the moment occurred. Then it is abandoned. |
After 10 consecutive abandoned notifications, the subscription is disabled automatically (active: false, disabledReason: AutoDisabled) and its pending notifications are dropped. Re-enable it with reactivateWebhookSubscription once your endpoint is healthy: the mutation asks your endpoint for consent again. Every automatic disable starts a cooldown during which the mutation fails with a CONFLICT error whose reactivationBlockedUntil extension says when re-activation is possible again: 10 minutes after the first automatic disable, then 1 hour, 6 hours and 24 hours with each further one. The escalation resets after a week without an automatic disable. reactivationBlockedUntil on the subscription tells you the same. If you accidentally got stuck in a long cooldown contact support.
Reconciliation
A notification your endpoint could not receive within its 72-hour retry window is gone, and so are the notifications that were pending when a subscription was disabled. Whenever you were unable to receive for longer than that, catch up with an ordinary GraphQL query on the subject type. The table below names the query for every topic.
| Topic | Reconcile with |
|---|---|
order/completely-shipped | orders with filter: { updatedAt: { after: <checkpoint> } }, keeping the orders whose deliveryState is shipped. |
order/partially-shipped | orders with filter: { updatedAt: { after: <checkpoint> } }, keeping the orders whose deliveryState is shipped_partially. |
goods-receipt/completed | goodsReceipts with filter: { goodsReceiptDateTime: { after: <checkpoint> } }, keeping the receipts whose state is completed. |
product/out-of-available-stock | products with filter: { availableStock: { lte: 0 } }, which needs no checkpoint at all. |
product/out-of-stock | products, keeping the products whose physicalStock is zero or below. physicalStock cannot be filtered on yet, so this reconciliation reads the catalogue rather than a filtered page. |
The pattern is the same for all of them: keep a checkpoint timestamp per topic, query the subject type for records that moved since your checkpoint, page through the result with the list conventions, keep the ones in the state the topic asserts, and move the checkpoint forward once you are done. Because the query returns current state, it also collapses several missed occurrences of the same record into the one answer you actually need.
Reconcile after an outage on your side, after re-activating a disabled subscription or a deactivated token, and once when you subscribe to a topic for records that already exist. A nightly reconciliation run is a reasonable safety net even when nothing went wrong.
Managing subscriptions
- webhookSubscriptions lists the subscriptions of your token, and webhookSubscription loads one by global ID. Besides
active,disabledReasonandconsentState, the delivery counters and timestamps show how delivery is going: what was delivered and abandoned, how many notifications were abandoned in a row, when the last one arrived, and the status code, message and time of the last failed attempt. - deleteWebhookSubscription removes a subscription and drops its pending notifications.
- Deactivating the token pauses its subscriptions: moments that occur while it is inactive are not notified, and notifications still waiting to be sent may be dropped rather than delivered late. Activating the token again resumes the subscriptions with their signing secrets; reconcile afterwards to catch up on what you missed.
- Deleting the token cancels its subscriptions for good.
The delivery promise
Every notification is delivered at least once while its subscription is active and its token is active, in no guaranteed order, and possibly delayed. Deduplicate on eventId. A notification tells you when to look, the API tells you what is true. Always treat the state you fetch back as current, it may already be newer than the event that woke you.
We retry for 72 hours after the event occurred, with increasing intervals, and then abandon the notification. After ten consecutive abandoned notifications the subscription is disabled and its pending notifications are dropped. When you have been unable to receive for longer than that, or after your subscription was disabled, reconcile with the GraphQL query documented for the topic.
No latency SLA is given. First attempts normally leave within a second.
Delivery also requires your endpoint to keep consenting. We verify the consent handshake before the first delivery and re-check it periodically. While it does not succeed, delivery is suspended and notifications wait within their 72-hour window.
Deliberately not promised: ordering. Per-subscription serial delivery makes it nearly true in practice, but retries break it.