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

> Receive real-time event notifications via webhooks

## What are Webhooks?

Webhooks are HTTP callbacks that notify your server when events happen in Shoppex — a paid order, a new subscription, a dispute. Instead of polling the API, your server receives events in real time.

<Info>
  This page covers normal Shoppex event webhooks.
  `dynamic_webhook` for `DYNAMIC` products is a separate fulfillment callback contract. See [Dynamic Product Delivery](/api-reference/webhooks/dynamic-delivery).
</Info>

## Setting Up Webhooks

Configure webhook endpoints in the Dashboard:

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

  <Step title="Add a new endpoint">
    Click **Add Endpoint**
  </Step>

  <Step title="Enter your URL">
    Enter your endpoint URL
  </Step>

  <Step title="Select events">
    Select which events to receive
  </Step>
</Steps>

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

***

## 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 payload values are synthetic examples, but Shoppex signs the raw JSON body the same way as a real delivery.
</Note>

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

***

## Headers

Each webhook request includes these headers:

| Header                   | Description                                                   |
| ------------------------ | ------------------------------------------------------------- |
| `Content-Type`           | `application/json`                                            |
| `User-Agent`             | `Shoppex-Webhook/1.0`                                         |
| `X-Shoppex-Event`        | Event type (e.g., `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 ensure authenticity. New integrations should verify `X-Shoppex-Signature-V2`.
Shoppex signs `${deliveryId}.${timestamp}.${rawBody}` with **HMAC-SHA256**.
Your webhook handler should 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}`, 'utf8')
      .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');
  });
  ```

  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.

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

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

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

<Warning>
  `X-Shoppex-Signature` and `X-Shoppex-Unescaped-Signature` are legacy body-only HMAC-SHA512 headers.
  They remain available during the migration period, but new integrations should 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>

<Info>
  Per-payment callbacks created with the `webhook` field on `POST /dev/v1/payments` are different from global webhook endpoints. Their signing secret is returned once as `webhook_secret` in the payment creation response.
</Info>

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

***

## 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 on 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
# e.g., https://abc123.ngrok.io/webhooks/shoppex
```

***

## Retry Policy

If your endpoint returns an error (non-2xx status) or times out, Shoppex retries 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, the webhook is marked as failed. You can manually retry from the dashboard.

<Warning>
  Your endpoint must respond within **30 seconds** or the request will timeout and count as a failure.
</Warning>

***

## Best Practices

<AccordionGroup>
  <Accordion title="Return 200 quickly">
    Process webhooks asynchronously. Return `200 OK` immediately and handle the event in a background job.
  </Accordion>

  <Accordion title="Handle duplicates">
    Webhooks may be delivered more than once. Use the `X-Shoppex-Delivery` header to deduplicate.
  </Accordion>

  <Accordion title="Verify signatures">
    Always verify webhook signatures in production to prevent spoofing.
  </Accordion>

  <Accordion title="Use HTTPS">
    Always use HTTPS endpoints in production for security.
  </Accordion>
</AccordionGroup>

***

## Next Steps

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

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