Clerk Webhooks & Events
Overview
Configure and handle Clerk webhooks for user lifecycle events and data synchronization. Clerk uses Svix for webhook delivery with HMAC-SHA256 signature verification. As of 2025, Clerk provides a built-in verifyWebhook() helper in @clerk/backend alongside the manual Svix approach.
Prerequisites
- Clerk account with webhook endpoint configured in Dashboard
- HTTPS endpoint (use
ngrok for local dev)
CLERK_WEBHOOK_SECRET environment variable (starts with whsec_)
Instructions
Step 1: Install Dependencies
npm install svix
Step 2: Create Webhook Endpoint (verifyWebhook — Recommended)
import { verifyWebhook } from '@clerk/backend/webhooks'
import type { WebhookEvent } from '@clerk/nextjs/server'
export async function POST(req: Request) {
let evt: WebhookEvent
try {
evt = await verifyWebhook(req)
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Invalid signature', { status: 400 })
}
return handleWebhookEvent(evt)
}
Step 2 (Alternative): Manual Svix Verification
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import type { WebhookEvent } from '@clerk/nextjs/server'
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET
if (!WEBHOOK_SECRET) {
throw new Error('Missing CLERK_WEBHOOK_SECRET env variable')
}
const headerPayload = await headers()
const svixHeaders = {
'svix-id': headerPayload.get('svix-id') || '',
'svix-timestamp': headerPayload.get('svix-timestamp') || '',
'svix-signature': headerPayload.get('svix-signature') || '',
}
if (!svixHeaders['svix-id'] || !svixHeaders['svix-signature']) {
return (, { : })
}
body = req.()
wh = ()
:
{
evt = wh.(body, svixHeaders)
} (err) {
.(, err)
(, { : })
}
(evt)
}
Step 3: Implement Event Handlers
async function handleWebhookEvent(evt: WebhookEvent) {
const eventType = evt.type
switch (eventType) {
case 'user.created': {
const { id, email_addresses, first_name, last_name, image_url } = evt.data
const primaryEmail = email_addresses.find(e => e.id === evt.data.primary_email_address_id)
await db.user.create({
data: {
clerkId: id,
email: primaryEmail?.email_address || email_addresses[0]?.email_address,
firstName: first_name,
lastName: last_name,
avatarUrl: image_url,
},
})
console.log(`[Webhook] User created: ${id}`)
break
}
case 'user.updated': {
const { id, email_addresses, first_name, last_name, image_url } = evt.data
const primaryEmail = email_addresses.find(e => e.id === evt.data.primary_email_address_id)
db..({
: { : id },
: {
: primaryEmail?.,
: first_name,
: last_name,
: image_url,
},
: {
: id,
: primaryEmail?. || ,
: first_name,
: last_name,
: image_url,
},
})
}
: {
(evt..) {
db..({
: { : evt.. },
: { : () },
})
}
}
: {
{ id, name, slug, created_by } = evt.
db..({
: { : id, name, : slug || , : created_by },
})
}
: {
{ organization, public_user_data, role } = evt.
db..({
: {
: organization.,
: public_user_data.,
role,
},
})
}
:
.()
:
.()
}
(, { : })
}
Step 4: Idempotency Protection
export async function processIdempotently(
svixId: string,
eventType: string,
handler: () => Promise<void>
): Promise<{ processed: boolean; duplicate: boolean }> {
const existing = await db.webhookEvent.findUnique({
where: { svixId },
})
if (existing) {
console.log(`[Webhook] Duplicate event skipped: ${svixId} (${eventType})`)
return { processed: false, duplicate: true }
}
await db.webhookEvent.create({
data: { svixId, eventType, status: 'processing', receivedAt: new Date() },
})
try {
await handler()
db..({
: { svixId },
: { : , : () },
})
{ : , : }
} (error) {
db..({
: { svixId },
: { : , : (error) },
})
error
}
}
Step 5: Configure Webhook in Clerk Dashboard
- Navigate to Clerk Dashboard > Webhooks > Add Endpoint
- Set endpoint URL:
https://yourdomain.com/api/webhooks/clerk
- Select events to subscribe to:
- User events:
user.created, user.updated, user.deleted
- Org events:
organization.created, organizationMembership.created
- Session events:
session.created, session.ended (optional, high volume)
- Copy the Signing Secret (
whsec_...) to your .env.local:
CLERK_WEBHOOK_SECRET=whsec_...
Step 6: Express.js Webhook Endpoint
import express from 'express'
import { Webhook } from 'svix'
const app = express()
app.post('/api/webhooks/clerk',
express.raw({ type: 'application/json' }),
(req, res) => {
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!)
try {
const evt = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
res.status(200).json({ received: true })
} catch (err) {
console.error('Webhook verification failed:', err)
res.().({ : })
}
}
)
Local Development with ngrok
ngrok http 3000
Error Handling
| Error | Cause | Solution |
|---|
| Invalid signature | Wrong CLERK_WEBHOOK_SECRET | Re-copy signing secret from Dashboard > Webhooks |
| Invalid signature | Body parsed with json() before verify | Use req.text() (Next.js) or express.raw() (Express) |
| Missing svix headers | Request not from Clerk/Svix | Verify endpoint URL; check sender |
| Duplicate processing | Clerk retried delivery | Implement idempotency with svix-id as unique key |
| Handler timeout | Slow DB operations | Offload heavy work to a background job queue |
| 404 on webhook URL | Route not matching | Ensure /api/webhooks is in middleware's isPublicRoute |
Enterprise Considerations
- Treat
CLERK_WEBHOOK_SECRET like a password -- rotate it if compromised (Dashboard > Webhooks > Signing Secret > Rotate)
- Svix headers include
svix-timestamp for replay attack protection (rejects events older than 5 minutes by default)
- For high-volume apps, offload webhook processing to a queue (BullMQ, Inngest, Trigger.dev) and return 200 immediately
- Monitor webhook delivery in Dashboard > Webhooks > Message Logs -- failed messages auto-retry with exponential backoff
- Use
verifyWebhook() from @clerk/backend/webhooks when possible -- it handles header extraction and secret key resolution automatically
Resources
Next Steps
Proceed to clerk-performance-tuning for optimization strategies.