# Guide to Stytch Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | Stytch Dashboard, **Webhooks** page, **+ Add Endpoint** |
| Event filtering | Per endpoint: all events or a selected set |
| Delivery platform | Svix |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | HMAC-SHA256, base64, in a space-delimited list of `v1,<signature>` entries |
| Signed content | `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | Per endpoint, prefixed `whsec_`. The key is the base64-decoded part after the prefix |
| Event name | `source.object_type.action`, built from three body fields |
| Event ID | `event_id` in the body; `svix-id` is the same on every retry |
| Ordering | Not guaranteed |
| Auto-disable | After 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.

| Project | Events | Fires when |
| --- | --- | --- |
| B2B | `direct.organization.create`, `.update`, `.delete` (and the same for `dashboard.`) | An organisation is created, updated or deleted |
| B2B | `direct.member.create`, `.update`, `.delete` (and `dashboard.`, `scim.`) | A member is created, updated or deleted |
| B2B | `direct.member.impersonate` | A member is impersonated through the API or SDK |
| B2B | `direct.saml_connection.*`, `direct.oidc_connection.*` (and `dashboard.`) | An SSO connection is created, updated or deleted |
| B2B | `scim.scim_group.create`, `.update`, `.delete`, `scim.scim_member_group.create`, `.delete` | A SCIM group is created, updated or deleted, or a SCIM member group is created or deleted |
| B2B | `direct.member_device.new_device_attribute` | A new device attribute is seen for a member |
| Consumer | `direct.user.create`, `.update`, `.delete` (and `dashboard.`) | A user is created, updated or deleted |
| Consumer | `direct.user_device.new_device_attribute` | A 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:

```json
{
  "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:

```javascript
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.

```javascript
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:

```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](/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](/docs/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](/docs/transformations) 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](/docs/destinations#set-a-max-delivery-rate) 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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Stytch webhooks reliably in minutes.