clerk-webhooks

Always output complete, working, copy-paste-ready webhook handlers. Never output stubs, placeholders, or partial implementations. Include verifyWebhook(req) in every handler.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "clerk-webhooks" with this command: npx skills add clerk/skills/clerk-skills-clerk-webhooks

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: `&#x3C;p>Hi ${name}, welcome to our app!&#x3C;/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() 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()

// 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 install

  • clerk-orgs

  • Org membership events

  • clerk-backend-api

  • Sync via direct API calls

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

clerk-nextjs-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
9.7K-clerk
General

clerk

No summary provided by upstream source.

Repository SourceNeeds Review
6.3K-clerk
General

clerk-setup

No summary provided by upstream source.

Repository SourceNeeds Review
5.5K-clerk
General

clerk-custom-ui

No summary provided by upstream source.

Repository SourceNeeds Review
5.2K-clerk