> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shoppex.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register endpoints, verify signatures, and handle retries

Shoppex sends an HTTP `POST` request when a subscribed event occurs.

This page covers normal event webhooks such as `order:paid`. For product delivery callbacks, read [Dynamic product delivery](/developers/dynamic-delivery).

## Create a webhook

You can create a webhook in **Settings → Webhooks**. You can also use the Developer API.

```bash theme={"system"}
curl https://api.shoppex.io/dev/v1/webhooks \
  -X POST \
  -H "Authorization: Bearer shx_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/shoppex",
    "events": ["order:paid", "order:cancelled"]
  }'
```

The create response contains the webhook ID and its secret. Store the secret when you create the webhook.

Each webhook has its own secret. A secret for one webhook cannot verify a delivery for another webhook.

## Event catalog

The event catalog contains these families:

* `order`
* `dispute`
* `product`
* `query`
* `feedback`
* `affiliate`
* `reseller`
* `subscription`
* `replacement`

Use `GET /dev/v1/webhooks/events` for the current allowlist. The endpoint is the source for valid event names.

See [Webhook events](/developers/webhook-events) for payload examples. Wildcard subscriptions are not supported.

## Request headers

Shoppex sends these headers with a normal event webhook:

| Header                             | Value                             |
| ---------------------------------- | --------------------------------- |
| `Content-Type`                     | `application/json`                |
| `User-Agent`                       | `Shoppex-Webhook/1.0`             |
| `X-Shoppex-Event`                  | The event name                    |
| `X-Shoppex-Delivery`               | The delivery ID                   |
| `X-Shoppex-Timestamp`              | A Unix timestamp in seconds       |
| `X-Shoppex-Signature-V2`           | `v1,t=<timestamp>,h=<sha256-hex>` |
| `X-Shoppex-Signature-V2-Algorithm` | `HMAC-SHA256`                     |
| `X-Shoppex-Signature`              | The legacy HMAC-SHA512 signature  |
| `X-Shoppex-Unescaped-Signature`    | A legacy compatibility signature  |

<Note>
  The `v1` token inside `X-Shoppex-Signature-V2` is the signature-scheme version.
  It is not the API version, and it is unrelated to the `/dev/v1` path. The header
  name remains `X-Shoppex-Signature-V2`.
</Note>

## Verify the V2 signature

Read the request body before you parse its JSON. Shoppex signs the exact body that it sends.

Build this message:

```text theme={"system"}
<deliveryId>.<timestamp>.<rawBody>
```

Compute its HMAC-SHA256 value with the webhook secret. Compare that value with the `h` value from `X-Shoppex-Signature-V2`.

Also compare the header's `t` value with `X-Shoppex-Timestamp`. Use a constant-time function for the signature comparison.

<CodeGroup>
  ```javascript Node.js theme={"system"}
  import crypto from 'node:crypto';

  function parseSignatureHeader(value) {
    const segments = value.split(',').map((part) => part.trim());
    if (!segments.includes('v1')) return null;

    const fields = Object.fromEntries(
      segments
        .filter((part) => part.includes('='))
        .map((part) => part.split('=', 2)),
    );

    if (!/^\d+$/.test(fields.t ?? '')) return null;
    if (!/^[0-9a-f]{64}$/i.test(fields.h ?? '')) return null;

    return { timestamp: fields.t, signature: fields.h.toLowerCase() };
  }

  export function verifyShoppexWebhook({
    rawBody,
    signatureHeader,
    deliveryId,
    timestampHeader,
    secret,
  }) {
    const parsed = parseSignatureHeader(signatureHeader);
    if (!parsed || !deliveryId || parsed.timestamp !== timestampHeader) return false;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${deliveryId}.${parsed.timestamp}.`, 'utf8')
      .update(rawBody)
      .digest();
    const received = Buffer.from(parsed.signature, 'hex');

    return received.length === expected.length
      && crypto.timingSafeEqual(received, expected);
  }
  ```

  ```php PHP theme={"system"}
  <?php
  function verifyShoppexWebhook(
      string $rawBody,
      string $signatureHeader,
      string $deliveryId,
      string $timestampHeader,
      string $secret
  ): bool {
      $segments = array_map('trim', explode(',', $signatureHeader));
      if (!in_array('v1', $segments, true)) {
          return false;
      }

      $fields = [];
      foreach ($segments as $segment) {
          if (str_contains($segment, '=')) {
              [$key, $value] = explode('=', $segment, 2);
              $fields[$key] = $value;
          }
      }

      $timestamp = $fields['t'] ?? '';
      $signature = strtolower($fields['h'] ?? '');
      if ($deliveryId === '' || $timestamp !== $timestampHeader) {
          return false;
      }
      if (!preg_match('/^[0-9a-f]{64}$/', $signature)) {
          return false;
      }

      $message = $deliveryId . '.' . $timestamp . '.' . $rawBody;
      $expected = hash_hmac('sha256', $message, $secret);

      return hash_equals($expected, $signature);
  }

  $rawBody = file_get_contents('php://input');
  ```

  ```python Python theme={"system"}
  import hashlib
  import hmac
  import re


  def verify_shoppex_webhook(
      raw_body: bytes,
      signature_header: str,
      delivery_id: str,
      timestamp_header: str,
      secret: str,
  ) -> bool:
      segments = [part.strip() for part in signature_header.split(",")]
      if "v1" not in segments:
          return False

      fields = dict(part.split("=", 1) for part in segments if "=" in part)
      timestamp = fields.get("t", "")
      signature = fields.get("h", "").lower()

      if not delivery_id or timestamp != timestamp_header:
          return False
      if re.fullmatch(r"[0-9a-f]{64}", signature) is None:
          return False

      prefix = f"{delivery_id}.{timestamp}.".encode("utf-8")
      expected = hmac.new(
          secret.encode("utf-8"),
          prefix + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

Do not verify a new JSON serialization of the parsed body. Whitespace or escaping changes will produce a different signature.

Store processed `X-Shoppex-Delivery` values. If Shoppex sends the same delivery again, return success without repeating the side effect.

<Warning>
  Webhook bodies can contain customer data and unredacted delivery content such as
  serial or license keys. Verify the signature before parsing or storing the body,
  restrict access to delivery logs, and avoid logging the raw request in production.
</Warning>

## Legacy signatures

`X-Shoppex-Signature` contains an HMAC-SHA512 value for the raw body. It does not include the delivery ID or timestamp.

`X-Shoppex-Unescaped-Signature` is another legacy header. It signs the body after escaped slashes change from `\/` to `/`.

New integrations must use `X-Shoppex-Signature-V2`.

## Response and retry behavior

Shoppex treats any `2xx` response as success. A non-`2xx` response or request error starts the retry flow.

Each request has a 30-second timeout. Shoppex makes up to five delivery attempts.

| Attempt | Delay before the attempt |
| ------- | ------------------------ |
| 1       | Immediate                |
| 2       | 2 minutes                |
| 3       | 4 minutes                |
| 4       | 8 minutes                |
| 5       | 16 minutes               |

The delay after a failed attempt is `2^attempts` minutes. After the fifth failure, Shoppex marks the delivery as failed.

Your endpoint can receive the same event more than once. Make event processing idempotent.

Standard order lifecycle payloads (`order:created`, `order:updated`,
`order:partial`, `order:paid`, `order:cancelled`, `order:disputed`, and their
`:product` variants) are frozen when the delivery is queued. Automated retries
and manual retries of that delivery reuse the same order state, including the
original `license_keys` and top-level `created_at`; later fulfillment changes
or replacement keys do not modify the queued event.

## Test a webhook

The test event must be part of the webhook subscription.

```bash theme={"system"}
curl https://api.shoppex.io/dev/v1/webhooks/WEBHOOK_ID/test \
  -X POST \
  -H "Authorization: Bearer shx_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"event":"order:paid"}'
```

The endpoint queues a test delivery. The response message is `Test webhook queued`.

## Rotate a secret

Rotate the secret for one webhook with this endpoint:

```bash theme={"system"}
curl https://api.shoppex.io/dev/v1/webhooks/WEBHOOK_ID/rotate-secret \
  -X POST \
  -H "Authorization: Bearer shx_your_api_key"
```

The response contains the new secret. Store it immediately. The new secret replaces the previous secret for that webhook.

## Inspect and retry deliveries

Use these endpoints to inspect delivery results:

* `GET /dev/v1/webhooks/logs`
* `GET /dev/v1/webhooks/logs/{id}`

Retry a failed delivery with `POST /dev/v1/webhooks/logs/{id}/retry`.

<CardGroup cols={2}>
  <Card title="Webhook events" icon="list" href="/developers/webhook-events">
    Read event names and payload examples.
  </Card>

  <Card title="Dynamic delivery" icon="truck-fast" href="/developers/dynamic-delivery">
    Read the callback contract for dynamic products.
  </Card>
</CardGroup>
