Webhooks
Webhooks
Instead of checking your orders for changes, you can have us send events to an HTTPS endpoint of yours. We notice changes within about a minute.
Events
| Event | When |
|---|---|
order.status_changed |
An order’s status changes. Payload |
order.shipment_added |
An order gets a new tracking number with the courier. Payload |
A webhook can get either event or both.
Setting up a webhook
We can set up your webhooks for you: tell us the URL and the events you want, and we’ll give you its signing secret. If we’ve turned it on for you, you can also manage them yourself with the webhooks endpoints. You can have up to five webhooks.
The URL must be HTTPS and reachable from the internet.
Payloads
Each event is a POST with a JSON body. Every event has the same shape: what happened in type, and in data the order as it was when we noticed, plus the field for that event type.
| Field | Type | |
|---|---|---|
id |
string | The event’s ID, such as evt_3cd3371cda5c4284bb7aae0a66fa4c98. Also sent as the webhook-id header. |
type |
string | order.status_changed or order.shipment_added. |
occurredAt |
string (date-time, UTC) | When we noticed the change, within about a minute of it happening. |
data.order |
Order | The order as it was at occurredAt. |
data.previousStatus |
string or null | For order.status_changed: the order’s status before. Null for other events. |
data.shipment |
Shipment or null | For order.shipment_added: the new consignment. Null for other events. |
Every field is always present: one that doesn’t apply is null, not left out.
order.status_changed
Sent when an order’s status changes. data.order.status is the new status and data.previousStatus the old one.
{
"id": "evt_3cd3371cda5c4284bb7aae0a66fa4c98",
"type": "order.status_changed",
"occurredAt": "2026-11-02T10:00:00Z",
"data": {
"order": {
"reference": "WEB-100234",
"status": "Dispatched",
"receivedAt": "2026-11-01T14:00:00Z",
"purchaser": { "name": "Siobhán Murphy", "email": "siobhan@example.com" },
"recipient": {
"name": "Mary Murphy",
"telephone": "+353 87 123 4567",
"address": { "line1": "12 Main Street", "line2": null, "city": "Dublin 6", "county": null, "postcode": "D06 A1B2", "country": "Ireland" }
},
"delivery": { "date": "2026-11-14", "earliestTime": "09:00", "latestTime": "13:00" },
"message": "Happy birthday",
"items": [{ "sku": "BQT-ROSE-12", "name": "A dozen red roses", "quantity": 2, "unitPrice": 49.9 }],
"shipments": [{ "carrier": "DPD", "trackingNumber": "15502000123456" }]
},
"previousStatus": "Received",
"shipment": null
}
}
You get the status the order has when we look, so it can skip statuses: an order that went from Received through InProgress and Packed to Dispatched between two checks arrives as one event from Received to Dispatched, as above. Don’t expect every status in turn.
order.shipment_added
Sent when an order gets a tracking number it didn’t have, once for each new one. data.shipment is the new consignment, and it’s also in data.order.shipments.
{
"id": "evt_6d56326f21b0494eaeaff963a55571e9",
"type": "order.shipment_added",
"occurredAt": "2026-11-02T10:00:00Z",
"data": {
"order": {
"reference": "WEB-100234",
"status": "Dispatched",
"receivedAt": "2026-11-01T14:00:00Z",
"purchaser": { "name": "Siobhán Murphy", "email": "siobhan@example.com" },
"recipient": {
"name": "Mary Murphy",
"telephone": "+353 87 123 4567",
"address": { "line1": "12 Main Street", "line2": null, "city": "Dublin 6", "county": null, "postcode": "D06 A1B2", "country": "Ireland" }
},
"delivery": { "date": "2026-11-14", "earliestTime": "09:00", "latestTime": "13:00" },
"message": "Happy birthday",
"items": [{ "sku": "BQT-ROSE-12", "name": "A dozen red roses", "quantity": 2, "unitPrice": 49.9 }],
"shipments": [{ "carrier": "DPD", "trackingNumber": "15502000123456" }]
},
"previousStatus": null,
"shipment": { "carrier": "DPD", "trackingNumber": "15502000123456" }
}
}
An order usually gets its tracking number as it’s dispatched, so expect this event alongside an order.status_changed to Dispatched, in either order.
The order
data.order is the same as getting the order:
| Field | Type | |
|---|---|---|
reference |
string | Your reference for the order. |
status |
string | Where the order has got to: one of the statuses. |
receivedAt |
string (date-time, UTC) or null | When we received the order. |
purchaser.name, purchaser.email |
string | The person who placed the order. |
recipient.name, recipient.telephone |
string | The person the order is for. |
recipient.address.line1, .country |
string | The first line of the delivery address, and its country. |
recipient.address.line2, .city, .county, .postcode |
string or null | The rest of the address, where you gave it. |
delivery.date |
string (date) | The delivery date, such as 2026-11-14. |
delivery.earliestTime, delivery.latestTime |
string or null | The delivery window in 24-hour Irish time, such as 09:00, where you gave one. |
message |
string or null | The card message. |
items[] |
array | What’s in the order: sku, name, quantity (a number) and unitPrice (a number, in euro). |
shipments[] |
array | The order’s consignments with the courier, empty until it’s dispatched: carrier (such as DPD, or null if we don’t know it) and trackingNumber. |
We may add fields to the body later, so ignore any you don’t recognise rather than refusing the event.
Headers
With each event come three headers, as in the Standard Webhooks specification:
| Header | |
|---|---|
webhook-id |
The event’s ID, the same as id in the body. |
webhook-timestamp |
When we sent it, in seconds since 1970 (UTC). |
webhook-signature |
v1, and the signature. |
Answering
Answer with any 2xx status within 10 seconds. Do the work afterwards if it takes longer: save the event and answer straight away.
Anything else, including a redirect or a timeout, is a failed delivery. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then stop. If every delivery to a webhook has failed for a day, we turn the webhook off. Turn it back on once your endpoint is fixed, then send the missed events again from its deliveries.
Checking the signature
Check every event’s signature before you trust it, so you know it came from us and wasn’t changed. The signature is an HMAC-SHA256 of the webhook-id, the webhook-timestamp and the raw body, joined with .. The key is your secret without its whsec_ prefix, base64-decoded. Also refuse events with a timestamp more than five minutes from now, so an old event can’t be sent to you again.
The Standard Webhooks libraries do all of this for you. Without one, in Node.js:
import crypto from "node:crypto";
// rawBody is the request body exactly as received, before parsing it.
function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return headers["webhook-signature"].split(" ").some((signature) => {
const [version, value] = signature.split(",");
const given = Buffer.from(value ?? "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
Or in C#:
using System.Security.Cryptography;
using System.Text;
static bool Verify(string secret, string id, string timestamp, string signatureHeader, string rawBody)
{
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false;
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);
var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"));
return signatureHeader.Split(' ').Any(signature =>
signature.StartsWith("v1,") &&
CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(signature[3..]), expected));
}
Duplicates and order
An event can arrive more than once, for example when your answer to the first delivery was lost, and events can arrive out of order. So:
- Use
webhook-idto skip events you’ve already handled. Every delivery of an event, including one you ask us to send again, has the same ID. - Don’t rely on the order events arrive in. Compare
occurredAt, or get the order when you need to be sure of its current state.