Webhooks
Sortez des handlers webhook complets et fonctionnels avec vérification verifyWebhook(req) dans chaque handler.
Quand utiliser les Webhooks
Les webhooks sont asynchrones et finalement cohérents. La livraison est rapide mais non garantie immédiate, et peut occasionnellement échouer (Svix réessaie selon un calendrier fixe). Utilisez-les pour :
- Synchronisation de base de données (une table users / orgs séparée qui suit Clerk)
- Notifications (e-mails de bienvenue, notifications Slack, alertes internes)
- Intégrations déclenchées par des événements de cycle de vie
Ne comptez PAS sur la livraison webhook dans un flux synchrone tel que l'onboarding (« l'utilisateur s'inscrit, puis nous lisons X depuis notre BD »). Pour les données que l'utilisateur vient de créer, lisez-les depuis le token de session Clerk ou appelez directement l'API Backend. Les webhooks comblent le fossé quand vous avez besoin de données sur d'autres utilisateurs ou événements que le token de session ne contient pas.
Vérifier chaque Webhook
Utilisez verifyWebhook(req) du package spécifique au framework (@clerk/nextjs/webhooks, @clerk/express/webhooks, etc.). Il lit CLERK_WEBHOOK_SIGNING_SECRET automatiquement et lève une erreur sur les mauvaises signatures. Ignorer la vérification, même pour les handlers notification-only, expose l'endpoint à des événements contrefaits.
Rendre la route Webhook publique
Les routes webhook doivent être exclues de la protection middleware Clerk. Sans cela, Clerk retourne 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()
})
Handler Webhook complet (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) {
// TOUJOURS vérifier - ne jamais ignorer, même pour les handlers notification-only
let evt
try {
evt = await verifyWebhook(req) // utilise CLERK_WEBHOOK_SIGNING_SECRET automatiquement
} 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 })
}
Exemple complet : E-mail de bienvenue (Resend) + Notification Slack sur user.created
Les handlers notification-only vérifient toujours la signature. Même motif que le handler database-sync :
// 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) {
// Étape 1 : TOUJOURS vérifier la signature webhook - NE JAMAIS ignorer
let evt
try {
evt = await verifyWebhook(req) // utilise la variable d'env CLERK_WEBHOOK_SIGNING_SECRET
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Verification failed', { status: 400 })
}
// Étape 2 : Écouter l'événement user.created
if (evt.type === 'user.created') {
// Étape 3 : Extraire l'e-mail et le nom de l'utilisateur du payload webhook
const { id, email_addresses, first_name, last_name } = evt.data
const email = email_addresses[0]?.email_address
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim()
// Étape 4 : Appeler l'API Resend pour envoyer l'e-mail de bienvenue
await resend.emails.send({
from: 'noreply@yourdomain.com',
to: email,
subject: 'Welcome!',
html: `<p>Hi ${name}, welcome to our app!</p>`,
})
// Étape 5 : Poster une notification au canal Slack
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New user signed up: ${name} (${email})`,
}),
})
}
// Toujours retourner 200 pour confirmer la réception
return new Response('OK', { status: 200 })
}
Incluez aussi proxy.ts (Next.js <=15: middleware.ts) pour rendre la route publique :
// 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()
})
Exemple complet : Synchronisation des adhésions à l'organisation dans la base de données
// app/api/webhooks/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { db } from '@/lib/db' // votre client de base de données
export async function POST(req: NextRequest) {
// TOUJOURS vérifier la signature - ne jamais ignorer, même pour les handlers simples
let evt
try {
evt = await verifyWebhook(req) // utilise la variable d'env CLERK_WEBHOOK_SIGNING_SECRET
} 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') {
// Extraire l'ID de l'organisation, l'ID de l'utilisateur et le rôle du payload
const { organization, public_user_data, role } = evt.data
const orgId = organization.id
const userId = public_user_data.user_id
// Ajouter à la table team_members
await db.team_members.create({
data: { orgId, userId, role },
})
// Créer un enregistrement workspace pour le nouveau membre
await db.workspaces.create({
data: { orgId, userId, createdAt: new Date() },
})
}
if (evt.type === 'organizationMembership.deleted') {
// Extraire l'ID de l'organisation et l'ID de l'utilisateur du payload
const { organization, public_user_data } = evt.data
const orgId = organization.id
const userId = public_user_data.user_id
// Supprimer de la table team_members
await db.team_members.delete({
where: { orgId, userId },
})
// Supprimer l'enregistrement workspace
await db.workspaces.deleteMany({
where: { orgId, userId },
})
}
// Retourner le statut 200 en cas de succès
return new Response('OK', { status: 200 })
}
Autres frameworks
Pour Express, Astro, Fastify, Nuxt, React Router et TanStack Start, utilisez l'adaptateur verifyWebhook spécifique au framework. Chaque package SDK Clerk en est livré avec un (@clerk/express/webhooks, @clerk/astro/webhooks, @clerk/fastify/webhooks, etc.).
Consultez references/frameworks.md pour les exemples de handler complets par framework.
Type Narrowing pour evt.data
verifyWebhook retourne WebhookEvent, une union discriminée de tous les types d'événements. Affinez avec evt.type pour obtenir un accès type-safe à evt.data :
const evt = await verifyWebhook(req)
if (evt.type === 'user.created') {
// evt.data est maintenant UserJSON, avec autocomplétion pour id, email_addresses, etc.
console.log(evt.data.id)
}
Pour le typage manuel des payloads imbriqués, importez les types JSON depuis le sous-chemin webhook de votre framework : DeletedObjectJSON, EmailJSON, OrganizationInvitationJSON, OrganizationJSON, OrganizationMembershipJSON, SessionJSON, SMSMessageJSON, UserJSON.
Référence des champs Payload
Événements utilisateur (user.created, user.updated, user.deleted)
const {
id, // Clerk user ID
email_addresses, // array; [0].email_address est l'e-mail principal
first_name,
last_name,
image_url,
public_metadata,
} = evt.data
Événements d'organisation (organization.created, organization.updated, organization.deleted)
const {
id, // org ID
name, // org name
slug,
} = evt.data
Événements d'adhésion à l'organisation (organizationMembership.created, organizationMembership.updated, organizationMembership.deleted)
const {
organization, // { id, name, ... }
public_user_data, // { user_id, first_name, last_name, ... }
role, // p. ex. 'org:admin', 'org:member'
} = evt.data
// Accès : organization.id, public_user_data.user_id, role
Événements supportés (Catalogue complet)
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
Fiabilité des webhooks
Retries : Svix réessaie les webhooks échoués selon un calendrier défini (voir Svix Retry Schedule). Retournez 2xx pour succès, 4xx/5xx pour réessai. Utilisez l'en-tête svix-id comme clé d'idempotence pour dédupliquer les événements réessayés.
Replay : Les webhooks échoués peuvent être rejouées depuis le Dashboard.
Pièges courants
| Symptôme | Cause | Solution |
|---|---|---|
| Vérification échoue (Next.js) | Mauvais import ou utilisation | Utilisez @clerk/nextjs/webhooks, passez req directement |
| Vérification échoue (Express) | Utilisation de express.json() |
Utilisez express.raw({ type: 'application/json' }) pour la route webhook |
| Route non trouvée (404) | Mauvais chemin | Utilisez /api/webhooks ou préservez le chemin existant |
| Non autorisé (401) | Route protégée par middleware | Rendez la route publique dans clerkMiddleware() |
| Aucune donnée dans la BD | Tâche asynchrone en attente | Attendez / vérifiez les logs |
| Entrées en doublons | Gestion de user.created uniquement |
Gérez aussi user.updated |
| Timeouts | Handler trop lent | Mettez le travail asynchrone en queue, retournez 200 d'abord |
Test et déploiement
Local : Utilisez le tunnel first-party du CLI Clerk — aucune authentification ou projet lié nécessaire :
clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks
Ajoutez l'URL de relais imprimée (https://webhooks.clerk.com/in/c_.../) comme endpoint webhook dans le Dashboard — les événements ne circulent pas tant que vous ne le faites pas. Les en-têtes svix-* sont préservés, donc verifyWebhook() fonctionne contre le secret de signature de cet endpoint comme d'habitude. Les flags, les vérifications de signature hors ligne (clerk webhooks verify), et le comportement mode-agent sont dans la skill clerk-cli. Sans le CLI, tunnelisez localhost:3000 vous-même (ngrok, localtunnel, Cloudflare Tunnel) et ajoutez l'URL publique à l'endpoint du Dashboard.
Production : Mettez à jour l'URL de l'endpoint webhook vers le domaine de production. Copiez CLERK_WEBHOOK_SIGNING_SECRET dans les variables d'env de production.
Références
| Référence | Description |
|---|---|
references/frameworks.md |
Exemples de handler webhook pour Express, Astro, Fastify, Nuxt, React Router, TanStack Start |
Voir aussi
clerk-cli-clerk webhooks listen/verifypour les tests de webhooks locauxclerk-setup- Installation initiale de Clerkclerk-orgs- Événements d'adhésion à l'organisationclerk-billing- Événements de subscription, subscription item et payment attemptclerk-backend-api- Synchronisation via appels directs à l'API