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

# Dynamic Product Delivery

> The callback contract used by DYNAMIC products via dynamic_webhook

## Overview

`dynamic_webhook` is not a normal Shoppex event webhook. It is a direct server-to-server callback that Shoppex sends when a `DYNAMIC` product is being fulfilled after a paid order.

Here's how it works:

<Steps>
  <Step title="Create the product">
    You create a product with `type: "DYNAMIC"`, set `dynamic_webhook`, and keep the generated dynamic webhook signing secret.
  </Step>

  <Step title="Customer pays">
    A customer pays for that product.
  </Step>

  <Step title="Shoppex calls your endpoint">
    Shoppex sends `POST` to your `dynamic_webhook` URL.
  </Step>

  <Step title="Your server responds">
    Your server returns the dynamic delivery data.
  </Step>

  <Step title="Shoppex stores the result">
    Shoppex stores that response in the delivered items for the invoice.
  </Step>
</Steps>

<Info>
  This page covers the callback contract for `dynamic_webhook`.
  For normal Shoppex event webhooks like `order:paid`, see [Webhooks Overview](/api-reference/webhooks/overview) and [Webhook Events](/api-reference/webhooks/events).
</Info>

## When Shoppex Calls It

Shoppex calls the `dynamic_webhook` URL during product fulfillment after the invoice reaches a paid/completed state. The customer buys your dynamic product, Shoppex marks the invoice as paid, starts fulfillment, calls your endpoint, and saves your response into the invoice delivery details.

## Request

Shoppex sends:

* Method: `POST`
* Content-Type: `application/json`
* Body: JSON payload with invoice, product, shop, and line item data

### Headers

| Header                             | Description                                                   |
| ---------------------------------- | ------------------------------------------------------------- |
| `Content-Type`                     | Always `application/json`                                     |
| `X-Shoppex-Idempotency-Key`        | Stable delivery key for deduplication                         |
| `X-Shoppex-Delivery-Id`            | Same value as the idempotency key                             |
| `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-V2-Algorithm` | `HMAC-SHA256` when a v2 signature is present                  |
| `X-Shoppex-Signature`              | Deprecated legacy HMAC-SHA512 body-only signature             |
| `X-Shoppex-Signature-Algorithm`    | Deprecated legacy value `HMAC-SHA512`                         |

## Signing Secret

Dynamic Product Delivery has its own signing secret on the product. It is separate from normal Shoppex event webhook secrets.

* Normal event webhooks: create the endpoint in **Settings -> Webhooks** and use that endpoint secret.
* Dynamic product delivery: set `dynamic_webhook` on the product and use the product's dynamic webhook signing secret.

When creating or updating a dynamic product through the Developer API, pass `dynamic_webhook_secret` to set your own secret. If you set `dynamic_webhook` without a secret, Shoppex generates one and returns it in `dynamic_webhook_secret` in that create or update response. Store it immediately.

If the product has a signing secret, Shoppex signs `${deliveryId}.${timestamp}.${rawBody}` and sends the digest in `X-Shoppex-Signature-V2`.
Reject timestamps outside a 5-minute window.

Simple example:

```typescript theme={"system"}
import crypto from 'crypto';

function verifyShoppexSignature(
  rawBody: string,
  signatureHeader: string | undefined,
  deliveryId: string | undefined,
  timestampHeader: string | undefined,
) {
  if (!signatureHeader || !deliveryId || !timestampHeader) return false;
  const segments = signatureHeader.split(',').map((part) => part.trim());
  const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => {
    const [key, value] = part.trim().split('=');
    return [key, value ?? ''];
  }));
  if (!segments.includes('v1') || 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', process.env.SHOPPEX_DYNAMIC_WEBHOOK_SECRET!)
    .update(`${deliveryId}.${parts.t}.${rawBody}`)
    .digest('hex');

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

<Warning>
  `X-Shoppex-Signature` is retained as a legacy HMAC-SHA512 body-only header during migration.
  New dynamic delivery handlers should verify `X-Shoppex-Signature-V2`.
</Warning>

### Recommended Deduplication Rule

Treat `X-Shoppex-Idempotency-Key` as the durable fulfillment key. If the first request times out and Shoppex retries, both requests carry the same idempotency key — your server should return the same result instead of issuing a second token or license. This is the most common source of bugs in dynamic delivery integrations.

## Payload Shape

The payload contains both camelCase and snake\_case for the most important fields.
This is intentional so simple handlers do not need a translation layer first.

### Top-Level Example

```json theme={"system"}
{
  "customerEmail": "buyer@example.com",
  "customer_email": "buyer@example.com",
  "productTitle": "Pro Pack",
  "product_title": "Pro Pack",
  "productType": "DYNAMIC",
  "product_type": "DYNAMIC",
  "quantity": 1,
  "variantId": "var_123",
  "variant_id": "var_123",
  "variantTitle": "Lifetime",
  "variant_title": "Lifetime",
  "customFields": {
    "discord_username": "tetra"
  },
  "custom_fields": {
    "discord_username": "tetra"
  },
  "invoice": {
    "id": "inv_db_123",
    "uniqid": "inv_123",
    "status": "COMPLETED",
    "type": "PRODUCT",
    "customer_email": "buyer@example.com",
    "currency": "USD",
    "subtotal": "29.99",
    "discount": "0.00",
    "tax": "0.00",
    "total": "29.99",
    "country": "US",
    "custom_fields": {
      "discord_username": "tetra"
    },
    "created_at": "2026-03-24T13:00:00.000Z",
    "updated_at": "2026-03-24T13:01:00.000Z"
  },
  "product": {
    "id": "prod_db_123",
    "uniqid": "prod_123",
    "title": "Pro Pack",
    "type": "DYNAMIC",
    "subtype": null,
    "price": "29.99",
    "price_display": "29.99",
    "currency": "USD"
  },
  "shop": {
    "id": "shop_db_123",
    "name": "Example Shop"
  },
  "line_item": {
    "id": "line_item_123",
    "quantity": 1,
    "product_id": "prod_db_123",
    "product_title": "Pro Pack",
    "product_type": "DYNAMIC",
    "variant_id": "var_123",
    "variant_title": "Lifetime",
    "unit_price": "29.99",
    "total": "29.99",
    "custom_fields": {
      "discord_username": "tetra"
    },
    "addons": [],
    "metadata": {},
    "bundle_config": {}
  },
  "invoiceId": "inv_123",
  "invoice_id": "inv_123",
  "invoiceDbId": "inv_db_123",
  "invoice_db_id": "inv_db_123",
  "productId": "prod_db_123",
  "product_id": "prod_db_123",
  "shopId": "shop_db_123",
  "shop_id": "shop_db_123",
  "deliveryId": "dynamic:inv_123:prod_db_123",
  "delivery_id": "dynamic:inv_123:prod_db_123",
  "idempotencyKey": "dynamic:inv_123:prod_db_123",
  "idempotency_key": "dynamic:inv_123:prod_db_123"
}
```

### Important Fields

| Field                                | Type   | Description                  |
| ------------------------------------ | ------ | ---------------------------- |
| `invoiceId` / `invoice_id`           | string | Public Shoppex invoice ID    |
| `invoiceDbId` / `invoice_db_id`      | string | Internal invoice row ID      |
| `productId` / `product_id`           | string | Internal product row ID      |
| `shopId` / `shop_id`                 | string | Internal shop row ID         |
| `deliveryId` / `delivery_id`         | string | Stable delivery identifier   |
| `idempotencyKey` / `idempotency_key` | string | Stable idempotency key       |
| `invoice`                            | object | Invoice snapshot             |
| `product`                            | object | Product snapshot             |
| `shop`                               | object | Shop snapshot                |
| `line_item`                          | object | Fulfilled line item snapshot |

## Response

Your endpoint should return `2xx` and JSON.

Recommended response:

```json theme={"system"}
{
  "data": {
    "service_text": "Join the private server with the token below.",
    "dynamic_response": {
      "token": "dyn_123",
      "expires_at": "2026-12-31T23:59:59.000Z"
    },
    "deliveryType": "DYNAMIC",
    "count": 1
  }
}
```

### What Shoppex Accepts

Shoppex accepts these response forms:

* A JSON object
* A JSON object with a nested `data` object
* A non-empty string

If you return a JSON object with `data`, Shoppex stores the nested `data` object.

```json theme={"system"}
{
  "data": {
    "service_text": "Use this token in the bot.",
    "dynamic_response": {
      "token": "dyn_123"
    }
  }
}
```

### What Shoppex Stores

Shoppex normalizes your response into delivered items:

```json theme={"system"}
{
  "service_text": "Use this token in the bot.",
  "dynamic_response": {
    "token": "dyn_123"
  },
  "deliveryType": "DYNAMIC",
  "count": 1
}
```

If you return a plain string, Shoppex stores it as `dynamic_response`.

If you return an empty body, Shoppex stores a fallback message and `count: 0`.

## Retry and Timeout Behavior

Shoppex retries only transient failures:

* **Timeout:** 15 seconds
* **Retry delays:** 1s, then 3s
* **Retry triggers:** timeout, `429`, `500`, `502`, `503`, `504`, and common network errors

So if your server returns `503`, Shoppex retries after 1s. If that also fails, it retries once more after 3s. After the third attempt, the delivery is marked as failed.

<Warning>
  If all retries fail, the customer sees a generic "delivery pending" message on their order page. The order stays in a fulfilled-but-undelivered state until you manually resolve it or the customer contacts support.
</Warning>

## URL Requirements

Your `dynamic_webhook` must be a valid public `http` or `https` URL.

For local development, use a tunnel such as ngrok or Cloudflare Tunnel.

* `https://dev.example.com/shoppex/dynamic` — works
* `https://abc123.ngrok.io/shoppex/dynamic` — works for local testing
* `http://127.0.0.1:3000/...` — won't work, Shoppex can't reach private/loopback URLs

## Example Handler

```typescript theme={"system"}
import express from 'express';
import crypto from 'crypto';

const app = express();
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8');
  },
}));

const issuedTokens = new Map<string, { token: string; expires_at: string }>();

app.post('/shoppex/dynamic', async (req, res) => {
  const rawBody = (req as express.Request & { rawBody?: string }).rawBody ?? JSON.stringify(req.body);
  const signature = req.header('x-shoppex-signature-v2');
  const deliveryId = req.header('x-shoppex-delivery-id');
  const timestamp = req.header('x-shoppex-timestamp');
  if (!signature || !deliveryId || !timestamp) {
    return res.status(401).json({ error: 'Missing signature' });
  }
  const segments = signature.split(',').map((part) => part.trim());
  const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => {
    const [key, value] = part.trim().split('=');
    return [key, value ?? ''];
  }));
  if (
    !segments.includes('v1')
    || parts.t !== timestamp
    || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300
    || !/^[0-9a-f]{64}$/i.test(parts.h ?? '')
  ) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  const expectedSignature = crypto
    .createHmac('sha256', process.env.SHOPPEX_DYNAMIC_WEBHOOK_SECRET!)
    .update(`${deliveryId}.${timestamp}.${rawBody}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(parts.h, 'hex'), Buffer.from(expectedSignature, 'hex'))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const idempotencyKey = String(
    req.header('x-shoppex-idempotency-key')
      ?? req.body.idempotencyKey
      ?? req.body.idempotency_key
      ?? ''
  ).trim();

  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Missing idempotency key' });
  }

  let existing = issuedTokens.get(idempotencyKey);

  if (!existing) {
    existing = {
      token: `dyn_${Math.random().toString(36).slice(2, 10)}`,
      expires_at: '2026-12-31T23:59:59.000Z',
    };

    issuedTokens.set(idempotencyKey, existing);
  }

  return res.json({
    data: {
      service_text: 'Use this token in the bot.',
      dynamic_response: existing,
      deliveryType: 'DYNAMIC',
      count: 1,
    },
  });
});
```

## Recommended Pattern

Here's what a solid integration looks like:

* use `idempotencyKey` as your fulfillment key
* return the same result for retries
* keep the response short and structured
* put the customer-facing text in `service_text`
* put machine-readable output like tokens or credentials in `dynamic_response`

Avoid:

* generating a new token on every retry
* depending on field names from only one casing style
* returning HTML or large non-JSON payloads

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Webhooks Overview" icon="webhook" href="/api-reference/webhooks/overview">
    Setup, signatures, and retry policies
  </Card>

  <Card title="Webhook Events" icon="list" href="/api-reference/webhooks/events">
    Full event type reference and payload schemas
  </Card>
</CardGroup>
