clerk-webhooks について
clerk-webhooksは、ClerkのWebhookハンドラーを実装する開発者向けのスキルです。複数のフレームワークでの署名検証とイベント処理に加え、非同期配信、再試行、冪等性を説明します。
主な機能
- 01verifyWebhook(req)による署名検証
- 02ユーザー・組織・サブスクリプションイベントの処理
- 03イベント種別に応じたデータの型絞り込み
- 04複数フレームワークのハンドラー例
- 05再試行と重複イベントへの対応
- 06ローカル転送テストのサポート
活用シーン
- 01Clerkのユーザーイベントをデータベースに同期
- 02ユーザー登録後の通知送信
- 03組織メンバー変更への対応
- 04ローカルでのWebhook配信テスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ clerk/ skills --skill clerk-webhooksインストール後、Claude Code で/clerk-webhooksを入力して読み込まれたか確認します。
同梱ファイルevals/evals.jsonreferences/frameworks.md
詳細
Webhook
すべてのハンドラーで verifyWebhook(req) による検証を行う、完全に動作するWebhookハンドラーを出力します。
Webhookを使用する場面
Webhookは非同期で、結果整合性があります。配信は速いものの、即時配信が保証されているわけではなく、失敗することもあります(Svixは固定スケジュールで再試行します)。次の用途に使用してください。
- データベースの同期(Clerkに追従する別のユーザー/組織テーブル)
- 通知(ウェルカムメール、Slackへの通知、内部アラート)
- ライフサイクルイベントをトリガーとする連携
オンボーディングのような同期フロー(「ユーザーがサインアップした後、DBからXを読み取る」など)の一部として、Webhookの配信を前提にしないでください。ユーザーが作成したばかりのデータについては、Clerkセッショントークンから読み取るか、Backend APIを直接呼び出してください。セッショントークンに含まれない、他のユーザーやイベントのデータが必要な場合に、Webhookがその不足を補います。
すべてのWebhookを検証する
フレームワーク固有のパッケージ(@clerk/nextjs/webhooks、@clerk/express/webhooks など)の verifyWebhook(req) を使用してください。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 })
}
完全な例:user.createdでのウェルカムメール(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 ヘッダーを冪等性キーとして使用してください。
再送:失敗した webhook は Dashboard から再送できます。
よくある問題
| 症状 | 原因 | 対処方法 |
|---|---|---|
| 検証に失敗する(Next.js) | インポートまたは使い方が誤っている | @clerk/nextjs/webhooks を使用し、req を直接渡します |
| 検証に失敗する(Express) | express.json() を使用している | webhook のルートには express.raw({ type: 'application/json' }) を使用します |
| ルートが見つからない(404) | パスが誤っている | /api/webhooks を使用するか、既存のパスを維持します |
| 配信のたびに 401 または 404 が返る | auth.protect() が(ミドルウェアまたはハンドラー内で)webhook のルートを対象にしている | そのチェックを削除します。ルートに必要なチェックは verifyWebhook() のみです |
| DB にデータがない | 非同期ジョブが保留中 | 待機してログを確認します |
| エントリーが重複する | 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)、エージェントモードの動作については、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 の直接呼び出しによる同期