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

`dynamic_webhook` is a direct server-to-server callback that Shoppex sends when a `DYNAMIC` product needs fulfillment after a paid order, separate from normal event webhooks.

<Note>
  This page covers the callback contract for `dynamic_webhook`. For normal Shoppex event webhooks like `order:paid`, see [Webhooks](/developers/webhooks) and [Webhook events](/developers/webhook-events).
</Note>

## How it works

1. You create a product with `type: "DYNAMIC"`, set `dynamic_webhook`, and keep the generated dynamic webhook signing secret.
2. A customer pays for that product.
3. Shoppex sends `POST` to your `dynamic_webhook` URL.
4. Your server returns delivery data, or clearly states that it will fulfill the line item later.
5. Shoppex either stores the delivered content or marks the line item as `AWAITING_FULFILLMENT`.

## Webhook request

### HTTP details

Shoppex sends a `POST` request with `Content-Type: application/json` and a JSON body that contains invoice, product, shop, and line item data.

Your `dynamic_webhook` URL must be a public `http` or `https` address. 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/...` — does not work. Shoppex cannot reach private or loopback URLs

### Example body

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

```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:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
  "delivery_id": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
  "idempotencyKey": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
  "idempotency_key": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f"
}
```

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

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

## Signature verification

Dynamic product delivery has its own signing secret on the product. This secret is separate from normal Shoppex event webhook secrets: normal webhooks use the endpoint secret from **Settings → Webhooks**, dynamic delivery uses the product's `dynamic_webhook_secret`.

When you create or update 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 once as `dynamic_webhook_secret` in that create or update response. Store it immediately.

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

```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>
  Shoppex keeps `X-Shoppex-Signature` as a legacy HMAC-SHA512 body-only header during the migration period. New dynamic delivery handlers must verify `X-Shoppex-Signature-V2`.
</Warning>

Treat `X-Shoppex-Idempotency-Key` as the durable fulfillment key for this callback. Shoppex makes only one automatic request, but a merchant can retry a failed delivery manually. Your endpoint must return the same result for the same key instead of issuing a second token, license, or account.

## Webhook response

### Success

Your endpoint must return `2xx` status 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
  }
}
```

A solid integration follows this pattern:

* Use `idempotencyKey` as your fulfillment key.
* Return the same result if a merchant retries a failed delivery.
* 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 the following:

* Generating a new token when Shoppex sends the same idempotency key again.
* Depending on field names from only one casing style.
* Returning HTML or a large non-JSON payload.

Shoppex accepts three response forms: a JSON object, a JSON object with a nested `data` object, or 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"
    }
  }
}
```

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 your system accepts the request but cannot return the delivery content within 15 seconds, respond with `200` and a clear pending status. Shoppex then marks this line item as `AWAITING_FULFILLMENT`. Deliver the content later with [`POST /dev/v1/orders/{id}/items/{item_id}/fulfill`](/developers/fulfillment#fulfill-a-line-item).

The following example shows a complete callback exchange. The request body uses the same fields described above. The response is the asynchronous acknowledgement.

```http theme={"system"}
POST /shoppex/dynamic HTTP/1.1
Host: vendor.example.com
Content-Type: application/json
X-Shoppex-Idempotency-Key: dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f
X-Shoppex-Delivery-Id: dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f
X-Shoppex-Timestamp: 1785823200
X-Shoppex-Signature-V2: v1,t=1785823200,h=SIGNATURE_HEX

{
  "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-08-04T08:39:00.000Z",
    "updated_at": "2026-08-04T08:40: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:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
  "delivery_id": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
  "idempotencyKey": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
  "idempotency_key": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f"
}

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "pending"
}
```

The nested form is also accepted:

```json theme={"system"}
{
  "data": {
    "status": "pending"
  }
}
```

### 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,
    },
  });
});
```

### Retryable errors

Shoppex makes one delivery attempt with a 15-second timeout. It does not retry automatically after a timeout, network error, `429`, or `5xx` response. This avoids duplicating a side effect when Shoppex cannot know whether your server already delivered the product before the connection failed.

After this terminal failure, Shoppex:

* marks the line item as `FAILED`
* notifies the merchant
* sends `order:item.delivery_failed` to subscribed merchant webhook endpoints

<Warning>
  The merchant can fulfill the failed line item manually through the dashboard or the Developer API, or retry the dynamic delivery again. A timeout or network error does not tell you whether the vendor already delivered the product. Check `X-Shoppex-Idempotency-Key` for duplicates before you take any action with a side effect.
</Warning>

### Non-retryable errors

<Warning>
  You must state pending clearly. An empty `2xx` response keeps its historical meaning: Shoppex treats the line item as delivered and stores a placeholder delivery note. Never use an empty body to say that you will deliver the content later.
</Warning>

An empty or accidentally empty `2xx` response has no retry path. Shoppex already considers the item delivered, so it does not call your endpoint again for that line item. Always send the pending response shown above when you cannot deliver content immediately.

## Invoice status behavior

Shoppex calls the `dynamic_webhook` URL during product fulfillment, after the invoice reaches a paid or completed state. A 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.

| Endpoint outcome                                 | Line item status                                                                |
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
| Returns delivery content (`2xx`)                 | Delivered — Shoppex stores your response                                        |
| Returns a clear pending status (`2xx`)           | `AWAITING_FULFILLMENT` until you call the fulfill endpoint                      |
| Times out, errors, or returns a non-`2xx` status | `FAILED` — Shoppex notifies the merchant and sends `order:item.delivery_failed` |
| Returns an empty `2xx` body                      | Delivered, with a placeholder delivery note                                     |

<CardGroup cols={2}>
  <Card title="Webhooks" icon="webhook" href="/developers/webhooks">
    Setup, signatures, and retry policies
  </Card>

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