# Guide to Clerk Webhooks: Features and Best Practices

Clerk webhooks notify your application when something changes in your user authentication system: a user signs up, a session starts, an organization adds a member. If you're building on [Clerk](https://clerk.com)'s user management platform, webhooks are how you sync users to your database, send welcome emails, and provision resources without polling.

This guide covers how Clerk webhooks work, the Svix-powered signature scheme and its raw-body requirement, the event envelope and catalog, the delivery semantics that shape your handler, and the best practices for production.

## What are Clerk webhooks?

Clerk delivers webhooks as HTTP POSTs to endpoints you register in the Clerk Dashboard. Delivery is handled by Svix, which brings automatic retries, signature verification, and comprehensive logging. Requests follow the [Standard Webhooks](https://www.standardwebhooks.com/) protocol, with one naming quirk: Clerk sends the headers as `svix-id`, `svix-timestamp`, and `svix-signature` rather than the spec's `webhook-*` names (the format is otherwise identical).

## Clerk webhook features

| Feature | Details |
| --- | --- |
| Configuration | Clerk Dashboard, Webhooks section: add an endpoint URL and select the events to receive |
| Verification | HMAC-SHA256 over `svix-id.svix-timestamp.rawBody`, base64-encoded, sent in `svix-signature` as `v1,<signature>` (possibly several, space-separated); secret format `whsec_<base64>` |
| Envelope | `data`, `object`, `type`, `instance_id`, and `timestamp` (milliseconds since epoch) |
| Events | User, session, organization, membership, and invitation events; full catalog in the Dashboard's Event Catalog |
| Retries | Failed deliveries are retried with exponential backoff for up to 3 days; 2xx acknowledges, 4xx or 5xx triggers a retry |
| Replay | Failed webhooks can be replayed from the Clerk Dashboard |
| Delivery | At-least-once, with no ordering guarantee |
| Replay protection | Reject deliveries whose `svix-timestamp` is older than 5 minutes |
| IP allowlisting | Optional, against Svix's published IP addresses |
| SDK verification | `verifyWebhook(request)` from `@clerk/backend/webhooks` (Next.js); the `standardwebhooks` npm package (Express) |

## Common events

Clerk event names are the `type` field values. The ones most integrations start with:

| Event | Fires when |
| --- | --- |
| `user.created` | A new user signs up |
| `user.updated` | A user's profile or metadata changes |
| `user.deleted` | A user account is removed |
| `session.created` | A user signs in |
| `session.ended` | A user signs out |
| `session.removed` | A session is revoked |
| `organization.created` | A new organization is created |
| `organization.updated` | Organization settings change |
| `organizationMembership.created` | A user joins an organization |
| `organizationInvitation.created` | An invite is sent to join an organization |

Branch on the `type` field. If you sync users to your own database, subscribe to all three user events: handling only `user.created` leaves updates and deletions unreflected and is a common cause of stale or duplicate records. The full event reference lives in Clerk's docs and in the Dashboard's Event Catalog tab.

> See Clerk webhook payloads in action. Inspect and replay sample Clerk webhook payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up Clerk webhooks

Registration happens in the Clerk Dashboard:

1. Go to the [Clerk Dashboard](https://dashboard.clerk.com/) and select your application.
2. Navigate to Webhooks in the left sidebar and click Add Endpoint.
3. Enter your endpoint URL, for example `https://yourdomain.com/webhooks/clerk`.
4. Select the events to receive: user events (`user.created`, `user.updated`, `user.deleted`), session events (`session.created`, `session.ended`, `session.removed`), and organization events if you use them.
5. Click Create, open the endpoint's details, and copy the Signing Secret (it starts with `whsec_`).

Store the secret in your environment as `CLERK_WEBHOOK_SIGNING_SECRET` (the name used by `@clerk/nextjs` and Clerk's docs; `CLERK_WEBHOOK_SECRET` appears as an alternative in some examples), and never commit it to source control. Two more setup details matter: the webhook route must be public, so exclude it from `clerkMiddleware()` or any auth middleware, and you can confirm everything works with the Dashboard's Send test event feature.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 clerk --path /webhooks/clerk`) gives you a public URL that forwards to your local server, no account needed. Use the tunnel URL when adding your endpoint in the Clerk Dashboard, then switch to your live URL and production signing secret when you deploy.

## Securing Clerk webhooks

Every delivery carries three headers: `svix-id` (unique message identifier), `svix-timestamp` (Unix seconds), and `svix-signature` (one or more base64 HMAC-SHA256 signatures in `v1,<signature>` format, space-separated). The signed content is `svix-id.svix-timestamp.rawBody`, keyed with the base64-decoded portion of your `whsec_` secret. Because the signature covers the exact bytes Clerk sent, verification needs the raw request body: a parsed and re-serialized JSON body will fail.

The skill recommends the `standardwebhooks` npm package for Express, mapping Clerk's `svix-*` headers to the `webhook-*` names the library expects:

```javascript
const express = require("express");
const { Webhook } = require("standardwebhooks");

const app = express();

// Use express.raw() for the webhook route: verification needs the raw body
app.post(
  "/webhooks/clerk",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const secret = process.env.CLERK_WEBHOOK_SIGNING_SECRET;
    if (!secret || !secret.startsWith("whsec_")) {
      return res.status(500).json({ error: "Server configuration error" });
    }

    // Clerk sends svix-* headers; standardwebhooks expects webhook-*
    const headers = {
      "webhook-id": req.headers["svix-id"],
      "webhook-timestamp": req.headers["svix-timestamp"],
      "webhook-signature": req.headers["svix-signature"]
    };

    let event;
    try {
      const wh = new Webhook(secret);
      event = wh.verify(req.body, headers);
    } catch (err) {
      return res.status(400).json({ error: "Webhook verification failed" });
    }

    switch (event.type) {
      case "user.created":
        console.log("User created:", event.data.id);
        break;
      case "session.created":
        console.log("Session created:", event.data.user_id);
        break;
      default:
        console.log("Unhandled event:", event.type);
    }

    res.status(200).json({ success: true });
  }
);

```

In Next.js App Router, use `verifyWebhook(request)` from `@clerk/backend/webhooks` and pass the request directly; it reads `CLERK_WEBHOOK_SIGNING_SECRET`. If you verify manually, use a timing-safe comparison, check every signature in the header (Svix can send several), and reject timestamps older than 5 minutes to prevent replays. Access headers in lowercase, since some frameworks capitalize them.

> Make Clerk webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies Svix signatures upstream, deduplicates, and durably queues every event.

## Clerk webhook limitations and pain points

### Signature verification demands the raw body

The Problem: Verification fails whenever the handler sees a parsed body, and the failure mode is an opaque "invalid signature" error that looks like a wrong secret.

Why It Happens: The HMAC covers the exact bytes of `svix-id.svix-timestamp.rawBody`. Framework body parsers (Express's `express.json()`, Next.js's default body parsing) consume the raw bytes before your code runs, and re-serializing the parsed JSON produces different bytes.

Workarounds:

* Use `express.raw({ type: "application/json" })` on the webhook route in Express, disable body parsing in Next.js Pages Router (`export const config = { api: { bodyParser: false } }`), or read `await request.body()` in FastAPI.
* Check the other usual suspects in order: all three `svix-*` headers present, secret matches exactly and starts with `whsec_`, headers accessed in lowercase.

How Hookdeck Can Help: Hookdeck verifies the Svix signature at the edge and logs every request's exact headers and body, so a verification failure is visible and diagnosable instead of a silent 400.

### Duplicate and out-of-order events

The Problem: The same event can arrive more than once, and events can arrive out of order, so a handler that blindly inserts on `user.created` produces duplicate rows and one that assumes lifecycle order breaks.

Why It Happens: Delivery is at-least-once with no ordering guarantee, and failed deliveries are retried with exponential backoff for up to 3 days, so a delayed retry can land long after newer events.

Workarounds:

* Make handlers idempotent: upsert keyed on the Clerk object's `id` rather than inserting.
* Handle `user.updated` and `user.deleted` alongside `user.created` so your database converges on the current state regardless of arrival order.

How Hookdeck Can Help: [Deduplication rules](/docs/deduplication) drop repeats at the edge before they reach your handler. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Slow handlers turn into timeouts and retry noise

The Problem: A handler that does heavy work inline (provisioning resources, calling third-party APIs) responds slowly, gets treated as failed, and the same event comes back again as a retry.

Why It Happens: A 2xx response acknowledges the delivery; anything else, including a timeout, triggers Svix's retry schedule. The skill's guidance is to keep webhook processing under 5 seconds and move heavy operations to background jobs.

Workarounds:

* Verify, enqueue, and return 200 immediately; do the real work in a background job.
* Monitor for repeated failures so a persistently failing endpoint gets fixed before retries are exhausted.

How Hookdeck Can Help: Hookdeck acknowledges Clerk in milliseconds and [retries](/docs/retries) delivery to your handler on its own schedule, with a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) to keep bursts from overwhelming your consumer.

### Missing events with no obvious error

The Problem: Webhooks silently stop arriving, or specific event types never show up, and your database quietly drifts out of sync.

Why It Happens: The usual causes are mundane: the event type was never selected in the endpoint configuration, the webhook route is protected by auth middleware and returns 401, or the route path doesn't match what was registered (404). Clerk sends webhooks to a public route, so `clerkMiddleware()` protecting the path blocks every delivery.

Workarounds:

* Exclude the webhook path from auth middleware and use one consistent path (such as `/webhooks/clerk`) in both code and Dashboard.
* Check the Clerk Dashboard's webhook logs for delivery attempts and response codes, and replay failed webhooks from the Dashboard once fixed.

How Hookdeck Can Help: [Issues](/docs/issues) alert you when deliveries start failing, and the full request history shows exactly which events arrived and how your endpoint responded.

## Best practices

### Verify every delivery before touching it

Verify the signature first and parse second. Use the library for your stack (`verifyWebhook` in Next.js, `standardwebhooks` in Express), keep the raw body available, reject stale timestamps, and never log the signing secret.

### Return 200 fast, process asynchronously

Acknowledge as soon as verification passes and queue the real work. Slow responses become retries, and retries become duplicates. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Upsert on the object's id

At-least-once delivery guarantees you'll eventually see a duplicate. Key records on the Clerk object's `id` and upsert so replays and retries are harmless. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) covers the patterns.

### Handle all three user events

Subscribing only to `user.created` leaves your copy stale as soon as a profile changes or an account is deleted. Handle `user.updated` and `user.deleted` too, so your database follows the full lifecycle.

### Sync only what you need

Webhooks are eventually consistent, so sync when you need other users' data, custom fields, or integrations. When you only need the current user's data and Clerk already holds everything, read it from the session token instead of maintaining a copy.

### Separate environments and use test events

Register separate endpoints for staging and production, each with its own signing secret, and use the Dashboard's test events to confirm your handler before real traffic arrives. Logging received webhooks makes later debugging much easier.

## Conclusion

Clerk webhooks ride on Svix: HMAC-SHA256 signatures over the raw body, `svix-*` headers, at-least-once unordered delivery, and retries with exponential backoff for up to 3 days. Verify with the raw body, keep the route public, return 200 quickly, and upsert on the object's `id` so duplicates and reordering never corrupt your data.

[Hookdeck Event Gateway](https://hookdeck.com) verifies the Svix signature at the edge, absorbs delivery bursts, deduplicates, and retries your handler independently, so Clerk's retry schedule and your processing speed stop being coupled.

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