关于 clerk-webhooks
clerk-webhooks 是面向 Clerk 应用开发者的 Webhook 处理技能,提供验证签名、处理用户与组织事件的指导。内容涵盖多种框架,并说明重试、幂等和异步处理注意事项。
主要功能
- 01使用 verifyWebhook(req) 验证签名
- 02处理用户、组织及订阅事件
- 03按事件类型安全读取数据
- 04提供多框架处理器示例
- 05说明重试与重复事件处理
- 06支持本地转发测试
适用场景
- 01将 Clerk 用户事件同步到数据库
- 02在用户注册后发送通知
- 03响应组织成员变更
- 04在本地测试 Webhook 交付
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ clerk/ skills --skill clerk-webhooks安装后在 Claude Code 中输入/clerk-webhooks确认已加载。
附带文件evals/evals.jsonreferences/frameworks.md
详细介绍
Webhooks
输出完整且可运行的 webhook 处理程序,并在每个处理程序中使用 verifyWebhook(req) 验证。
何时使用 Webhooks
Webhooks 是异步且最终一致的。事件会快速送达,但不保证立即送达,并且偶尔可能会失败(Svix 会按固定计划重试)。可用于:
- 数据库同步(单独的 users / orgs 表,随 Clerk 保持同步)
- 通知(欢迎邮件、Slack 提醒、内部警报)
- 由生命周期事件触发的集成
不要在同步流程中依赖 webhook 送达,例如入门流程(“用户注册后,我们再从数据库读取 X”)。对于用户刚刚创建的数据,请从 Clerk 会话令牌中读取,或直接调用 Backend API。当你需要会话令牌中未包含的其他用户或事件的数据时,webhook 可以填补这一空缺。
验证每个 Webhook
使用框架专用包中的 verifyWebhook(req)(例如 @clerk/nextjs/webhooks、@clerk/express/webhooks 等)。它会自动读取 CLERK_WEBHOOK_SIGNING_SECRET,并在签名无效时抛出错误。即使处理程序仅用于发送通知,跳过验证也会使端点暴露于伪造事件。
保持 Webhook 路由不受保护
Webhook 投递不携带用户会话。verifyWebhook() 是路由唯一需要的检查。单独使用 clerkMiddleware() 不会保护任何内容,因此该路由本来就可以访问。不要在中间件或处理程序中对 /api/webhooks(.*) 调用 auth.protect()。如果项目中这样做,在移除该调用之前,每次投递都会返回 404。请参阅确保 webhook 路由是公开的。
完整的 Webhook 处理程序(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 })
}
完整示例:用户创建时发送欢迎邮件(Resend)+ Slack 通知
仅用于发送通知的处理程序仍需验证签名。使用与数据库同步处理程序相同的模式:
// 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: '[email protected]',
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 })
}
完整示例:将组织成员资格同步到数据库
// 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 })
}
其他框架
对于 Express、Astro、Fastify、Nuxt、React Router 和 TanStack Start,请使用框架专用的 verifyWebhook 适配器。每个 Clerk SDK 包都提供自己的适配器(@clerk/express/webhooks、@clerk/astro/webhooks、@clerk/fastify/webhooks 等)。
请参阅 references/frameworks.md,查看各框架的完整处理程序示例。
evt.data 的类型收窄
verifyWebhook 返回 WebhookEvent,这是一个涵盖所有事件类型的判别联合类型。使用 evt.type 进行收窄,即可对 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)
}
如需手动为嵌套负载指定类型,请从框架的 webhook 子路径导入 JSON 类型:DeletedObjectJSON、EmailJSON、OrganizationInvitationJSON、OrganizationJSON、OrganizationMembershipJSON、SessionJSON、SMSMessageJSON、UserJSON。
负载字段参考
用户事件(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.created、organization.updated、organization.deleted)
const {
id, // org ID
name, // org name
slug,
} = evt.data
组织成员资格事件(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
支持的事件(完整目录)
用户:user.created user.updated user.deleted
会话:session.created session.ended session.removed session.revoked
组织:organization.created organization.updated organization.deleted
组织成员资格:organizationMembership.created organizationMembership.updated organizationMembership.deleted
组织域名:organizationDomain.created organizationDomain.updated organizationDomain.deleted
组织邀请:organizationInvitation.accepted organizationInvitation.created organizationInvitation.revoked
通信:email.created sms.created
候补名单:waitlistEntry.created waitlistEntry.updated
权限:permission.created permission.updated permission.deleted
角色:role.created role.updated role.deleted
订阅:subscription.created subscription.updated subscription.active subscription.pastDue
订阅项:subscriptionItem.created subscriptionItem.active subscriptionItem.updated subscriptionItem.canceled subscriptionItem.upcoming subscriptionItem.ended subscriptionItem.abandoned subscriptionItem.incomplete subscriptionItem.pastDue subscriptionItem.freeTrialEnding
付款:paymentAttempt.created paymentAttempt.updated
Webhook 可靠性
重试:Svix 会按照设定的计划重试失败的 webhook(参见 Svix 重试计划)。返回 2xx 表示成功,返回 4xx/5xx 则会重试。使用 svix-id 标头作为幂等键,以去重重试的事件。
重放:可以从 Dashboard 重放失败的 webhook。
常见问题
| 症状 | 原因 | 解决方法 |
|---|---|---|
| 验证失败(Next.js) | 导入或用法错误 | 使用 @clerk/nextjs/webhooks,直接传入 req |
| 验证失败(Express) | 使用了 express.json() | 在 webhook 路由中使用 express.raw({ type: 'application/json' }) |
| 找不到路由(404) | 路径错误 | 使用 /api/webhooks 或保留现有路径 |
| 每次投递都返回 401 或 404 | auth.protect() 覆盖了 webhook 路由(在中间件或处理程序中) | 移除此检查;路由只需使用 `verifyWebhook()`` |
| 数据库中没有数据 | 异步任务仍在等待处理 | 等待/检查日志 |
| 条目重复 | 只处理了 user.created | 也处理 user.updated |
| 超时 | 处理程序太慢 | 将工作放入异步队列,先返回 200 |
测试与部署
本地:使用 Clerk CLI 的官方隧道,无需身份验证或关联项目:
clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks
将输出的中继 URL(https://webhooks.clerk.com/in/c_.../)添加为 Dashboard 中的 webhook 端点——完成此操作后,事件才会开始流动。svix-* 标头会保留,因此 verifyWebhook() 可以像往常一样使用该端点的签名密钥进行验证。相关标志、离线签名检查(clerk webhooks verify)和 Agent 模式行为,请参阅 clerk-cli 技能。如不使用 CLI,请自行隧道转发 localhost:3000(使用 ngrok、localtunnel、Cloudflare Tunnel),并将公开 URL 添加到 Dashboard 端点。
生产环境:将 webhook 端点 URL 更新为生产域名。将 CLERK_WEBHOOK_SIGNING_SECRET 复制到生产环境变量中。
参考资料
| 参考资料 | 说明 |
|---|---|
references/frameworks.md | Express、Astro、Fastify、Nuxt、React Router、TanStack Start 的 webhook 处理程序示例 |
另请参阅
clerk-cli- 用于本地 webhook 测试的clerk webhooks listen/verifyclerk-setup- Clerk 初始安装clerk-orgs- 组织成员资格事件clerk-billing- 订阅、订阅项和付款尝试事件clerk-backend-api- 通过直接 API 调用进行同步