# Statsig Webhooks

## When to Use This Skill

* Setting up a Statsig Event Webhook (the "Generic Webhook" integration)
* Debugging `X-Statsig-Signature` verification failures
* Processing exposure events or config-change notifications (feature gate,
  experiment, or dynamic config `created` / `updated` events)
* Handling Statsig's JSON batch payloads (arrays) and the config-change
  `{ "data": [...] }` envelope
* Answering the `url_verification` handshake so the webhook actually
  registers (a missed handshake fails silently — no events, no log entries)

## Essential Code (USE THIS)

Statsig signs every webhook request with HMAC-SHA256 using a Slack/Stripe-style
scheme (this is not the Standard Webhooks spec). The signed content is the
literal string `v0:{timestamp}:{raw_body}`, and the result is sent as
`X-Statsig-Signature: v0=<hex>`. Use the raw request body — parsing JSON
before verifying will change byte ordering and break the signature.

> Note: Statsig's `X-Statsig-Request-Timestamp` is a Unix timestamp in
> milliseconds (13 digits), not seconds.

### URL Validation Handshake (answer this or the webhook never registers)

When you save the Generic Webhook integration, Statsig POSTs a validation
request to the destination URL and registers the webhook only if the endpoint
echoes the code back:

```json
{ "data": { "event": "url_verification", "verification_code": "abc123" } }

```

Respond `200` with a JSON body carrying the same value:

```json
{ "verification_code": "abc123" }

```

A missed handshake fails silently: the webhook never registers, no event is
ever delivered, and nothing appears in any delivery log. Answer it before
enforcing signature verification — it only echoes a value the caller supplied,
the same way an unauthenticated URL-check ping is answered for providers like
Mailchimp. The Express handler below includes the responder.

### Statsig Signature Verification (JavaScript)

```javascript
const crypto = require('crypto');

function verifyStatsigRequest(rawBody, signatureHeader, timestampHeader, signingSecret) {
  if (!signatureHeader || !timestampHeader || !signingSecret) return false;

  // Statsig's timestamp is a Unix time in MILLISECONDS (13 digits)
  const timestamp = parseInt(timestampHeader, 10);
  if (Number.isNaN(timestamp)) return false;

  // Replay protection (best practice; Statsig does not document a tolerance):
  // reject requests whose timestamp is more than 5 minutes from now.
  if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) return false;

  // Statsig signs the literal string: "v0:" + timestamp + ":" + raw body
  const basestring = `v0:${timestampHeader}:${rawBody}`;
  const expected = 'v0=' + crypto
    .createHmac('sha256', signingSecret)
    .update(basestring, 'utf8')
    .digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signatureHeader),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

```

### Express Webhook Handler

```javascript
const express = require('express');
const app = express();

// CRITICAL: Use express.raw() - Statsig signs the raw body, not parsed JSON
app.post('/webhooks/statsig',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-statsig-signature'];
    const timestamp = req.headers['x-statsig-request-timestamp'];
    const rawBody = req.body.toString('utf8');
    const payload = JSON.parse(rawBody);

    // URL validation handshake (sent when the integration is saved):
    // echo the code back or the webhook never registers.
    if (payload?.data?.event === 'url_verification') {
      return res.status(200).json({ verification_code: payload.data.verification_code });
    }

    if (!verifyStatsigRequest(rawBody, signature, timestamp, process.env.STATSIG_WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    // Statsig delivers batches. Config changes arrive as { data: [...] };
    // exposure events arrive as a top-level JSON array.
    const items = Array.isArray(payload) ? payload : (payload.data || []);

    for (const item of items) {
      const meta = item.metadata || {};
      if (meta.action) {
        // Config change: type e.g. "Feature Gate", action e.g. "created" | "updated"
        console.log(`Config change: ${meta.type} "${meta.name}" was ${meta.action}`);
      } else {
        console.log(`Exposure event: ${item.eventName}`);
      }
    }

    res.status(200).send('OK');
  }
);

```

### Python Signature Verification (FastAPI)

```python
import hmac
import hashlib
import time

def verify_statsig_request(raw_body: bytes, signature_header: str, timestamp_header: str, signing_secret: str) -> bool:
    if not signature_header or not timestamp_header or not signing_secret:
        return False

    try:
        timestamp = int(timestamp_header)
    except ValueError:
        return False

    # Statsig's timestamp is a Unix time in MILLISECONDS (13 digits).
    # Replay protection (best practice; Statsig does not document a tolerance).
    if abs(time.time() * 1000 - timestamp) > 5 * 60 * 1000:
        return False

    # Statsig signs the literal string: "v0:" + timestamp + ":" + raw body
    basestring = f"v0:{timestamp_header}:{raw_body.decode('utf-8')}".encode("utf-8")
    expected = "v0=" + hmac.new(
        signing_secret.encode("utf-8"),
        basestring,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature_header)

```

> For complete working examples with tests, see:
> 
> * [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/statsig-webhooks/examples/express/) - Full Express implementation
> * [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/statsig-webhooks/examples/nextjs/) - Next.js App Router implementation
> * [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/statsig-webhooks/examples/fastapi/) - Python FastAPI implementation

## Payload Shapes

Statsig delivers events in batches. There are two shapes depending on what
you subscribe to under Event Filtering:

| Subscription | Shape | Example |
| --- | --- | --- |
| Exposures | A top-level JSON array of event objects | `[ { "eventName": "statsig::gate_exposure", "user": { ... }, "metadata": { "gate": "my_gate", ... } } ]` |
| Config Changes | An object wrapping a `data` array | `{ "data": [ { "eventName": "...", "metadata": { "type": "Feature Gate", "name": "my_gate", "description": "...", "action": "updated" } } ] }` |

Config-change `metadata` carries `type`, `name`, `description`, and `action`
(e.g. `"created"`, `"updated"`). Normalize both shapes by reading
`Array.isArray(payload) ? payload : payload.data`.

## Important Headers

| Header | Description |
| --- | --- |
| `X-Statsig-Signature` | HMAC-SHA256 hex signature, formatted as `v0=<hex>` |
| `X-Statsig-Request-Timestamp` | Unix epoch in milliseconds, used in the signing basestring |

## Environment Variables

```bash
STATSIG_WEBHOOK_SECRET=your_signing_secret   # Webhook integration card → Project Settings → Integrations

```

## Local Development

```bash
# Forward Statsig events to your local server (no account required)
npx hookdeck-cli listen 3000 statsig --path /webhooks/statsig

```

Then paste the Hookdeck URL into the destination URL field of the Generic
Webhook integration in Project Settings → Integrations.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/statsig-webhooks/references/overview.md) - Statsig Event Webhook concepts, payload shapes, retry behavior
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/statsig-webhooks/references/setup.md) - Configure the Generic Webhook integration and get the signing secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/statsig-webhooks/references/verification.md) - Signature verification details and gotchas