# Guide to WorkOS Webhooks: Features and Best Practices

WorkOS webhooks notify your application about enterprise identity activity: a directory user is created or updated, an SSO connection is activated, a user signs in. If you're building on WorkOS for SSO, SCIM directory sync, or auth, webhooks are how you react to these events without polling.

This guide covers how WorkOS webhooks work, the events you'll handle, how to verify the `WorkOS-Signature`, and the best practices for production.

## What are WorkOS webhooks?

WorkOS webhooks are JSON POSTs delivered to a URL you configure. Each is signed with a `WorkOS-Signature` header (no `X-` prefix) carrying a Stripe-style composite value: `t=<timestamp>,v1=<signature>`, comma-delimited, so you split it before comparing. The signature is an HMAC-SHA256 (hex) over `<issued_timestamp> + "." + raw_body`, with the timestamp taken from inside the header value. The official Node SDK verifies all of this.

## WorkOS webhook features

| Feature | Details |
| --- | --- |
| Configuration | WorkOS dashboard > Webhooks |
| Signature header | `WorkOS-Signature` (no `X-`), composite `t=<ts>,v1=<sig>` |
| Signature scheme | HMAC-SHA256 (hex) over `<timestamp>.<raw_body>` |
| Tolerance | An SDK parameter (default ~3-5 minutes), not a server rule |
| Event field | Top-level `event` (not `type`), dotted |
| Retries | Production: 6 retries, exponential backoff over 3 days; staging: only "several minutes" |
| SDK | npm `@workos-inc/node` `webhooks.constructEvent` |

## Common events

WorkOS reports the event type in a top-level field named `event` (not `type`), dotted:

| Event | Fires when |
| --- | --- |
| `dsync.user.created` / `dsync.user.updated` | A directory-sync user changes |
| `dsync.group.user_added` | A user is added to a group |
| `connection.activated` / `connection.deactivated` | An SSO connection changes |
| `user.created` | A user is created |
| `authentication.sso_succeeded` | An SSO authentication succeeds |
| `session.revoked` | A session is revoked |
| `organization.created` / `organization_membership.created` | An organization or membership changes |

The envelope carries `event`, `id`, `data`, `created_at`, and `context`. Note a documented absence: Audit Logs have no webhook event type, you can't receive audit-log entries by webhook.

## Setting up WorkOS webhooks

In the WorkOS dashboard, go to Webhooks, add your endpoint, and copy the signing secret into `WORKOS_WEBHOOK_SECRET`. Note that staging retries only over "several minutes" while production retries over 3 days, so staging isn't a faithful rehearsal of production failure behavior.

## Securing WorkOS webhooks

In Node, use the official SDK's `webhooks.constructEvent`, which parses the composite header, reconstructs the signed string, and verifies (throwing on a bad signature or stale timestamp). In Python (the SDK's own examples verify manually), split the header, rebuild `<timestamp> + "." + raw_body` from the timestamp string as delivered, and compare a hex HMAC-SHA256 in constant time. Look the header up case-insensitively, some proxies lowercase it.

```javascript
const { WorkOS } = require("@workos-inc/node");

const workos = new WorkOS(process.env.WORKOS_API_KEY);
const SECRET = process.env.WORKOS_WEBHOOK_SECRET;

app.post("/webhooks/workos", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await workos.webhooks.constructEvent({
      payload: req.body.toString("utf8"), // raw body
      sigHeader: req.headers["workos-signature"],
      secret: SECRET,
    });
    res.sendStatus(200); // acknowledge fast
    processQueue.add(event); // branch on event.event, async
  } catch {
    res.sendStatus(401); // invalid signature or stale timestamp
  }
});

```

The same verification in Python (manual, matching the composite scheme):

```python
import hmac
import hashlib
import os

SECRET = os.environ["WORKOS_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, signature_header: str) -> bool:
    params = dict(p.split("=", 1) for p in (signature_header or "").split(",") if "=" in p)
    timestamp, provided = params.get("t", ""), params.get("v1", "")
    signed = timestamp.encode() + b"." + raw_body  # timestamp used as delivered
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)

```

## WorkOS webhook limitations and pain points

### The header is a composite you must split

The Problem: `WorkOS-Signature` is `t=<timestamp>,v1=<signature>`. Comparing the whole header, or forgetting the timestamp goes into the signed string, never matches.

Why It Happens: WorkOS uses a Stripe-style composite with the timestamp inside the header.

Workarounds:

* Split on the comma, rebuild `<timestamp>.<raw_body>`, and compare the `v1` value; or use the SDK.

How Hookdeck Can Help: Hookdeck parses and verifies the composite header at the edge, so your app receives pre-verified events.

### The event field is `event`, not `type`

The Problem: Many providers put the event name in `type`. WorkOS uses `event`. A handler switching on `type` finds nothing.

Why It Happens: WorkOS names the field `event`.

Workarounds:

* Branch on the top-level `event` field.

How Hookdeck Can Help: Hookdeck's filters route on the `event` field you actually receive.

### Audit Logs aren't available by webhook

The Problem: There's no audit-log webhook event type, so you can't stream audit-log entries via webhooks.

Why It Happens: WorkOS doesn't emit audit logs as webhook events.

Workarounds:

* Use the Audit Logs API or export mechanisms for that data, not webhooks.

How Hookdeck Can Help: Hookdeck gives you reliable delivery and observability for the events that do exist; audit-log retrieval stays on WorkOS's API.

### Staging doesn't mirror production retries

The Problem: Production retries 6 times over 3 days; staging retries only over several minutes. Testing failure handling in staging gives a false sense of resilience.

Why It Happens: WorkOS uses a shorter retry policy in staging.

Workarounds:

* Don't treat staging retry behavior as representative; design for the production policy.

How Hookdeck Can Help: Hookdeck applies a consistent retry policy you control across environments, so behavior doesn't change between staging and production.

## Best practices

### Verify with the SDK (or the composite scheme manually)

Use `webhooks.constructEvent` in Node; in Python split the header, rebuild `<timestamp>.<raw_body>`, and compare a hex HMAC-SHA256 in constant time.

### Read the header case-insensitively

Proxies may lowercase `WorkOS-Signature`, so look it up case-insensitively.

### Branch on the event field and dedupe on id

Route on `event`, and make handlers idempotent on the event `id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast, process asynchronously

Return 200 quickly and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

WorkOS webhooks are verified with a `WorkOS-Signature` composite header (`t=<ts>,v1=<sig>`), an HMAC-SHA256 over `<timestamp>.<raw_body>`. Split the header, verify with the official Node SDK (or the scheme manually in Python), branch on the top-level `event` field, remember that Audit Logs aren't available by webhook, and design for the production retry policy rather than staging's.

[Hookdeck](https://hookdeck.com) verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle WorkOS webhooks reliably in minutes.