All skills
clerk avatar

/clerk-webhooks

@ee43556 official
by clerkclerk/skills83 stars
5

Clerk webhooks for real-time events and data syncing. Verify with verifyWebhook from the framework-specific package. Handle user, session, organization, billing, and payment events. Build event-driven features like database sync, notifications, and integrations.

Use this Skill: https://skilld.dev/gh/clerk/skills/clerk-webhooks

This session only. Nothing lands on disk.

referencesframeworks.md

≈1.5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Framework-Specific Webhook Handlers

Each Clerk SDK package ships its own verifyWebhook adapter that reads the framework's native request type and uses CLERK_WEBHOOK_SIGNING_SECRET automatically. Use the framework-specific import; do not roll your own with raw svix.

Same WebhookEvent payload shape across all frameworks. See SKILL.md for payload field reference and the full event catalog.

Express

import { verifyWebhook } from '@clerk/express/webhooks'
import express from 'express'

const app = express()

// Use express.raw() not express.json() for the webhook route -
// signature verification requires the raw body bytes.
app.post('/api/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const evt = await verifyWebhook(req)

    if (evt.type === 'user.created') {
      const { id, email_addresses, first_name, last_name } = evt.data
      const email = email_addresses[0]?.email_address
      console.log(`New user: ${first_name} ${last_name} (${email})`)
    }

    return res.send('Webhook received')
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return res.status(400).send('Error verifying webhook')
  }
})

Astro

// src/pages/api/webhooks.ts
import { verifyWebhook } from '@clerk/astro/webhooks'
import type { APIRoute } from 'astro'

export const POST: APIRoute = async ({ request }) => {
  try {
    const evt = await verifyWebhook(request, {
      signingSecret: import.meta.env.CLERK_WEBHOOK_SIGNING_SECRET,
    })

    if (evt.type === 'user.created') {
      const { id, email_addresses } = evt.data
      const email = email_addresses[0]?.email_address
      console.log(`New user: ${id} (${email})`)
    }

    return new Response('Webhook received', { status: 200 })
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error verifying webhook', { status: 400 })
  }
}

Astro requires explicit signingSecret since import.meta.env is not auto-read.

Fastify

import { verifyWebhook } from '@clerk/fastify/webhooks'
import Fastify from 'fastify'

const fastify = Fastify()

fastify.post('/api/webhooks', async (request, reply) => {
  try {
    const evt = await verifyWebhook(request)

    if (evt.type === 'user.created') {
      const { id } = evt.data
      console.log(`New user: ${id}`)
    }

    return 'Webhook received'
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return reply.code(400).send('Error verifying webhook')
  }
})

Nuxt

// server/api/webhooks.post.ts
import { verifyWebhook } from '@clerk/nuxt/webhooks'

export default defineEventHandler(async (event) => {
  try {
    const evt = await verifyWebhook(event)

    if (evt.type === 'user.created') {
      const { id } = evt.data
      console.log(`New user: ${id}`)
    }

    return 'Webhook received'
  } catch (err) {
    console.error('Error verifying webhook:', err)
    setResponseStatus(event, 400)
    return 'Error verifying webhook'
  }
})

Prefix the env var with NUXT_ (i.e. NUXT_CLERK_WEBHOOK_SIGNING_SECRET) per Nuxt runtime config rules.

When tunneling via ngrok in dev, allow the host in nuxt.config.ts:

export default defineNuxtConfig({
  vite: {
    server: {
      allowedHosts: ['fawn-two-nominally.ngrok-free.app'],
    },
  },
})

React Router

// app/routes/webhooks.ts
import { verifyWebhook } from '@clerk/react-router/webhooks'
import type { Route } from './+types/webhooks'

export const action = async ({ request }: Route.ActionArgs) => {
  try {
    const evt = await verifyWebhook(request)

    if (evt.type === 'user.created') {
      const { id } = evt.data
      console.log(`New user: ${id}`)
    }

    return new Response('Webhook received', { status: 200 })
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error verifying webhook', { status: 400 })
  }
}

Register the route in router.ts:

import { type RouteConfig, route, index } from '@react-router/dev/routes'

export default [
  index('routes/home.tsx'),
  route('api/webhooks', 'routes/webhooks.ts'),
] satisfies RouteConfig

When tunneling via ngrok in dev, allow the host in vite.config.ts:

export default defineConfig({
  server: {
    allowedHosts: ['fawn-two-nominally.ngrok-free.app'],
  },
})

TanStack Start

// app/routes/api/webhooks.ts
import { verifyWebhook } from '@clerk/tanstack-react-start/webhooks'
import { createServerFileRoute } from '@tanstack/react-start/server'

export const ServerRoute = createServerFileRoute().methods({
  POST: async ({ request }) => {
    try {
      const evt = await verifyWebhook(request)

      if (evt.type === 'user.created') {
        const { id } = evt.data
        console.log(`New user: ${id}`)
      }

      return new Response('Webhook received', { status: 200 })
    } catch (err) {
      console.error('Error verifying webhook:', err)
      return new Response('Error verifying webhook', { status: 400 })
    }
  },
})

When tunneling via ngrok in dev, allow the host in app.config.ts:

import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    allowedHosts: ['fawn-two-nominally.ngrok-free.app'],
  },
})

Common Patterns Across Frameworks

  • All verifyWebhook adapters return the same WebhookEvent discriminated union, so handler logic (if (evt.type === ...)) is identical.
  • All adapters read CLERK_WEBHOOK_SIGNING_SECRET automatically except Astro (pass signingSecret option).
  • All adapters require a public webhook route, exclude /api/webhooks(.*) from middleware protection.
  • Vite-based frameworks (Nuxt, React Router, TanStack Start) need allowedHosts configured when tunneling localhost via ngrok in development.
  • Express specifically needs express.raw({ type: 'application/json' }) for the webhook route, raw body bytes are required for signature verification.

Source: SKILL.md on GitHub

1 warning16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides secure templates and instructions for handling Clerk webhooks. It correctly emphasizes signature verification and the use of environment variables for managing secrets.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: MEDIUM · 1 issue

  • Runlayer7mo

    2 files scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at ee43556. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated 3 months ago
What it can do
Network
metadata
{
  "author": "clerk",
  "version": "1.2.0"
}
compatibility
Requires CLERK_WEBHOOK_SIGNING_SECRET (svix signing secret from Clerk dashboard)
All 1 allowed tools
WebFetch
  • Next.js
  • TypeScript
  • clerk
  • webhooks
  • authentication
  • database-sync
  • event-driven
  • svix
  • organizations

README badge

README badge for clerk/skills/clerk-webhooks

Handles Clerk webhook events (user, session, organization, membership, billing, payment) with signature verification via verifyWebhook. Enables database sync, notifications, and integrations triggered by Clerk lifecycle events across Next.js, Express, Astro, Fastify, and other frameworks.

Generated from the current SKILL.md.

Does this skill work with frameworks other than Next.js?
Yes. The skill includes examples for Next.js App Router but covers Express, Astro, Fastify, Nuxt, React Router, and TanStack Start. Each framework has its own `verifyWebhook` adapter (e.g. `@clerk/express/webhooks`, `@clerk/astro/webhooks`).
What environment variable do I need to configure?
You need `CLERK_WEBHOOK_SIGNING_SECRET`, which is the Svix signing secret from your Clerk dashboard. The `verifyWebhook()` function reads it automatically.
Is webhook delivery guaranteed to be immediate?
No. Webhooks are asynchronous and eventually consistent. Svix retries on a fixed schedule, so delivery may be delayed or fail occasionally. Do not rely on webhooks for synchronous flows like onboarding; use the session token or Backend API directly instead.
Do I need to verify every webhook?
Yes. Always call `verifyWebhook(req)` even for notification-only handlers. Skipping verification exposes the endpoint to spoofed events.
Why does my webhook route return 401?
The route is being protected by Clerk middleware. You must exclude webhook routes (e.g. `/api/webhooks(.*)`) from protection using `createRouteMatcher()` in your `clerkMiddleware()` config.

Generated from the current SKILL.md. These answers refresh after source changes.