# 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

```http
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=
```

```json
{
  "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](https://www.standardwebhooks.com/) 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.

```javascript
// 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
<?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;
}
```

```python
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.
