Clerk
Verify and handle Clerk webhooks — user, session, organization, and billing events.
Create the endpoint on the Webhooks page in the Clerk Dashboard, pass its signing secret, and handle events by name:
import { createWebhookHandler } from 'webhooks-sdk'
import { clerk } from 'webhooks-sdk/clerk'
const handler = createWebhookHandler({
provider: clerk({ secret: process.env.CLERK_WEBHOOK_SIGNING_SECRET! }),
on: {
'user.created': async (event) => {
await syncUser(event.payload.data.id)
},
'session.revoked': async (event) => {
await dropSession(event.payload.data.id)
},
},
})
export const POST = handler.fetch
The secret is the whsec_… value shown when you select the endpoint on the
Webhooks page. Clerk delivers through Svix and signs with Standard
Webhooks over the legacy svix-*
headers — the id and timestamp are inside the signed material, so a replayed
request is rejected on its own.
Options
| Option | Type | Default | |
|---|---|---|---|
secret |
string | string[] |
— | The whsec_… signing secret(s). Pass an array during rotation. |
tolerance |
number |
300 |
Replay window in seconds. |
Events
user.created · user.updated · user.deleted · session.created ·
session.ended · session.removed · session.revoked · email.created ·
sms.created · organization.created · organization.updated ·
organization.deleted · organizationDomain.created ·
organizationDomain.updated · organizationDomain.deleted ·
organizationInvitation.created · organizationInvitation.accepted ·
organizationInvitation.revoked · organizationMembership.created ·
organizationMembership.updated · organizationMembership.deleted ·
role.created · role.updated · role.deleted · permission.created ·
permission.updated · permission.deleted · waitlistEntry.created ·
waitlistEntry.updated · paymentAttempt.created ·
paymentAttempt.updated · subscription.created · subscription.updated ·
subscription.active · subscription.pastDue · subscriptionItem.created ·
subscriptionItem.updated · subscriptionItem.active ·
subscriptionItem.canceled · subscriptionItem.upcoming ·
subscriptionItem.ended · subscriptionItem.abandoned ·
subscriptionItem.incomplete · subscriptionItem.pastDue ·
subscriptionItem.freeTrialEnding
These autocomplete in the on map; any other string still routes, so an
event type Clerk adds later is handled without an SDK update. The
authoritative list for your instance is the Event Catalog tab on the
endpoint in the Clerk Dashboard. The subscription.*, subscriptionItem.*,
and paymentAttempt.* families are Clerk Billing events.
Mind the casing: multi-word resources are camelCase on the wire —
organizationMembership.created, waitlistEntry.updated,
subscription.pastDue, subscriptionItem.freeTrialEnding. Those are Clerk’s
own strings, kept verbatim because they must match the wire.
Two timestamps, two units
The body’s timestamp is milliseconds since the epoch, while the signed
svix-timestamp header is seconds. event.timestamp is built from the
header, so use it — or divide before comparing the two.
The envelope
event.id— thesvix-idheader, the canonical idempotency key.event.type— the body’stype.event.timestamp— the signedsvix-timestampheader.event.payload—{ data, object: 'event', type, timestamp, instance_id }; the affected resource ispayload.data, identified bypayload.data.id.
Standalone & testing
The wrapper is the Standard Webhooks
provider with id: 'clerk', so the
standalone triple lives there:
import {
verifyStandardWebhook, // (raw, { secret }) — throws on failure
parseStandardWebhook, // (raw) — the envelope
signStandardWebhook, // (body, secret, { headerPrefix: 'svix' }) — for tests
} from 'webhooks-sdk/standard-webhooks'
Pass headerPrefix: 'svix' to signStandardWebhook to produce the header
names Clerk actually sends. See Testing.