clerk-webhooks

Par clerk · skills

Webhooks Clerk pour les événements en temps réel et la synchronisation des données. Vérification avec `verifyWebhook`

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

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/verify pour les tests de webhooks locaux
  • clerk-setup - Installation initiale de Clerk
  • clerk-orgs - Événements d'adhésion à l'organisation
  • clerk-billing - Événements de subscription, subscription item et payment attempt
  • clerk-backend-api - Synchronisation via appels directs à l'API

Skills similaires