clerk-webhooks
Real-time event webhooks for syncing Clerk user, organization, and session data to external systems.
- Supports 40+ event types across users, organizations, sessions, roles, permissions, invitations, and communications
- Includes built-in webhook verification via
verifyWebhook()and automatic retry logic through Svix (up to 3 days) - Best suited for background tasks like database syncing, notifications, and integrations; not for synchronous flows requiring immediate data access
- Requires public, unprotected route and environment variable
CLERK_WEBHOOK_SIGNING_SECRETfor signature verification
Webhooks
Always output complete, working, copy-paste-ready webhook handlers. Never output stubs, placeholders, or partial implementations. Include verifyWebhook(req) in every handler.
CRITICAL: Always Verify Webhooks
NEVER skip signature verification, even for notification-only handlers. Always use verifyWebhook(req) from @clerk/nextjs/webhooks. This uses the CLERK_WEBHOOK_SECRET env var automatically.
CRITICAL: Make Webhook Route Public
Webhook routes MUST be excluded from Clerk middleware protection. Without this, Clerk returns 401.
// proxy.ts (Next.js <=15: middleware.ts)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublicRoute = createRouteMatcher(['/api/webhooks(.*)'])
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) auth().protect()
})
Complete Webhook Handler (Next.js App Router)
// app/api/webhooks/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
export async function POST(req: NextRequest) {
// ALWAYS verify - never skip, even for notification-only handlers
let evt
try {
evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SECRET automatically
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Verification failed', { status: 400 })
}
if (evt.type === 'user.created') {
const { id, email_addresses, first_name, last_name } = evt.data
const email = email_addresses[0]?.email_address
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim()
await db.users.create({ data: { clerkId: id, email, name } })
}
if (evt.type === 'user.updated') {
const { id, email_addresses, first_name, last_name } = evt.data
const email = email_addresses[0]?.email_address
await db.users.update({ where: { clerkId: id }, data: { email, first_name, last_name } })
}
if (evt.type === 'user.deleted') {
const { id } = evt.data
await db.users.delete({ where: { clerkId: id } })
}
if (evt.type === 'organizationMembership.created') {
const { organization, public_user_data, role } = evt.data
const orgId = organization.id
const userId = public_user_data.user_id
await db.teamMembers.create({ data: { orgId, userId, role } })
}
if (evt.type === 'organizationMembership.deleted') {
const { organization, public_user_data } = evt.data
const orgId = organization.id
const userId = public_user_data.user_id
await db.teamMembers.delete({ where: { orgId_userId: { orgId, userId } } })
}
return new Response('OK', { status: 200 })
}
Full Example: Welcome Email (Resend) + Slack Notification on user.created
ALWAYS use this COMPLETE pattern — never stub it out:
// app/api/webhooks/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function POST(req: NextRequest) {
// Step 1: ALWAYS verify the webhook signature - NEVER skip this
let evt
try {
evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SECRET env var
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Verification failed', { status: 400 })
}
// Step 2: Listen for user.created event
if (evt.type === 'user.created') {
// Step 3: Extract user email and name from webhook payload
const { id, email_addresses, first_name, last_name } = evt.data
const email = email_addresses[0]?.email_address
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim()
// Step 4: Call Resend API to send welcome email
await resend.emails.send({
from: 'noreply@yourdomain.com',
to: email,
subject: 'Welcome!',
html: `<p>Hi ${name}, welcome to our app!</p>`,
})
// Step 5: Post notification to Slack channel
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New user signed up: ${name} (${email})`,
}),
})
}
// Always return 200 to acknowledge receipt
return new Response('OK', { status: 200 })
}
Also include proxy.ts (Next.js <=15: middleware.ts) to make the route public:
// proxy.ts (Next.js <=15: middleware.ts)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublicRoute = createRouteMatcher(['/api/webhooks(.*)'])
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) auth().protect()
})
Full Example: Organization Membership Sync to Database
// app/api/webhooks/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { db } from '@/lib/db' // your database client
export async function POST(req: NextRequest) {
// ALWAYS verify signature - never skip, even for simple handlers
let evt
try {
evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SECRET env var
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Verification failed', { status: 400 })
}
if (evt.type === 'organization.created') {
const { id, name } = evt.data
await db.workspaces.create({
data: { orgId: id, name, createdAt: new Date() },
})
}
if (evt.type === 'organizationMembership.created') {
// Extract organization ID, user ID, and role from payload
const { organization, public_user_data, role } = evt.data
const orgId = organization.id
const userId = public_user_data.user_id
// Add to team_members table
await db.team_members.create({
data: { orgId, userId, role },
})
// Create workspace record for new member
await db.workspaces.create({
data: { orgId, userId, createdAt: new Date() },
})
}
if (evt.type === 'organizationMembership.deleted') {
// Extract organization ID and user ID from payload
const { organization, public_user_data } = evt.data
const orgId = organization.id
const userId = public_user_data.user_id
// Remove from team_members table
await db.team_members.delete({
where: { orgId, userId },
})
// Remove workspace record
await db.workspaces.deleteMany({
where: { orgId, userId },
})
}
// Return 200 status on success
return new Response('OK', { status: 200 })
}
Express.js Webhook Handler
CRITICAL: Use
express.raw()NOTexpress.json()for webhook routes. Signature verification requires the raw body bytes.express.json()parses the body and breaks verification.
import express from 'express'
import { Webhook } from 'svix'
const app = express()
// WRONG - breaks verification because it parses the body:
// app.use(express.json())
// CORRECT - use raw body for webhook route only:
app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!
const wh = new Webhook(webhookSecret)
let evt: any
try {
// Svix verifies using raw body bytes + svix headers
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,
})
} catch (err) {
console.error('Webhook verification failed:', err)
return res.status(400).json({ error: 'Verification failed' })
}
if (evt.type === 'user.created') {
const { id, email_addresses, first_name, last_name } = evt.data
const email = email_addresses[0]?.email_address
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim()
console.log(`New user: ${name} (${email})`)
}
if (evt.type === 'user.updated') {
const { id, email_addresses } = evt.data
const email = email_addresses[0]?.email_address
console.log(`User updated: ${id}, email: ${email}`)
}
if (evt.type === 'user.deleted') {
const { id } = evt.data
console.log(`User deleted: ${id}`)
}
// Return 200 status on success
return res.status(200).json({ received: true })
})
Payload Field Reference
User events (user.created, user.updated, user.deleted)
const {
id, // Clerk user ID
email_addresses, // array; [0].email_address is primary email
first_name,
last_name,
image_url,
public_metadata,
} = evt.data
Organization events (organization.created, organization.updated, organization.deleted)
const {
id, // org ID
name, // org name
slug,
} = evt.data
Organization Membership events (organizationMembership.created, organizationMembership.updated, organizationMembership.deleted)
const {
organization, // { id, name, ... }
public_user_data, // { user_id, first_name, last_name, ... }
role, // e.g. 'org:admin', 'org:member'
} = evt.data
// Access: organization.id, public_user_data.user_id, role
Supported Events (Full Catalog)
User: user.created user.updated user.deleted
Session: session.created session.ended session.pending session.removed session.revoked
Organization: organization.created organization.updated organization.deleted
Organization Membership: organizationMembership.created organizationMembership.updated organizationMembership.deleted
Organization Domain: organizationDomain.created organizationDomain.updated organizationDomain.deleted
Organization Invitation: organizationInvitation.accepted organizationInvitation.created organizationInvitation.revoked
Communication: email.created sms.created
Invitation: invitation.accepted invitation.created invitation.revoked
Waitlist: waitlistEntry.created waitlistEntry.updated
Permission: permission.created permission.updated permission.deleted
Role: role.created role.updated role.deleted
Subscription: subscription.created subscription.updated subscription.active subscription.pastDue
Subscription Item: subscriptionItem.created subscriptionItem.active subscriptionItem.updated subscriptionItem.canceled subscriptionItem.upcoming subscriptionItem.ended subscriptionItem.abandoned subscriptionItem.incomplete subscriptionItem.pastDue subscriptionItem.freeTrialEnding
Payment: paymentAttempt.created paymentAttempt.updated
Webhook Reliability
Retries: Svix retries failed webhooks on a set schedule (see Svix Retry Schedule). Return 2xx to succeed, 4xx/5xx to retry. Use the svix-id header as an idempotency key to deduplicate retried events.
Replay: Failed webhooks can be replayed from Dashboard.
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Verification fails (Next.js) | Wrong import or usage | Use @clerk/nextjs/webhooks, pass req directly |
| Verification fails (Express) | Using express.json() |
Use express.raw({ type: 'application/json' }) for webhook route |
| Route not found (404) | Wrong path | Use /api/webhooks or preserve existing path |
| Not authorized (401) | Route is protected by middleware | Make route public in clerkMiddleware() |
| No data in DB | Async job pending | Wait/check logs |
| Duplicate entries | Only handling user.created |
Also handle user.updated |
| Timeouts | Handler too slow | Queue async work, return 200 first |
Testing & Deployment
Local: Use ngrok to tunnel localhost:3000 to internet. Add ngrok URL to Dashboard endpoint.
Production: Update webhook endpoint URL to production domain. Copy CLERK_WEBHOOK_SECRET to production env vars.
See Also
clerk-setup- Initial Clerk installclerk-orgs- Org membership eventsclerk-backend-api- Sync via direct API calls