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

# Webhook handler in a Cloudflare Worker

> Verify, idempotency-check, and act on Shoppex webhooks from a Worker.

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

```bash theme={"system"}
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.

```bash theme={"system"}
npx wrangler kv:namespace create WEBHOOK_DELIVERIES
```

Wrangler prints a binding snippet — copy it into `wrangler.toml`:

```toml theme={"system"}
[[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`):

```bash theme={"system"}
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`:

```ts theme={"system"}
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 segments = signatureHeader.split(',').map((part) => part.trim());
  const parts = segments.reduce<Record<string, string>>((acc, part) => {
    const [k, v] = part.split('=');
    if (k && v) acc[k] = v;
    return acc;
  }, {});
  const signature = parts.h;
  if (!segments.includes('v1') || parts.t !== timestamp || !/^[0-9a-f]{64}$/i.test(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

```bash theme={"system"}
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

```bash theme={"system"}
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:

```bash theme={"system"}
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

```bash theme={"system"}
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:

```bash theme={"system"}
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` — direct catalog stock update. For stock consumed by checkout, use `order:paid` or `order:paid:product`.

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.

<Card title="Reference: Webhooks" href="/api-reference/webhooks/overview">
  Full event list, payload shapes, and the signature spec.
</Card>
