Webhooks
Toujours générer des handlers webhook complets, fonctionnels et prêts à copier-coller. Ne jamais générer de stubs, placeholders ou implémentations partielles. Inclure verifyWebhook(req) dans chaque handler.
CRITIQUE : Toujours vérifier les webhooks
NE JAMAIS sauter la vérification de signature, même pour les handlers de notification uniquement. Toujours utiliser verifyWebhook(req) de @clerk/nextjs/webhooks. Cela utilise automatiquement la variable d'env CLERK_WEBHOOK_SECRET.
CRITIQUE : Rendre la route webhook publique
Les routes webhook DOIVENT être exclues de la protection du 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((auth, req) => {
if (!isPublicRoute(req)) 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 sauter, même pour les handlers de notification uniquement
let evt
try {
evt = await verifyWebhook(req) // utilise CLERK_WEBHOOK_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 : Email de bienvenue (Resend) + notification Slack sur user.created
TOUJOURS utiliser ce pattern COMPLET — ne jamais le transformer en stub :
// 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 du webhook - NE JAMAIS sauter cette étape
let evt
try {
evt = await verifyWebhook(req) // utilise la variable d'env CLERK_WEBHOOK_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'email et le nom de l'utilisateur du payload du 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 un email 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 dans le 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 })
}
Inclure 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((auth, req) => {
if (!isPublicRoute(req)) auth().protect()
})
Exemple complet : Synchronisation des appartenances à 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 sauter, même pour les handlers simples
let evt
try {
evt = await verifyWebhook(req) // utilise la variable d'env CLERK_WEBHOOK_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 })
}
Handler webhook Express.js
CRITIQUE : Utiliser
express.raw()et NONexpress.json()pour les routes webhook. La vérification de signature nécessite les octets bruts du corps.express.json()analyse le corps et casse la vérification.
import express from 'express'
import { Webhook } from 'svix'
const app = express()
// MAUVAIS - casse la vérification parce qu'il analyse le corps :
// app.use(express.json())
// CORRECT - utiliser le corps brut pour la route webhook uniquement :
app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!
const wh = new Webhook(webhookSecret)
let evt: any
try {
// Svix vérifie en utilisant les octets bruts du corps + les en-têtes svix
evt = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
} catch (err) {
console.error('Webhook verification failed:', err)
return res.status(400).json({ error: 'Verification failed' })
}
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()
console.log(`New user: ${name} (${email})`)
}
if (evt.type === 'user.updated') {
const { id, email_addresses } = evt.data
const email = email_addresses[0]?.email_address
console.log(`User updated: ${id}, email: ${email}`)
}
if (evt.type === 'user.deleted') {
const { id } = evt.data
console.log(`User deleted: ${id}`)
}
// Retourner le statut 200 en cas de succès
return res.status(200).json({ received: true })
})
Référence des champs de payload
Événements utilisateur (user.created, user.updated, user.deleted)
const {
id, // Clerk user ID
email_addresses, // array; [0].email_address est l'email principal
first_name,
last_name,
image_url,
public_metadata,
} = evt.data
Événements d'organisation (organization.created, organization.updated, organization.deleted)
const {
id, // ID de l'organisation
name, // nom de l'organisation
slug,
} = evt.data
Événements d'appartenance à l'organisation (organizationMembership.created, organizationMembership.updated, organizationMembership.deleted)
const {
organization, // { id, name, ... }
public_user_data, // { user_id, first_name, last_name, ... }
role, // ex. 'org:admin', 'org:member'
} = evt.data
// Accès : organization.id, public_user_data.user_id, role
Événements supportés (catalogue complet)
Utilisateur : user.created user.updated user.deleted
Session : session.created session.ended session.pending session.removed session.revoked
Organisation : organization.created organization.updated organization.deleted
Appartenance à l'organisation : organizationMembership.created organizationMembership.updated organizationMembership.deleted
Domaine de l'organisation : organizationDomain.created organizationDomain.updated organizationDomain.deleted
Invitation à l'organisation : organizationInvitation.accepted organizationInvitation.created organizationInvitation.revoked
Communication : email.created sms.created
Invitation : invitation.accepted invitation.created invitation.revoked
Liste d'attente : waitlistEntry.created waitlistEntry.updated
Permission : permission.created permission.updated permission.deleted
Rôle : role.created role.updated role.deleted
Abonnement : subscription.created subscription.updated subscription.active subscription.pastDue
Élément d'abonnement : subscriptionItem.created subscriptionItem.active subscriptionItem.updated subscriptionItem.canceled subscriptionItem.upcoming subscriptionItem.ended subscriptionItem.abandoned subscriptionItem.incomplete subscriptionItem.pastDue subscriptionItem.freeTrialEnding
Paiement : paymentAttempt.created paymentAttempt.updated
Fiabilité du webhook
Tentatives : Svix réessaie les webhooks échoués selon un calendrier défini (voir Svix Retry Schedule). Retourner 2xx pour réussir, 4xx/5xx pour réessayer. Utiliser l'en-tête svix-id comme clé d'idempotence pour dédupliquer les événements qui ont été retentés.
Rejouer : Les webhooks échoués peuvent être rejouées depuis le Dashboard.
Pièges courants
| Symptôme | Cause | Solution |
|---|---|---|
| La vérification échoue (Next.js) | Mauvais import ou utilisation | Utiliser @clerk/nextjs/webhooks, passer req directement |
| La vérification échoue (Express) | Utiliser express.json() |
Utiliser express.raw({ type: 'application/json' }) pour la route webhook |
| Route non trouvée (404) | Mauvais chemin | Utiliser /api/webhooks ou préserver le chemin existant |
| Non autorisé (401) | La route est protégée par le middleware | Rendre la route publique dans clerkMiddleware() |
| Pas de données en BD | Job asynchrone en attente | Attendre/vérifier les logs |
| Entrées en double | Gérer uniquement user.created |
Gérer aussi user.updated |
| Timeouts | Handler trop lent | Mettre en file d'attente le travail asynchrone, retourner 200 d'abord |
Test et déploiement
Local : Utiliser ngrok pour tunneliser localhost:3000 vers Internet. Ajouter l'URL ngrok au endpoint du Dashboard.
Production : Mettre à jour l'URL du endpoint webhook vers le domaine de production. Copier CLERK_WEBHOOK_SECRET dans les variables d'env de production.
Voir aussi
clerk-setup- Installation initiale de Clerkclerk-orgs- Événements d'appartenance à l'organisationclerk-backend-api- Synchronisation via appels directs à l'API