Webhooks
Webhooks send changes to your server as they happen: a POST to an address you choose, with the object as the API shows it.
Setting up
- Make an address that accepts a POST: public, HTTPS, answering within 10 seconds.
- In Settings → Developers → Webhooks, choose Add endpoint, enter the address and pick the events.
- Copy the endpoint's signing secret (
whsec_...) to your server. - Choose Test to send a
webhook.testevent, and check the delivery log.
A store can have up to 10 endpoints. Addresses on private or local networks are refused.
Events
| Event | Sent when |
|---|---|
order.created |
An order is placed, from any channel |
order.updated |
Anything about an order changes |
order.paid |
An order is paid |
order.fulfilled |
An order is paid and handed over |
order.cancelled |
An order is cancelled |
order.refunded |
Goods from an order are returned and refunded |
product.created |
A product is added |
product.updated |
A product or its variants, images, options, categories or collections change |
product.deleted |
A product is deleted |
inventory.updated |
A variant's stock, reservations or tracking change |
customer.created |
A customer is added |
customer.updated |
A customer's details, addresses or tags change |
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Nepzio-Webhooks/1.0 (+https://developers.nepzio.com/webhooks)
webhook-id: 01a1c4f2-5b8d-7e3a-9c41-2f6d8e0b1a73
webhook-timestamp: 1794290100
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "01a1c4f2-5b8d-7e3a-9c41-2f6d8e0b1a73",
"type": "order.paid",
"api_version": "v1",
"created_at": "2026-11-10T08:15:00Z",
"store_id": "01a093ee-872c-73a3-9edd-e1f8f228e967",
"data": { "object": { "id": "...", "object": "order", "...": "..." } }
}
data.objectis the object asGET /v1/orders/{id}would show it when the event was sent, normally seconds after the change. Costs are never included.product.deletedcarries only{"id", "object": "product", "deleted": true}.
Verifying a webhook
Anyone can POST to your address, so check every request before acting on it. Nepzio signs the Standard Webhooks way, so its libraries for your language verify it with your secret. To do it yourself:
- Signed content: take
{webhook-id}.{webhook-timestamp}.{raw body}, using the body exactly as received, before any JSON parsing. - Signature: HMAC-SHA256 it with your secret's key: the part after
whsec_, base64-decoded. Base64-encode the result. - Compare: compare it, in constant time, with each
v1,<signature>inwebhook-signature, which holds one or more separated by spaces. Accept if any matches. - Freshness: refuse a
webhook-timestampmore than 5 minutes from now, so an old request can't be replayed.
// Node.js
import crypto from 'node:crypto';
export 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((part) => {
const given = Buffer.from(part.replace(/^v1,/, ''), 'base64');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
<?php
function verifyNepzioWebhook(string $secret, array $headers, string $rawBody): bool
{
$id = $headers['webhook-id'];
$timestamp = $headers['webhook-timestamp'];
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
foreach (explode(' ', $headers['webhook-signature']) as $part) {
if (hash_equals($expected, substr($part, strlen('v1,')))) {
return true;
}
}
return false;
}
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, part.removeprefix("v1,"))
for part in headers["webhook-signature"].split(" ")
)
Answering
- Answer
2xxwithin 10 seconds. Do slow work afterwards, from a queue. Redirects aren't followed. - Expect repeats: delivery is at least once. Use
webhook-id, the same on every retry, to ignore an event you have already handled. - Don't rely on order: events can arrive out of order. When the latest state matters, fetch the object, or compare its
updated_at. - Bursts are merged: several updates to one object within a few seconds may arrive as one
*.updatedevent with the latest state.
Retries
A delivery that doesn't get a 2xx in time is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: about a day and a half.
If every delivery to an endpoint fails for 5 days, the endpoint is switched off and the store's owners and admins are emailed. Switch it back on in Settings → Developers once it answers again.
The delivery log there shows the last 30 days: what was sent, what your server answered, and Send again.
Rotating the secret
Rotate gives the endpoint a new secret. For 24 hours each request is signed with both the old and the new secret, so your server keeps verifying while you replace it.