Agent skill
Stytch Webhooks Skill
Receive and verify Stytch webhooks (delivered by Svix). Use when setting up Stytch webhook handlers, debugging svix-signature verification, or handling auth events like direct.user.create, direct.member.update, or direct.organization.create.
Install this skill
npx skills add hookdeck/webhook-skills --skill 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.createordirect.member.updateevents? - 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 fieldsreferences/setup.md- Create an endpoint in the Stytch Dashboard and get the signing secretreferences/verification.md- Svix signature verification details and gotchasexamples/- 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.
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_ (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:
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/, examples/nextjs/, examples/fastapi/.
Event Types
Stytch event types are source.object_. 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_.
| 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_ | 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.
Environment Variables
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
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-idis 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.