Skip to main content

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" and set dynamic_webhook.
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
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": "[email protected]",
  "customer_email": "[email protected]",
  "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": "[email protected]",
    "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';

const app = express();
app.use(express.json());

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

app.post('/shoppex/dynamic', async (req, res) => {
  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