> ## 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

> Receive real-time notifications when events happen in your shop

Webhooks are HTTP callbacks that notify your server in real time when an event happens in Shoppex, such as a paid order, a new subscription, or a dispute.

<Note>
  This page covers normal Shoppex event webhooks like `order:paid` and `subscription:created`. If you build a `DYNAMIC` product with `dynamic_webhook`, read [Dynamic product delivery](/developers/dynamic-delivery) instead.
</Note>

## Setting up webhooks

<Steps>
  <Step title="Create an endpoint">
    Create an HTTP endpoint on your server that accepts `POST` requests.

    ```typescript theme={"system"}
    app.post('/webhooks/shoppex', (req, res) => {
      const event = req.body;
      // Handle event
      res.status(200).send('OK');
    });
    ```
  </Step>

  <Step title="Register the endpoint">
    Go to **Settings → Webhooks → Add Endpoint** and enter your URL.
  </Step>

  <Step title="Select events">
    Choose the event names you want, for example:

    * `order:paid`
    * `order:cancelled`
    * `subscription:created`

    If you use the Developer API, fetch the full allowlist from `GET /dev/v1/webhooks/events`.
  </Step>
</Steps>

<Tip>
  For local development, use a tunnel service such as [ngrok](https://ngrok.com) to expose your local server to the internet.
</Tip>

<Note>
  You can register webhooks through the dashboard or manage webhook subscriptions through the Developer API. The event payload is the same either way.
</Note>

<Note>
  Developer webhooks must target your own HTTP(S) endpoint. For Discord notifications, use **Notifications → Sales Alerts** instead of a Discord webhook URL.
</Note>

### Order events

| Event                     | Description                                  |
| ------------------------- | -------------------------------------------- |
| `order:paid`              | Order/invoice paid successfully              |
| `order:cancelled`         | Order cancelled or expired                   |
| `order:paid:product`      | Order paid (includes full product data)      |
| `order:cancelled:product` | Order cancelled (includes full product data) |

<Note>
  If a paid checkout reduces product stock, handle `order:paid` or `order:paid:product`. Shoppex does not send `product:stock` as a second event for every purchase-driven stock decrease. Use `product:stock` for direct catalog stock updates, for example when you edit stock through the dashboard or API.
</Note>

### Subscription events

| Event                    | Description              |
| ------------------------ | ------------------------ |
| `subscription:created`   | New subscription started |
| `subscription:cancelled` | Subscription cancelled   |

<Note>
  Shoppex supports more event names than these two tables show. Examples include `order:created`, `order:updated`, `order:partial`, `order:disputed`, `subscription:trial:started`, `subscription:updated`, `subscription:renewed`, `subscription:upcoming`, plus product, query, feedback, and affiliate events. See [Webhook events](/developers/webhook-events) or `GET /dev/v1/webhooks/events` for the full current list.
</Note>

## Webhook payload

All webhooks follow this structure:

```json theme={"system"}
{
  "event": "order:paid",
  "data": {
    "uniqid": "abc123def456",
    "type": "PRODUCT",
    "status": "COMPLETED",
    "gateway": "STRIPE",
    "total": 49.99,
    "total_display": 49.99,
    "currency": "USD",
    "exchange_rate": 1,
    "crypto_exchange_rate": 0,
    "crypto_gateway": null,
    "apm_method": "CARD",
    "customer_email": "customer@example.com"
  },
  "created_at": 1705318200
}
```

<Note>
  Dashboard test deliveries use the same `event` / `data` / `created_at` envelope as live deliveries. The values inside `data` are synthetic, but Shoppex signs the raw JSON body the same way as a real delivery.
</Note>

<Note>
  The top-level webhook `created_at` field is a Unix timestamp. Nested timestamps inside `data` can be ISO 8601 strings.
</Note>

<Note>
  Order webhooks can include payment-context fields such as `exchange_rate`, `crypto_exchange_rate`, `crypto_gateway`, and `apm_method`. These fields let you match and analyze payments without a follow-up invoice fetch in most cases.
</Note>

## Headers

Each webhook request includes these headers:

| Header                   | Description                                                   |
| ------------------------ | ------------------------------------------------------------- |
| `Content-Type`           | `application/json`                                            |
| `User-Agent`             | `Shoppex-Webhook/1.0`                                         |
| `X-Shoppex-Event`        | The event type, for example `order:paid`                      |
| `X-Shoppex-Timestamp`    | Unix timestamp in seconds, used in the V2 signature           |
| `X-Shoppex-Signature-V2` | Timestamped HMAC-SHA256 signature: `v1,t=<timestamp>,h=<hex>` |
| `X-Shoppex-Signature`    | Deprecated legacy HMAC-SHA512 body-only signature             |
| `X-Shoppex-Delivery`     | Unique delivery ID for deduplication                          |

## Signature verification

Always verify webhook signatures to confirm that a request came from Shoppex. New integrations must verify `X-Shoppex-Signature-V2`. Shoppex signs `${deliveryId}.${timestamp}.${rawBody}` with HMAC-SHA256. Your webhook handler must reject timestamps outside a 5-minute window.

<CodeGroup>
  ```typescript TypeScript theme={"system"}
  import crypto from 'crypto';

  function verifySignature(
    payload: string,
    signatureHeader: string,
    deliveryId: string,
    timestampHeader: string,
    secret: string,
  ): boolean {
    const segments = signatureHeader.split(',').map((part) => part.trim());
    const hasV1Marker = segments.includes('v1');
    const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => {
      const [key, value] = part.trim().split('=');
      return [key, value ?? ''];
    }));
    if (!deliveryId || !hasV1Marker || parts.t !== timestampHeader) return false;

    const timestamp = Number(parts.t);
    if (!Number.isFinite(timestamp)) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
    if (!/^[0-9a-f]{64}$/i.test(parts.h ?? '')) return false;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${deliveryId}.${parts.t}.${payload}`)
      .digest('hex');

    if (parts.h.length !== expected.length) return false;

    return crypto.timingSafeEqual(
      Buffer.from(parts.h, 'hex'),
      Buffer.from(expected, 'hex'),
    );
  }

  app.post('/webhooks/shoppex', (req, res) => {
    const signature = req.headers['x-shoppex-signature-v2'] as string;
    const deliveryId = req.headers['x-shoppex-delivery'] as string;
    const timestamp = req.headers['x-shoppex-timestamp'] as string;

    if (!verifySignature(req.rawBody, signature, deliveryId, timestamp, WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const { event, data } = req.body;

    switch (event) {
      case 'order:paid':
        handleOrderPaid(data);
        break;
      case 'order:cancelled':
        handleOrderCancelled(data);
        break;
      case 'subscription:created':
        handleSubscriptionCreated(data);
        break;
    }

    res.status(200).send('OK');
  });
  ```

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

  def verify_signature(payload: bytes, signature_header: str, delivery_id: str, timestamp_header: str, secret: str) -> bool:
      segments = [part.strip() for part in signature_header.split(",")]
      parts = dict(part.split("=", 1) for part in segments if "=" in part)
      if not delivery_id or "v1" not in segments or parts.get("t") != timestamp_header:
          return False
      if not re.fullmatch(r"[0-9a-fA-F]{64}", parts.get("h", "")):
          return False
      if abs(int(time.time()) - int(parts["t"])) > 300:
          return False
      expected = hmac.new(
          secret.encode(),
          f"{delivery_id}.{parts['t']}.".encode() + payload,
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(parts["h"], expected)
  ```
</CodeGroup>

Shoppex signs the full JSON request body exactly as sent on the wire. Simple example: verify `{"event":"order:paid","data":{...},"created_at":1775764170}`, not just the inner `data` object. Use the per-webhook secret from **Settings → Webhooks** for that endpoint.

Per-payment callbacks created with the `webhook` field on `POST /dev/v1/payments` use a different secret. Shoppex returns it once as `webhook_secret` in the payment creation response.

The `v1` segment in `X-Shoppex-Signature-V2` is a version marker, not the signature. Simple example: in `v1,t=1775764170,h=abc...`, verify only the `h` value.

Do not verify `JSON.stringify(req.body)` after you parse the request. Verify the raw body bytes from the incoming HTTP request instead.

<Warning>
  `X-Shoppex-Signature` and `X-Shoppex-Unescaped-Signature` are deprecated legacy body-only HMAC-SHA512 headers. They stay available during the migration period, but new integrations must use `X-Shoppex-Signature-V2`.
</Warning>

<Warning>
  Your webhook secret is available in **Settings → Webhooks** in the dashboard. Keep it secure and never expose it in client-side code.
</Warning>

## Response handling

Return `200 OK` as soon as you receive the request, then process the event in a background job. A slow synchronous handler risks the 30-second timeout described below.

Shoppex can deliver the same webhook more than once. Use the `X-Shoppex-Delivery` header to detect duplicates, and make your fulfillment logic idempotent.

One invoice can produce more than one payment attempt over time. For example, a customer can retry a payment or switch gateways. Treat the Shoppex invoice ID and event type as the durable signal, not a single provider session. Mark an order fulfilled when Shoppex reports the invoice as paid, not when you first see a provider-specific session ID.

Always verify signatures in production to prevent spoofing, and always use an HTTPS endpoint.

## Retry policy

If your endpoint returns an error (a non-2xx status) or times out, Shoppex retries the delivery automatically.

| Delivery        | Delay      |
| --------------- | ---------- |
| Initial attempt | Immediate  |
| Retry 1         | 2 minutes  |
| Retry 2         | 4 minutes  |
| Retry 3         | 8 minutes  |
| Retry 4         | 16 minutes |

After the 5th failed attempt, Shoppex marks the webhook as failed. You can retry it manually from the dashboard.

<Warning>
  Your endpoint must respond within 30 seconds. Shoppex times out the request and counts it as a failure after that.
</Warning>

## Testing webhooks

Use the dashboard to send test events.

<Steps>
  <Step title="Open webhook settings">
    Go to **Settings → Webhooks**.
  </Step>

  <Step title="Select your endpoint">
    Click your endpoint.
  </Step>

  <Step title="Send a test event">
    Click **Send Test Event**.
  </Step>

  <Step title="Choose an event type">
    Select an event type.
  </Step>
</Steps>

<Note>
  Test `order:*` deliveries include the main live fields you usually integrate against, for example `gateway`, `total`, `total_display`, `currency`, `exchange_rate`, `crypto_gateway`, `apm_method`, `customer_email`, and product context.
</Note>

For local development:

```bash theme={"system"}
# Start a tunnel
ngrok http 3000

# Use the generated URL as your webhook endpoint
# for example https://abc123.ngrok.io/webhooks/shoppex
```

## Migrating from the legacy signature

Some existing integrations still verify the legacy `X-Shoppex-Signature` header. Move them to `X-Shoppex-Signature-V2`, using the code sample in [Signature verification](#signature-verification) above.

`X-Shoppex-Signature-V2` brings two headers not shown in the headers table above.

| Header                             | Description                                                                 |
| ---------------------------------- | --------------------------------------------------------------------------- |
| `X-Shoppex-Signature-V2-Algorithm` | `HMAC-SHA256`                                                               |
| `X-Shoppex-Delivery-Id`            | Delivery ID for dynamic delivery webhooks, in place of `X-Shoppex-Delivery` |

<Note>
  Dynamic delivery webhooks send `X-Shoppex-Delivery-Id` instead of `X-Shoppex-Delivery`. See [Dynamic product delivery](/developers/dynamic-delivery) for the full dynamic delivery header set.
</Note>

<Steps>
  <Step title="Read the raw body">
    If your handler does not already do this, read the raw request body before you parse it as JSON.
  </Step>

  <Step title="Switch the verified header">
    Verify `X-Shoppex-Signature-V2` with the code sample above, instead of `X-Shoppex-Signature`.
  </Step>

  <Step title="Check the timestamp window">
    Reject requests where `X-Shoppex-Timestamp` falls outside a 5-minute window, as the code sample does.
  </Step>

  <Step title="Remove the legacy check">
    Remove code that verifies `X-Shoppex-Signature` or `X-Shoppex-Unescaped-Signature`.
  </Step>
</Steps>

The legacy `X-Shoppex-Signature` and `X-Shoppex-Unescaped-Signature` headers carry a body-only HMAC-SHA512 signature, with no timestamp. Shoppex keeps both available during the migration period. The timestamp in `X-Shoppex-Signature-V2` is what protects a migrated integration against replay.

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

  <Card title="Dynamic delivery" icon="truck-fast" href="/developers/dynamic-delivery">
    Deliver digital products in real time
  </Card>
</CardGroup>
