# Stripe webhooks on Cloudflare Workers

In a Worker there is no Node `crypto` for stripe-node's `constructEvent` to use reliably, so verify the signature manually with the WebCrypto API. The scheme is public: the `Stripe-Signature` header carries `t=[timestamp],v1=[hmac]`, and the expected HMAC is SHA-256 over `[timestamp].[raw body]` keyed with your signing value.

## The setup

```
async function verifyStripeSignature(rawBody, header, signingValue) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = parts['t'];
  const v1 = parts['v1'];
  if (!t || !v1) return false;
  // reject stale webhooks; Stripe uses a 300s tolerance
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(signingValue),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const sig = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(t + '.' + rawBody)
  );
  const expected = [...new Uint8Array(sig)]
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
  return expected === v1;
}

export default {
  async fetch(request, env) {
    const rawBody = await request.text();
    const header = request.headers.get('stripe-signature') || '';
    const ok = await verifyStripeSignature(
      rawBody, header, env.STRIPE_WEBHOOK_SIGNING_VALUE
    );
    if (!ok) return new Response('bad signature', { status: 400 });
    const event = JSON.parse(rawBody);
    // switch on event.type ...
    return new Response('ok', { status: 200 });
  },
};
```

## Notes

- Only parse the JSON after the signature passes.
- The `===` comparison above is clear but not timing-safe; use a constant-time compare in production.
- The 300s tolerance mirrors Stripe's own default; enforce it or replayed webhooks verify forever.