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.

Cloudflare Workers are a good fit for webhook handlers — cheap, fast, globally distributed, no servers to babysit. This tutorial wires up a worker that receives Shoppex webhook deliveries, verifies the signature, deduplicates by delivery ID, and reacts to the event.

What you’ll have at the end

  • A deployed Cloudflare Worker that listens for Shoppex events.
  • HMAC-SHA256 signature verification against the V2 header.
  • Idempotency using Cloudflare KV (so you can safely receive duplicates).
  • A registered Shoppex webhook pointing at the worker.

Prerequisites

  • A Cloudflare account with Workers enabled.
  • wrangler CLI installed (npm i -g wrangler or bun add -g wrangler).
  • A Shoppex shop with an API key that has webhooks.write.

Step 1 — Create the worker

npx wrangler init shoppex-webhook-worker
cd shoppex-webhook-worker
Pick the “Hello World” Worker template, TypeScript. You’ll have a src/index.ts to edit.

Step 2 — Add a KV namespace for idempotency

Shoppex assigns a unique delivery_id to each delivery. To safely handle duplicates (which can happen if you manually retry a webhook), record the IDs you’ve already processed and short-circuit if you see one twice.
npx wrangler kv:namespace create WEBHOOK_DELIVERIES
Wrangler prints a binding snippet — copy it into wrangler.toml:
[[kv_namespaces]]
binding = "WEBHOOK_DELIVERIES"
id = "your-namespace-id-from-wrangler"

Step 3 — Add the webhook secret

The webhook secret is what you’ll use to verify HMAC signatures. Add it as a wrangler secret (not in code, not in wrangler.toml):
npx wrangler secret put SHOPPEX_WEBHOOK_SECRET
# Paste the secret when prompted
You’ll get the secret from Shoppex in step 8 (when you create the webhook), so come back here once.

Step 4 — Write the handler

Replace src/index.ts:
export interface Env {
  WEBHOOK_DELIVERIES: KVNamespace;
  SHOPPEX_WEBHOOK_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    // 1. Read raw body — required for signature verification
    const rawBody = await request.text();

    // 2. Extract Shoppex headers
    const signatureV2 = request.headers.get('X-Shoppex-Signature-V2');
    const deliveryId = request.headers.get('X-Shoppex-Delivery');
    const timestamp = request.headers.get('X-Shoppex-Timestamp');
    const event = request.headers.get('X-Shoppex-Event');

    if (!signatureV2 || !deliveryId || !timestamp || !event) {
      return new Response('Missing required Shoppex headers', { status: 400 });
    }

    // 3. Reject deliveries older than 5 minutes (replay protection)
    const now = Math.floor(Date.now() / 1000);
    const ts = parseInt(timestamp, 10);
    if (Math.abs(now - ts) > 300) {
      return new Response('Timestamp out of window', { status: 400 });
    }

    // 4. Verify HMAC-SHA256 signature
    const isValid = await verifySignature(
      signatureV2,
      deliveryId,
      timestamp,
      rawBody,
      env.SHOPPEX_WEBHOOK_SECRET,
    );
    if (!isValid) {
      return new Response('Invalid signature', { status: 401 });
    }

    // 5. Idempotency — check if we've seen this delivery before
    const seen = await env.WEBHOOK_DELIVERIES.get(deliveryId);
    if (seen) {
      return new Response('Already processed', { status: 200 });
    }

    // 6. Parse and act on the event
    const payload = JSON.parse(rawBody);
    await handleEvent(event, payload, env);

    // 7. Record the delivery ID with a 7-day TTL
    await env.WEBHOOK_DELIVERIES.put(deliveryId, '1', { expirationTtl: 60 * 60 * 24 * 7 });

    return new Response('ok', { status: 200 });
  },
};

async function verifySignature(
  signatureHeader: string,
  deliveryId: string,
  timestamp: string,
  rawBody: string,
  secret: string,
): Promise<boolean> {
  // Header format: "v1,t={timestamp},h={hex_signature}"
  const parts = signatureHeader.split(',').reduce<Record<string, string>>((acc, part) => {
    const [k, v] = part.split('=');
    if (k && v) acc[k] = v;
    return acc;
  }, {});
  const signature = parts.h;
  if (!signature) return false;

  // Message format: "{deliveryId}.{timestamp}.{rawBody}"
  const message = `${deliveryId}.${timestamp}.${rawBody}`;

  // Compute HMAC-SHA256 with Web Crypto API
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    enc.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const sigBuffer = await crypto.subtle.sign('HMAC', key, enc.encode(message));
  const expected = Array.from(new Uint8Array(sigBuffer))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');

  // Constant-time compare
  if (expected.length !== signature.length) return false;
  let mismatch = 0;
  for (let i = 0; i < expected.length; i++) {
    mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
  }
  return mismatch === 0;
}

async function handleEvent(event: string, payload: any, env: Env): Promise<void> {
  switch (event) {
    case 'order:paid':
      console.log('Order paid:', payload.data.uniqid, payload.data.total);
      // Your business logic here — send Slack message, fulfill in your system, etc.
      break;
    case 'order:disputed':
      console.warn('Dispute opened on order:', payload.data.uniqid);
      break;
    case 'subscription:renewed':
      console.log('Subscription renewed:', payload.data.uniqid);
      break;
    default:
      console.log('Unhandled event:', event);
  }
}

Step 5 — Test locally

npx wrangler dev
Wrangler runs the worker at http://localhost:8787. Send a POST request with fake headers to confirm it returns 400 (missing headers) — then 401 (bad signature) when you send full headers but wrong signature. That’s the right behavior.

Step 6 — Deploy

npx wrangler deploy
Wrangler gives you a URL like https://shoppex-webhook-worker.your-account.workers.dev. Copy it.

Step 7 — Register the webhook in Shoppex

From your dashboard at Settings → Developer → Webhooks or via the API:
curl https://api.shoppex.io/dev/v1/webhooks \
  -H "Authorization: Bearer shx_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shoppex-webhook-worker.your-account.workers.dev",
    "events": ["order:paid", "order:disputed", "subscription:renewed"]
  }'
The response includes a secret. Save it immediately — Shoppex shows it only once.

Step 8 — Set the secret in the worker

npx wrangler secret put SHOPPEX_WEBHOOK_SECRET
# Paste the secret you just got

Step 9 — Verify

Trigger a real event — make a purchase on your shop or use the dashboard’s “send test event” on the webhook configuration page. Check your worker logs:
npx wrangler tail
You should see the event come in, get verified, and be processed.

Event reference

Common events you’ll likely subscribe to:
  • order:created — new order, not yet paid.
  • order:paid — payment confirmed. This is the event 90% of integrations care about.
  • order:cancelled, order:disputed — bad-path events worth knowing about.
  • subscription:renewed, subscription:cancelled — recurring billing lifecycle.
  • subscription:trial:started, :trial:ended — for nudge flows.
  • product:stock — low-stock alert.
Up to 12 events per webhook, and 15 webhooks per shop in total.

Retry behavior — important

If your worker returns a non-2xx or times out (30s limit), Shoppex automatically retries on an exponential backoff: 2 min, 4 min, 8 min, 16 min between attempts. After 5 total attempts (initial + 4 retries) the delivery is marked failed. You can manually re-queue any failed delivery from the dashboard or via POST /dev/v1/webhooks/logs/:id/retry. The same delivery_id is reused — that’s why idempotency (the KV check above) matters. Practical implications:
  • Your worker should be reliable. Use a Worker (which is globally distributed) rather than a single-region origin server. Transient failures are forgiven automatically; sustained outages still get the delivery through if you recover within the ~30-minute retry window.
  • Make handlers idempotent. Both automatic retries and your own manual retries will re-send the same delivery_id. The KV check above is what catches both cases.
  • Watch for failures. If a delivery exhausts all 5 attempts, it’s done — Shoppex won’t try again. Monitor your worker logs (Cloudflare Analytics or wrangler tail) and the Shoppex webhook-logs dashboard for failed entries you’ll need to manually re-queue once you’ve fixed the root cause.

Common pitfalls

  • Parsing the body before verifying. You must use request.text() to get the exact raw bytes Shoppex signed. If you request.json() first and re-serialize, the message no longer matches the signature.
  • Reading headers case-sensitively. The Fetch API in Workers gives you case-insensitive access, but be consistent.
  • Forgetting the timestamp window. Without a 5-minute timestamp check, an attacker who captured one signed payload could replay it forever.
  • Not enabling KV in production. Without idempotency, a manual retry double-processes the order. Worth the 5 minutes of KV setup.

Reference: Webhooks

Full event list, payload shapes, and the signature spec.