Gareth Wilson Gareth Wilson

Guide to Stytch Webhooks: Features and Best Practices

Published


Stytch webhooks tell your application when users, organisations, members and SSO connections change in your Stytch project, including changes that happen outside your own code: an admin edits a member in the Stytch Dashboard, an identity provider provisions a user over SCIM, or a customer sets up a new SAML connection. If you keep your own user or tenant tables alongside Stytch, webhooks are how you keep them in sync.

This guide covers how Stytch webhooks are configured and signed, how event names are put together, the events available for B2B and Consumer projects, and the best practices for handling them in production.

What are Stytch webhooks?

Stytch is an authentication and user management API. Its webhooks are delivered by Svix, so every request carries Svix's three headers (svix-id, svix-timestamp and svix-signature) and is signed with Svix's HMAC-SHA256 scheme. Stytch points readers to Svix's documentation for verification rather than describing it on its own pages.

Event names follow the pattern source.object_type.action, for example direct.member.update, but no field in the request contains that string. The body has three separate fields, source, object_type and action, and you combine them yourself.

Stytch has two kinds of project. B2B projects send events about organisations, members, SSO and SCIM connections and SCIM groups. Consumer projects send events about users.

Stytch webhook features

FeatureDetails
ConfigurationStytch Dashboard, Webhooks page, + Add Endpoint
Event filteringPer endpoint: all events or a selected set
Delivery platformSvix
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeHMAC-SHA256, base64, in a space-delimited list of v1,<signature> entries
Signed content{svix-id}.{svix-timestamp}.{raw body}
SecretPer endpoint, prefixed whsec_. The key is the base64-decoded part after the prefix
Event namesource.object_type.action, built from three body fields
Event IDevent_id in the body; svix-id is the same on every retry
OrderingNot guaranteed
Auto-disableAfter 5 days of failed attempts to an endpoint

Stytch does not publish source IP ranges for webhook traffic.

Common events

source says where the change came from: DIRECT for an API or SDK call, DASHBOARD for the Stytch Dashboard, and SCIM for an identity provider. Stytch's event catalogs list 34 B2B and 7 Consumer event types.

ProjectEventsFires when
B2Bdirect.organization.create, .update, .delete (and the same for dashboard.)An organisation is created, updated or deleted
B2Bdirect.member.create, .update, .delete (and dashboard., scim.)A member is created, updated or deleted
B2Bdirect.member.impersonateA member is impersonated through the API or SDK
B2Bdirect.saml_connection.*, direct.oidc_connection.* (and dashboard.)An SSO connection is created, updated or deleted
B2Bscim.scim_group.create, .update, .delete, scim.scim_member_group.create, .deleteA SCIM group is created, updated or deleted, or a SCIM member group is created or deleted
B2Bdirect.member_device.new_device_attributeA new device attribute is seen for a member
Consumerdirect.user.create, .update, .delete (and dashboard.)A user is created, updated or deleted
Consumerdirect.user_device.new_device_attributeA new device attribute, such as a new IP address or visitor ID, is seen for a user

Here is a trimmed direct.member.update body from Stytch's B2B catalog:

{
  "action": "UPDATE",
  "details": {
    "organization_id": "organization-test-00000000-0000-0000-0000-000000000000"
  },
  "event_id": "webhook-event-test-00000000-0000-0000-0000-000000000000",
  "id": "member-test-00000000-0000-0000-0000-000000000000",
  "member": {
    "member_id": "member-test-00000000-0000-0000-0000-000000000000",
    "email_address": "adalovelace@stytch.example.com",
    "name": "Ada Lovelace",
    "organization_id": "organization-test-00000000-0000-0000-0000-000000000000",
    "status": "active",
    "updated_at": "2024-01-02T03:04:05.678901Z"
  },
  "object_type": "member",
  "project_id": "project-test-00000000-0000-0000-0000-000000000000",
  "source": "DIRECT",
  "timestamp": "2024-01-02T03:04:05.678901Z",
  "vertical": "B2B",
  "workspace_id": "workspace-prod-00000000-0000-0000-0000-000000000000"
}

event_id identifies the event, and id identifies the entity it is about. CREATE and UPDATE events include the full entity under a key named after object_type, here member. DELETE events carry only id. The timestamp is when the event was sent, with nanosecond precision, and Stytch notes that it is not necessarily unique.

To get the event name, lower-case source and action and join the three fields:

const event_type = `${body.source}.${body.object_type}.${body.action}`.toLowerCase();
// "direct.member.update"

Stytch's page lists CREATE, UPDATE and DELETE as the actions, but the catalogs also include IMPERSONATE and NEW_DEVICE_ATTRIBUTE, so don't hard-code three values.

Setting up Stytch webhooks

  1. In the Stytch Dashboard, go to the Webhooks page and click + Add Endpoint.
  2. Enter your endpoint URL. Your server must accept POST requests there.
  3. Choose all events, or filter to the event types you need, and click Create.
  4. Open the endpoint and copy its signing secret, which starts with whsec_. Store it server-side, for example as STYTCH_WEBHOOK_SECRET.
  5. Trigger a real event, such as creating an organisation in the Dashboard or updating a member through the API. It should appear on your server and in the endpoint's Message Attempts, Logs and Activity views.

You can add several endpoints, for example one per environment or one per group of events. Each has its own secret.

For local development, the Hookdeck CLI gives you a public URL that forwards to your machine: hookdeck listen 3000 stytch --path /webhooks/stytch.

Securing Stytch webhooks

Svix signs {svix-id}.{svix-timestamp}.{raw body} with HMAC-SHA256. The key is the base64-decoded portion of the secret after whsec_, not the secret string itself. svix-signature holds one or more space-separated entries in the form v1,<base64 signature>, and the request is valid if any v1 entry matches.

const crypto = require('crypto');
const express = require('express');

function verifyStytchWebhook(rawBody, headers, secret) {
  const msg_id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatures = headers['svix-signature'];
  if (!msg_id || !/^\d+$/.test(timestamp || '') || !signatures) return false;

  // The key is the base64 part after whsec_, decoded to bytes.
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${msg_id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/stytch', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.STYTCH_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyStytchWebhook(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: reject old deliveries by comparing svix-timestamp with the current time.
  // Leave this off if you replay or retry events later, because they keep the original timestamp.

  const body = JSON.parse(req.body.toString('utf8'));
  const event_type = `${body.source}.${body.object_type}.${body.action}`.toLowerCase();
  // Deduplicate on body.event_id, then process event_type asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import base64
import hashlib
import hmac


def verify_stytch_webhook(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signatures = headers.get("svix-signature")
    if not (msg_id and timestamp and timestamp.isdigit() and signatures):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and signature:
            if hmac.compare_digest(base64.b64decode(signature), expected):
                return True
    return False

You can also use Svix's own libraries (svix on npm and PyPI) to verify. They enforce a five-minute timestamp window, which rejects any delivery you replay or retry from a log later, so the manual check above leaves the timestamp check as an option.

Make Stytch webhooks production-ready. Hookdeck Event Gateway verifies Stytch's Svix signatures, durably queues every user and organisation event, and lets you replay any of them without fighting a timestamp window.

Stytch webhook limitations and pain points

No event name on the wire

The Problem: Routing, filtering and logging all need the event name, but nothing in the request carries it.

Why It Happens: Stytch defines event types as source.object_type.action, but sends the three parts as separate body fields, upper-casing two of them. Svix sends no event-type header. Stytch's own page also has one example written in the wrong order (dashboard.update.member).

Workarounds:

  • Build the name once, at the edge of your handler, with one function, and use it everywhere.
  • Test that function against the catalog names, including member_device and scim_member_group.

How Hookdeck Can Help: Hookdeck filters can match on source, object_type and action directly, so you can send SCIM events to one service and Dashboard events to another without composing anything. A transformation can also add the composed name to the body before delivery.

Out-of-order delivery

The Problem: An older update overwrites a newer one in your database.

Why It Happens: Stytch states that webhooks have no ordering guarantee, because of retries, failures and network variability. The timestamp field is not unique either.

Workarounds:

  • Follow Stytch's advice and treat the webhook as a signal: on direct.member.update, call GET /v1/b2b/organizations/{organization_id}/member for the member ID in the event, and store the response.
  • If you use the embedded entity, compare its updated_at with what you have stored and skip anything older.

How Hookdeck Can Help: Hookdeck can limit delivery to your service, so a burst of SCIM provisioning events becomes a steady stream of follow-up API calls.

The secret is not the key

The Problem: Every signature check fails even though the secret was copied correctly.

Why It Happens: The HMAC key is the base64-decoded bytes after whsec_. Using the full string, or the string without the prefix but not decoded, produces a valid-looking signature that never matches.

Workarounds:

  • Strip whsec_, base64-decode the rest, and use the bytes as the key.
  • Sign {svix-id}.{svix-timestamp}. followed by the raw bytes, not re-serialised JSON.

How Hookdeck Can Help: Hookdeck's Stytch source type verifies the Svix headers with your whsec_ secret, so the key handling is configured once instead of in every service.

Disabled endpoints

The Problem: After a long outage, events stop arriving altogether.

Why It Happens: Svix disables an endpoint if every attempt to it fails for 5 days. It stays disabled until someone selects Enable Endpoint in the webhook dashboard.

Workarounds:

  • Alert on failing deliveries well before the 5 day mark.
  • After an outage, check the endpoint's status in the Stytch Dashboard.

How Hookdeck Can Help: Hookdeck accepts Stytch's deliveries while your service is down and holds them until it recovers, so Svix never sees five days of failures. Issues notify you when your own service starts failing.

Best practices

Verify against the raw body

Mount express.raw() on the webhook route, use await request.text() in a Next.js route handler, or await request.body() in FastAPI. Parsing first changes the bytes.

Deduplicate on event_id or svix-id

Svix keeps svix-id the same across retries of a message, and event_id is unique per Stytch event. Store one of them and skip repeats. See our guide to webhook idempotency.

Handle deletes without an entity

DELETE events carry only id. Look the record up by that ID in your own store, rather than reading the entity key, which is absent.

Separate your own writes from external changes

DIRECT events fire for any API or SDK call, including the ones your own backend makes. If your code already updated its tables when it called Stytch, decide whether the matching direct.* event should be skipped or used as confirmation. DASHBOARD and SCIM events always come from outside your code.

Acknowledge fast and process asynchronously

Verify, store the event, return 200, and do the API call and database write in a background job. See why to process webhooks asynchronously.

Conclusion

Stytch webhooks are Svix webhooks: verify svix-signature over {svix-id}.{svix-timestamp}.{raw body} with the base64-decoded whsec_ key, then build the event name from source, object_type and action. Because Stytch guarantees no ordering, use each event as a prompt to read the current state from the API, and deduplicate on event_id.

Hookdeck Event Gateway verifies every Stytch delivery, routes on the body fields, and holds events through your outages so Svix never disables the endpoint.

Get started with Hookdeck for free and handle Stytch webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.