clerk-webhooks

Webhooks Clerk pour les événements en temps réel et la synchronisation des données. Toujours produire des sorties complètes,

npx skills add https://github.com/clerk/skills --skill clerk-webhooks

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 NON express.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 Clerk
  • clerk-orgs - Événements d'appartenance à l'organisation
  • clerk-backend-api - Synchronisation via appels directs à l'API

Skills similaires