Receive webhooks
Instead of polling, let Picked tell you when something happens: an order arrives, is picked or ships, or a product's stock changes. Picked POSTs a signed JSON event to your URL within seconds, and keeps trying for about a day if your endpoint is down.
Add an endpoint
In Picked, open Settings → Developers → Webhooks (you need the webhooks.manage permission, which owners and admins have) and add your URL and the events it should receive.
- The URL must be public
https. Picked never calls internal or private addresses, checks again before every delivery, and doesn't follow redirects: answer at the URL itself. - Picked shows the endpoint's signing secret (
whsec_…) once. Store it; you need it to verify events. Rotate secret gives you a new one. - Send test event delivers a
pingevent and shows your endpoint's answer.
Events
| Event | When |
|---|---|
order.imported |
A new order arrived as work, from any channel (your shop or ERP, the API, a CSV, or by hand). An order that arrives already shipped or cancelled is not announced. |
order.picked |
The order's picking finished. |
order.ready_for_pickup |
A pickup order is ready to collect. |
order.shipped |
The order left the building: its carrier shipment was handed over, or a pickup or own-delivery order was handed over. |
order.cancelled |
The order was cancelled. |
stock.changed |
A product's sellable or available stock changed in a warehouse. |
What you receive
Each event is a POST with a JSON body. data is exactly what the matching read returns: GET /api/v1/orders/{order} for order events, the stock level GET /api/v1/stock returns for that SKU for stock.changed. It's built when the event happens, so a retry delivers what happened, not the state at delivery.
{
"id": "evt_01JAB3XK8Q2V7N0R5T9W4YH6CD",
"type": "order.shipped",
"occurred_at": "2026-10-17T14:02:11Z",
"data": {
"id": 48211,
"external_id": "WS-2026-104233",
"status": "shipped",
"fulfilment_method": "carrier",
"shipments": [
{ "carrier": "postnl", "tracking_number": "3SPCKD0104233" }
],
"_links": { "self": { "href": "/api/v1/orders/48211" } }
},
"_links": { "self": { "href": "/api/v1/orders/48211" } }
}
The headers follow the Standard Webhooks spec:
| Header | |
|---|---|
webhook-id |
The event's id. The same on every retry and on a redelivery, so use it to ignore an event you already handled. |
webhook-timestamp |
Unix seconds of this attempt. Reject an event much older than a few minutes. |
webhook-signature |
v1, and a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. |
Answer with any 2xx status within 10 seconds, and do the slow work afterwards. Picked keeps the first 1 KB of your answer for the deliveries list, and gives up on an answer over 1 MB.
Verify the signature
Use a Standard Webhooks library (PHP, Node, C# and more), or verify it yourself. Always use the raw request body, before any JSON parsing.
<?php
$secret = getenv('PICKED_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input');
$id = $_SERVER['HTTP_WEBHOOK_ID'];
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'];
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
$valid = false;
foreach (explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE']) as $given) {
$valid = $valid || hash_equals($expected, $given);
}
$valid = $valid && abs(time() - (int) $timestamp) <= 300;
http_response_code($valid ? 204 : 401);
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, headers, rawBody) {
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false;
}
return headers['webhook-signature'].split(' ').some((signature) => {
const given = Buffer.from(signature.replace(/^v1,/, ''));
const wanted = Buffer.from(expected);
return given.length === wanted.length && timingSafeEqual(given, wanted);
});
}
using System.Security.Cryptography;
using System.Text;
static bool Verify(string secret, string id, string timestamp, string signatures, string rawBody)
{
var key = Convert.FromBase64String(secret["whsec_".Length..]);
using var hmac = new HMACSHA256(key);
var expected = "v1," + Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}")));
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false;
return signatures.Split(' ').Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), Encoding.UTF8.GetBytes(expected)));
}
Retries and switching off
- A delivery that doesn't get a
2xxis tried again after 1 minute, 5 minutes, 30 minutes, 1 hour, 2 hours, 6 hours and 12 hours: 8 tries over about 22 hours. Every attempt, with its status code and the start of your response, is in the endpoint's Deliveries list for 30 days. - Redeliver in that list sends the same event again, with the same
webhook-id. - An endpoint switches itself off, and the people who manage webhooks are told, when it answers
410 Gone, when its address starts pointing to an internal network, or when its deliveries fail 50 times in a row or for 3 days. Fix it, then press Switch back on. - Events can arrive more than once and, across retries, out of order. Use
webhook-idto de-dupe andoccurred_atto order; when in doubt, read the current state with the_links.selfURL.