| 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.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:
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[0]?.email_address;
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim();
await resend.emails.send({
from: 'noreply@yourdomain.com',
to: email,
subject: 'Welcome!',
html: `<p>Hi ${name}, welcome to our app!</p>`,
});
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New user signed up: ${name} (${email})`,
}),
});
}
return new Response('OK', { status: 200 });
}
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 === 'organizationMembership.created') {
const { organization, public_user_data, role } = evt.data;
const orgId = organization.id;
const userId = public_user_data.user_id;
await db.team_members.create({
data: { orgId, userId, role },
});
await db.workspaces.create({
data: { orgId, userId, createdAt: new Date() },
});
}
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.team_members.delete({
where: { orgId, userId },
});
await db.workspaces.deleteMany({
where: { orgId, userId },
});
}
return new Response('OK', { status: 200 });
}
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('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 res.status(200).json({ received: true });
},
);
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