← ClerkCONTENT HISTORY

Update to Clerk

Snapshot Sep 30, 2026 · 23:09 UTC · version 0.1.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "name": "clerk-webhooks",
  "description": "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.",
  "included_files": [
    {
      "relative_path": "evals/evals.json",
      "size_in_bytes": 3750
    },
    {
      "relative_path": "references/frameworks.md",
      "size_in_bytes": 6073
    }
  ],
  "skill_md_contents": "---\nname: clerk-webhooks\ndescription: Clerk webhooks for real-time events and data syncing. Verify with verifyWebhook\n  from the framework-specific package. Handle user, session, organization, billing, and\n  payment events. Build event-driven features like database sync, notifications, and\n  integrations.\nallowed-tools: WebFetch\nlicense: MIT\nmetadata:\n  author: clerk\n  version: 1.2.0\ncompatibility: Requires CLERK_WEBHOOK_SIGNING_SECRET (svix signing secret from Clerk dashboard)\n---\n\n# Webhooks\n\nOutput complete, working webhook handlers with `verifyWebhook(req)` verification in every handler.\n\n## When to Use Webhooks\n\nWebhooks are **asynchronous and eventually consistent**. Delivery is fast but not guaranteed to be immediate, and may occasionally fail (Svix retries on a fixed schedule). Use them for:\n\n- Database sync (a separate users / orgs table that follows Clerk)\n- Notifications (welcome emails, Slack pings, internal alerts)\n- Integrations triggered by lifecycle events\n\nDo NOT rely on webhook delivery as part of a synchronous flow such as onboarding (\"user signs up, then we read X from our DB\"). For data the user just created, read it from the [Clerk session token](https://clerk.com/docs/guides/sessions/session-tokens) or call the Backend API directly. Webhooks fill the gap when you need data about *other* users or events the session token doesn't carry.\n\n## Verify Every Webhook\n\nUse `verifyWebhook(req)` from the framework-specific package (`@clerk/nextjs/webhooks`, `@clerk/express/webhooks`, etc.). It reads `CLERK_WEBHOOK_SIGNING_SECRET` automatically and throws on bad signatures. Skipping verification, even for notification-only handlers, exposes the endpoint to spoofed events.\n\n## Make the Webhook Route Public\n\nWebhook routes must be excluded from Clerk middleware protection. Without this, Clerk returns 401.\n\n```typescript\n// proxy.ts (Next.js <=15: middleware.ts)\nimport { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'\n\nconst isPublicRoute = createRouteMatcher(['/api/webhooks(.*)'])\n\nexport default clerkMiddleware(async (auth, req) => {\n  if (!isPublicRoute(req)) await auth.protect()\n})\n```\n\n## Complete Webhook Handler (Next.js App Router)\n\n```typescript\n// app/api/webhooks/route.ts\nimport { verifyWebhook } from '@clerk/nextjs/webhooks'\nimport { NextRequest } from 'next/server'\nimport { db } from '@/lib/db'\n\nexport async function POST(req: NextRequest) {\n  // ALWAYS verify - never skip, even for notification-only handlers\n  let evt\n  try {\n    evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SIGNING_SECRET automatically\n  } catch (err) {\n    console.error('Webhook verification failed:', err)\n    return new Response('Verification failed', { status: 400 })\n  }\n\n  if (evt.type === 'user.created') {\n    const { id, email_addresses, first_name, last_name } = evt.data\n    const email = email_addresses[0]?.email_address\n    const name = `${first_name ?? ''} ${last_name ?? ''}`.trim()\n    await db.users.create({ data: { clerkId: id, email, name } })\n  }\n\n  if (evt.type === 'user.updated') {\n    const { id, email_addresses, first_name, last_name } = evt.data\n    const email = email_addresses[0]?.email_address\n    await db.users.update({ where: { clerkId: id }, data: { email, first_name, last_name } })\n  }\n\n  if (evt.type === 'user.deleted') {\n    const { id } = evt.data\n    await db.users.delete({ where: { clerkId: id } })\n  }\n\n  if (evt.type === 'organizationMembership.created') {\n    const { organization, public_user_data, role } = evt.data\n    const orgId = organization.id\n    const userId = public_user_data.user_id\n    await db.teamMembers.create({ data: { orgId, userId, role } })\n  }\n\n  if (evt.type === 'organizationMembership.deleted') {\n    const { organization, public_user_data } = evt.data\n    const orgId = organization.id\n    const userId = public_user_data.user_id\n    await db.teamMembers.delete({ where: { orgId_userId: { orgId, userId } } })\n  }\n\n  return new Response('OK', { status: 200 })\n}\n```\n\n## Full Example: Welcome Email (Resend) + Slack Notification on user.created\n\nNotification-only handlers still verify the signature. Same pattern as the database-sync handler:\n\n```typescript\n// app/api/webhooks/route.ts\nimport { verifyWebhook } from '@clerk/nextjs/webhooks'\nimport { NextRequest } from 'next/server'\nimport { Resend } from 'resend'\n\nconst resend = new Resend(process.env.RESEND_API_KEY)\n\nexport async function POST(req: NextRequest) {\n  // Step 1: ALWAYS verify the webhook signature - NEVER skip this\n  let evt\n  try {\n    evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SIGNING_SECRET env var\n  } catch (err) {\n    console.error('Webhook verification failed:', err)\n    return new Response('Verification failed', { status: 400 })\n  }\n\n  // Step 2: Listen for user.created event\n  if (evt.type === 'user.created') {\n    // Step 3: Extract user email and name from webhook payload\n    const { id, email_addresses, first_name, last_name } = evt.data\n    const email = email_addresses[0]?.email_address\n    const name = `${first_name ?? ''} ${last_name ?? ''}`.trim()\n\n    // Step 4: Call Resend API to send welcome email\n    await resend.emails.send({\n      from: 'noreply@yourdomain.com',\n      to: email,\n      subject: 'Welcome!',\n      html: `<p>Hi ${name}, welcome to our app!</p>`,\n    })\n\n    // Step 5: Post notification to Slack channel\n    await fetch(process.env.SLACK_WEBHOOK_URL!, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        text: `New user signed up: ${name} (${email})`,\n      }),\n    })\n  }\n\n  // Always return 200 to acknowledge receipt\n  return new Response('OK', { status: 200 })\n}\n```\n\n**Also include proxy.ts (Next.js <=15: middleware.ts) to make the route public:**\n```typescript\n// proxy.ts (Next.js <=15: middleware.ts)\nimport { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'\nconst isPublicRoute = createRouteMatcher(['/api/webhooks(.*)'])\nexport default clerkMiddleware(async (auth, req) => {\n  if (!isPublicRoute(req)) await auth.protect()\n})\n```\n\n## Full Example: Organization Membership Sync to Database\n\n```typescript\n// app/api/webhooks/route.ts\nimport { verifyWebhook } from '@clerk/nextjs/webhooks'\nimport { NextRequest } from 'next/server'\nimport { db } from '@/lib/db' // your database client\n\nexport async function POST(req: NextRequest) {\n  // ALWAYS verify signature - never skip, even for simple handlers\n  let evt\n  try {\n    evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SIGNING_SECRET env var\n  } catch (err) {\n    console.error('Webhook verification failed:', err)\n    return new Response('Verification failed', { status: 400 })\n  }\n\n  if (evt.type === 'organization.created') {\n    const { id, name } = evt.data\n    await db.workspaces.create({\n      data: { orgId: id, name, createdAt: new Date() },\n    })\n  }\n\n  if (evt.type === 'organizationMembership.created') {\n    // Extract organization ID, user ID, and role from payload\n    const { organization, public_user_data, role } = evt.data\n    const orgId = organization.id\n    const userId = public_user_data.user_id\n\n    // Add to team_members table\n    await db.team_members.create({\n      data: { orgId, userId, role },\n    })\n\n    // Create workspace record for new member\n    await db.workspaces.create({\n      data: { orgId, userId, createdAt: new Date() },\n    })\n  }\n\n  if (evt.type === 'organizationMembership.deleted') {\n    // Extract organization ID and user ID from payload\n    const { organization, public_user_data } = evt.data\n    const orgId = organization.id\n    const userId = public_user_data.user_id\n\n    // Remove from team_members table\n    await db.team_members.delete({\n      where: { orgId, userId },\n    })\n\n    // Remove workspace record\n    await db.workspaces.deleteMany({\n      where: { orgId, userId },\n    })\n  }\n\n  // Return 200 status on success\n  return new Response('OK', { status: 200 })\n}\n```\n\n## Other Frameworks\n\nFor 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.).\n\nSee `references/frameworks.md` for full handler examples per framework.\n\n## Type Narrowing for `evt.data`\n\n`verifyWebhook` returns `WebhookEvent`, a discriminated union of all event types. Narrow with `evt.type` to get type-safe access to `evt.data`:\n\n```typescript\nconst evt = await verifyWebhook(req)\n\nif (evt.type === 'user.created') {\n  // evt.data is now UserJSON, autocompletes id, email_addresses, etc.\n  console.log(evt.data.id)\n}\n```\n\nFor manual typing of nested payloads, import the JSON types from your framework's webhook subpath: `DeletedObjectJSON`, `EmailJSON`, `OrganizationInvitationJSON`, `OrganizationJSON`, `OrganizationMembershipJSON`, `SessionJSON`, `SMSMessageJSON`, `UserJSON`.\n\n## Payload Field Reference\n\n### User events (`user.created`, `user.updated`, `user.deleted`)\n```typescript\nconst {\n  id,                  // Clerk user ID\n  email_addresses,     // array; [0].email_address is primary email\n  first_name,\n  last_name,\n  image_url,\n  public_metadata,\n} = evt.data\n```\n\n### Organization events (`organization.created`, `organization.updated`, `organization.deleted`)\n```typescript\nconst {\n  id,    // org ID\n  name,  // org name\n  slug,\n} = evt.data\n```\n\n### Organization Membership events (`organizationMembership.created`, `organizationMembership.updated`, `organizationMembership.deleted`)\n```typescript\nconst {\n  organization,        // { id, name, ... }\n  public_user_data,    // { user_id, first_name, last_name, ... }\n  role,                // e.g. 'org:admin', 'org:member'\n} = evt.data\n// Access: organization.id, public_user_data.user_id, role\n```\n\n## Supported Events (Full Catalog)\n\n**User**: `user.created` `user.updated` `user.deleted`\n\n**Session**: `session.created` `session.ended` `session.removed` `session.revoked`\n\n**Organization**: `organization.created` `organization.updated` `organization.deleted`\n\n**Organization Membership**: `organizationMembership.created` `organizationMembership.updated` `organizationMembership.deleted`\n\n**Organization Domain**: `organizationDomain.created` `organizationDomain.updated` `organizationDomain.deleted`\n\n**Organization Invitation**: `organizationInvitation.accepted` `organizationInvitation.created` `organizationInvitation.revoked`\n\n**Communication**: `email.created` `sms.created`\n\n**Waitlist**: `waitlistEntry.created` `waitlistEntry.updated`\n\n**Permission**: `permission.created` `permission.updated` `permission.deleted`\n\n**Role**: `role.created` `role.updated` `role.deleted`\n\n**Subscription**: `subscription.created` `subscription.updated` `subscription.active` `subscription.pastDue`\n\n**Subscription Item**: `subscriptionItem.created` `subscriptionItem.active` `subscriptionItem.updated` `subscriptionItem.canceled` `subscriptionItem.upcoming` `subscriptionItem.ended` `subscriptionItem.abandoned` `subscriptionItem.incomplete` `subscriptionItem.pastDue` `subscriptionItem.freeTrialEnding`\n\n**Payment**: `paymentAttempt.created` `paymentAttempt.updated`\n\n## Webhook Reliability\n\n**Retries**: Svix retries failed webhooks on a set schedule (see [Svix Retry Schedule](https://docs.svix.com/retries)). Return 2xx to succeed, 4xx/5xx to retry. Use the `svix-id` header as an idempotency key to deduplicate retried events.\n\n**Replay**: Failed webhooks can be replayed from Dashboard.\n\n## Common Pitfalls\n\n| Symptom | Cause | Fix |\n|---------|-------|-----|\n| Verification fails (Next.js) | Wrong import or usage | Use `@clerk/nextjs/webhooks`, pass `req` directly |\n| Verification fails (Express) | Using `express.json()` | Use `express.raw({ type: 'application/json' })` for webhook route |\n| Route not found (404) | Wrong path | Use `/api/webhooks` or preserve existing path |\n| Not authorized (401) | Route is protected by middleware | Make route public in `clerkMiddleware()` |\n| No data in DB | Async job pending | Wait/check logs |\n| Duplicate entries | Only handling `user.created` | Also handle `user.updated` |\n| Timeouts | Handler too slow | Queue async work, return 200 first |\n\n## Testing & Deployment\n\n**Local**: Use the Clerk CLI's first-party tunnel — no auth or linked project needed:\n\n```sh\nclerk webhooks listen --token \"$(clerk webhooks token)\" --forward-to http://localhost:3000/api/webhooks\n```\n\nAdd the printed relay URL (`https://webhooks.clerk.com/in/c_.../`) as a webhook endpoint in the Dashboard — events don't flow until you do. `svix-*` headers are preserved, so `verifyWebhook()` works against that endpoint's signing secret as usual. Flags, offline signature checks (`clerk webhooks verify`), and agent-mode behavior are in the `clerk-cli` skill. Without the CLI, tunnel `localhost:3000` yourself (`ngrok`, `localtunnel`, `Cloudflare Tunnel`) and add the public URL to the Dashboard endpoint.\n\n**Production**: Update webhook endpoint URL to production domain. Copy `CLERK_WEBHOOK_SIGNING_SECRET` to production env vars.\n\n## References\n\n| Reference | Description |\n|-----------|-------------|\n| `references/frameworks.md` | Webhook handler examples for Express, Astro, Fastify, Nuxt, React Router, TanStack Start |\n\n## See Also\n\n- `clerk-cli` - `clerk webhooks listen`/`verify` for local webhook testing\n- `clerk-setup` - Initial Clerk install\n- `clerk-orgs` - Org membership events\n- `clerk-billing` - Subscription, subscription item, and payment attempt events\n- `clerk-backend-api` - Sync via direct API calls"
}

SHA-256: 4f34735dda35837ef49bedf7485abaf1959ae4d7c60ff61ad963387a3bb6043c