Skip to main content

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.

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

Create the product

You create a product with type: "DYNAMIC", set dynamic_webhook, and keep the generated dynamic webhook signing secret.
2

Customer pays

A customer pays for that product.
3

Shoppex calls your endpoint

Shoppex sends POST to your dynamic_webhook URL.
4

Your server responds

Your server returns the dynamic delivery data.
5

Shoppex stores the result

Shoppex stores that response in the delivered items for the invoice.
This page covers the callback contract for dynamic_webhook. For normal Shoppex event webhooks like order:paid, see Webhooks Overview and Webhook Events.

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

HeaderDescription
Content-TypeAlways application/json
X-Shoppex-Idempotency-KeyStable delivery key for deduplication
X-Shoppex-Delivery-IdSame value as the idempotency key
X-Shoppex-TimestampUnix timestamp in seconds used in the v2 signature
X-Shoppex-Signature-V2Timestamped HMAC-SHA256 signature: v1,t=<timestamp>,h=<hex>
X-Shoppex-Signature-V2-AlgorithmHMAC-SHA256 when a v2 signature is present
X-Shoppex-SignatureDeprecated legacy HMAC-SHA512 body-only signature
X-Shoppex-Signature-AlgorithmDeprecated 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:
import crypto from 'crypto';

function verifyShoppexSignature(
  rawBody: string,
  signatureHeader: string | undefined,
  deliveryId: string | undefined,
  timestampHeader: string | undefined,
) {
  if (!signatureHeader || !deliveryId || !timestampHeader) return false;
  const parts = Object.fromEntries(signatureHeader.split(',').map((part) => {
    const [key, value] = part.trim().split('=');
    return [key, value ?? ''];
  }));
  if (parts.v1 !== '' || parts.t !== timestampHeader || !parts.h) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > 300) 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), Buffer.from(expected));
}
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.
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

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

FieldTypeDescription
invoiceId / invoice_idstringPublic Shoppex invoice ID
invoiceDbId / invoice_db_idstringInternal invoice row ID
productId / product_idstringInternal product row ID
shopId / shop_idstringInternal shop row ID
deliveryId / delivery_idstringStable delivery identifier
idempotencyKey / idempotency_keystringStable idempotency key
invoiceobjectInvoice snapshot
productobjectProduct snapshot
shopobjectShop snapshot
line_itemobjectFulfilled line item snapshot

Response

Your endpoint should return 2xx and JSON. Recommended response:
{
  "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.
{
  "data": {
    "service_text": "Use this token in the bot.",
    "dynamic_response": {
      "token": "dyn_123"
    }
  }
}

What Shoppex Stores

Shoppex normalizes your response into delivered items:
{
  "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.
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.

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

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 parts = Object.fromEntries(signature.split(',').map((part) => {
    const [key, value] = part.trim().split('=');
    return [key, value ?? ''];
  }));
  if (parts.v1 !== '' || parts.t !== timestamp || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) {
    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), Buffer.from(expectedSignature))) {
    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,
    },
  });
});
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

Webhooks Overview

Setup, signatures, and retry policies

Webhook Events

Full event type reference and payload schemas