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_SECRET for signature verification

INSTALLATION
npx skills add https://github.com/clerk/skills --skill clerk-webhooks
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2a

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(async (auth, req) => {

  if (!isPublicRoute(req)) await 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_SIGNING_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

Notification-only handlers still verify the signature. Same pattern as the database-sync handler:

// 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_SIGNING_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(async (auth, req) => {

  if (!isPublicRoute(req)) await 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_SIGNING_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 })

}

Other Frameworks

For Express, Astro, Fastify, Nuxt, React Router, and TanStack Start, use the framework-specific verifyWebhook adapter. Each Clerk SDK package ships its own (@clerk/express/webhooks, @clerk/astro/webhooks, @clerk/fastify/webhooks, etc.).

See references/frameworks.md for full handler examples per framework.

Type Narrowing for evt.data

verifyWebhook returns WebhookEvent, a discriminated union of all event types. Narrow with evt.type to get type-safe access to evt.data:

const evt = await verifyWebhook(req)

if (evt.type === 'user.created') {

  // evt.data is now UserJSON, autocompletes id, email_addresses, etc.

  console.log(evt.data.id)

}

For manual typing of nested payloads, import the JSON types from your framework's webhook subpath: DeletedObjectJSON, EmailJSON, OrganizationInvitationJSON, OrganizationJSON, OrganizationMembershipJSON, SessionJSON, SMSMessageJSON, UserJSON.

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.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

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 &#x26; Deployment

Local: Tunnel localhost:3000 to the internet so Clerk can reach the endpoint. Common options: ngrok, localtunnel, Cloudflare Tunnel. Add the public URL to the Dashboard endpoint.

Production: Update webhook endpoint URL to production domain. Copy CLERK_WEBHOOK_SIGNING_SECRET to production env vars.

References

Reference

Description

references/frameworks.md

Webhook handler examples for Express, Astro, Fastify, Nuxt, React Router, TanStack Start

See Also

  • clerk-setup - Initial Clerk install
  • clerk-orgs - Org membership events
  • clerk-billing - Subscription, subscription item, and payment attempt events
  • clerk-backend-api - Sync via direct API calls
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card