Menu

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

  1. Make an address that accepts a POST: public, HTTPS, answering within 10 seconds.
  2. In Settings → Developers → Webhooks, choose Add endpoint, enter the address and pick the events.
  3. Copy the endpoint's signing secret (whsec_...) to your server.
  4. Choose Test to send a webhook.test event, 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.object is the object as GET /v1/orders/{id} would show it when the event was sent, normally seconds after the change. Costs are never included.
  • product.deleted carries 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:

  1. Signed content: take {webhook-id}.{webhook-timestamp}.{raw body}, using the body exactly as received, before any JSON parsing.
  2. Signature: HMAC-SHA256 it with your secret's key: the part after whsec_, base64-decoded. Base64-encode the result.
  3. Compare: compare it, in constant time, with each v1,<signature> in webhook-signature, which holds one or more separated by spaces. Accept if any matches.
  4. Freshness: refuse a webhook-timestamp more 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 2xx within 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 *.updated event 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.