From features
Generates complete, verified Next.js webhook handlers for Clerk events including user create/update/delete and organization membership. Enables database sync, notifications, integrations.
npx claudepluginhub clerk/skills --plugin mobileThis skill is limited to using the following tools:
Always output complete, working, copy-paste-ready webhook handlers. Never output stubs, placeholders, or partial implementations. Include `verifyWebhook(req)` in every handler.
Sets up Clerk webhook endpoints in Next.js to verify signatures and handle auth events for user sync using @clerk/backend or Svix.
Provides expert Clerk auth patterns for Next.js App Router: ClerkProvider setup, SignIn/SignUp components, middleware route protection, and anti-patterns.
Provides expert patterns for Clerk auth implementation, middleware, organizations, webhooks, and user sync. Grounds responses in reference files for creation, diagnosis, and review.
Share bugs, ideas, or general feedback.
Always output complete, working, copy-paste-ready webhook handlers. Never output stubs, placeholders, or partial implementations. Include verifyWebhook(req) in every handler.
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.
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()
})
// 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 })
}
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()
})
// 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 })
}
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 })
})
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.created, organization.updated, organization.deleted)const {
id, // org ID
name, // org name
slug,
} = evt.data
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
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
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.
| 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 |
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.
clerk-setup - Initial Clerk installclerk-orgs - Org membership eventsclerk-backend-api - Sync via direct API calls