# Stytch Webhooks

## When to Use This Skill

* How do I receive Stytch webhooks?
* How do I verify Stytch webhook signatures (`svix-signature`)?
* How do I handle `direct.user.create` or `direct.member.update` events?
* Why is my Stytch webhook signature verification failing?
* How do I keep my database in sync with Stytch users, members, and organizations?

## Resources

* `references/overview.md` - What Stytch webhooks are, event type structure, payload fields
* `references/setup.md` - Create an endpoint in the Stytch Dashboard and get the signing secret
* `references/verification.md` - Svix signature verification details and gotchas
* `examples/` - Runnable Express, Next.js, and FastAPI examples

## Verification (core)

Stytch webhooks are delivered by Svix. Each request carries `svix-id`, `svix-timestamp`, and `svix-signature` (a space-delimited list of `v1,<base64>` entries). The signed content is `{svix-id}.{svix-timestamp}.{raw body}`, HMAC-SHA256, keyed with the **base64-decoded** part of the `whsec_` signing secret.

```javascript
import crypto from 'node:crypto';

// rawBody: the exact bytes received (express.raw() / request.text()), NOT re-serialised JSON.
// toleranceSec = 0 (default) skips the timestamp window, because Hookdeck retries and replays
// keep the original svix-timestamp. A positive value opts in.
function verifyStytchWebhook(rawBody, headers, secret, toleranceSec = 0) {
  const id = headers['svix-id'];
  const ts = headers['svix-timestamp'];
  const sigs = headers['svix-signature'];
  if (!id || !/^\d+$/.test(ts ?? '') || !sigs) throw new Error('Missing or malformed svix-* headers');
  if (toleranceSec > 0 && Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) {
    throw new Error('Timestamp outside tolerance');
  }
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); // decode; don't use the string as-is
  const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest();
  const ok = sigs.split(' ').some((entry) => {
    const [version, sig] = entry.split(',');
    const given = Buffer.from(sig ?? '', 'base64');
    return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!ok) throw new Error('Invalid signature');
  return JSON.parse(rawBody.toString('utf8'));
}

```

**Why not the `svix` SDK?** `new Webhook(secret).verify(...)` (npm) and `Webhook(secret).verify(...)` (PyPI) check the same signature, but they always enforce a 5-minute timestamp window with no way to turn it off. Hookdeck retries and replays carry the original `svix-timestamp`, so the SDK rejects them. The examples verify manually and make the window opt-in with `STYTCH_TIMESTAMP_TOLERANCE_SECONDS` (default `0`; a positive number of seconds turns it on; anything that isn't a whole number is treated as `0`, with a warning at startup). If you never retry or replay late, the SDK is fine.

Python equivalent:

```python
import base64, hashlib, hmac, time

def verify_svix(raw_body: bytes, headers, secret: str, tolerance: int = 0) -> None:
    # tolerance = 0 (default) skips the timestamp window; a positive value opts in
    msg_id, ts, sigs = headers.get("svix-id"), headers.get("svix-timestamp"), headers.get("svix-signature")
    if not (msg_id and ts and ts.isdigit() and sigs):
        raise ValueError("Missing or malformed svix-* headers")
    if tolerance > 0 and abs(time.time() - int(ts)) > tolerance:
        raise ValueError("Timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    for entry in sigs.split(" "):
        version, _, sig = entry.partition(",")
        if version == "v1" and hmac.compare_digest(base64.b64decode(sig or ""), expected):
            return
    raise ValueError("Invalid signature")

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stytch-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stytch-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stytch-webhooks/examples/fastapi/).

## Event Types

Stytch event types are `source.object_type.action`. The body carries `source` (`DIRECT`, `DASHBOARD`, `SCIM`), `object_type` (e.g. `member`), and `action` (`CREATE`, `UPDATE`, `DELETE`), but no combined `type` field, so build it: ``${source}.${object_type}.${action}`.toLowerCase()`.

| Event | Triggered When |
| --- | --- |
| `direct.user.create` | A user is created via API/SDK (Consumer projects) |
| `direct.user.update` | A user is updated via API/SDK (Consumer projects) |
| `direct.organization.create` | An Organization is created via API/SDK (B2B projects) |
| `direct.member.update` | A Member is updated via API/SDK (B2B projects) |
| `direct.saml_connection.delete` | A SAML connection is deleted via API/SDK (B2B projects) |
| `dashboard.organization.create` | An Organization is created in the Stytch Dashboard |
| `scim.member.update` | A Member is updated through SCIM |

CREATE and UPDATE events include the full entity under a key named after `object_type` (e.g. `member`); DELETE events carry only `id`. For the complete catalog, see the Webhooks page of the Stytch Dashboard and [Stytch's webhook docs](https://stytch.com/docs/workspace-management/webhooks).

## Environment Variables

```bash
STYTCH_WEBHOOK_SECRET=whsec_xxxxx   # Dashboard > Webhooks > your endpoint > Signing secret
STYTCH_TIMESTAMP_TOLERANCE_SECONDS=0   # Optional replay window; a positive value opts in

```

## Local Development

```bash
npx hookdeck-cli listen 3000 stytch --path /webhooks/stytch

```

No account required. Use the tunnel URL as the endpoint URL in the Stytch Dashboard.

## Gotchas

* Use the **raw body** for verification; parsing JSON first changes the bytes and breaks the signature.
* Webhooks have no ordering guarantee. Treat them as a signal and re-fetch the entity from the Stytch API before overwriting your data.
* `svix-id` is the same on every retry of a message; use it to deduplicate.
* Svix disables an endpoint after 5 days of continuous failures; re-enable it in the Dashboard.