| name | clerk-webhooks |
| description | Clerk webhooks for real-time events and data syncing. Always output complete, copy-paste-ready webhook handlers with verifyWebhook(req) verification. Listen for user creation, updates, deletion, and organization events. Build event-driven features like database sync, notifications, integrations. |
| allowed-tools | WebFetch |
| license | MIT |
| metadata | {"author":"clerk","version":"1.2.0"} |
| compatibility | Requires CLERK_WEBHOOK_SECRET (svix signing secret from Clerk dashboard) |
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.
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)
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
export async function POST(req: NextRequest) {
let evt
try {
evt = await verifyWebhook(req)
} 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..({ : { : id, email, name } })
}
(evt. === ) {
{ id, email_addresses, first_name, last_name } = evt.
email = email_addresses[]?.
db..({ : { : id }, : { email, first_name, last_name } })
}
(evt. === ) {
{ id } = evt.
db..({ : { : id } })
}
(evt. === ) {
{ organization, public_user_data, role } = evt.
orgId = organization.
userId = public_user_data.
db..({ : { orgId, userId, role } })
}
(evt. === ) {
{ organization, public_user_data } = evt.
orgId = organization.
userId = public_user_data.
db..({ : { : { orgId, userId } } })
}
(, { : })
}
Full Example: Welcome Email (Resend) + Slack Notification on user.created
ALWAYS use this COMPLETE pattern — never stub it out:
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) {
let evt
try {
evt = await verifyWebhook(req)
} 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[]?.
name = .()
resend..({
: ,
: email,
: ,
: ,
})
(process..!, {
: ,
: { : },
: .({
: ,
}),
})
}
(, { : })
}
Also include proxy.ts (Next.js <=15: middleware.ts) to make the route public:
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
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
export async function POST(req: NextRequest) {
let evt
try {
evt = await verifyWebhook(req)
} 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 === ) {
{ organization, public_user_data, role } = evt.
orgId = organization.
userId = public_user_data.
db..({
: { orgId, userId, role },
})
db..({
: { orgId, userId, : () },
})
}
(evt. === ) {
{ organization, public_user_data } = evt.
orgId = organization.
userId = public_user_data.
db..({
: { orgId, userId },
})
db..({
: { orgId, userId },
})
}
(, { : })
}
Express.js Webhook Handler
CRITICAL: Use express.raw() NOT express.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()
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 {
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(, err)
res.().({ : })
}
(evt. === ) {
{ id, email_addresses, first_name, last_name } = evt.
email = email_addresses[]?.
name = .()
.()
}
(evt. === ) {
{ id, email_addresses } = evt.
email = email_addresses[]?.
.()
}
(evt. === ) {
{ id } = evt.
.()
}
res.().({ : })
})
Payload Field Reference
User events (user.created, user.updated, user.deleted)
const {
id,
email_addresses,
first_name,
last_name,
image_url,
public_metadata,
} = evt.data
Organization events (organization.created, organization.updated, organization.deleted)
const {
id,
name,
slug,
} = evt.data
Organization Membership events (organizationMembership.created, organizationMembership.updated, organizationMembership.deleted)
const {
organization,
public_user_data,
role,
} = evt.data
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 install
clerk-orgs - Org membership events
clerk-backend-api - Sync via direct API calls