or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

backend-api-client.mdclient-components.mdclient-hooks.mderror-handling.mdindex.mdmiddleware-and-route-protection.mdserver-auth-app-router.mdserver-auth-pages-router.mdsetup-and-provider.mdwebhooks.md
tile.json

webhooks.mddocs/

Webhooks

Verify and handle webhook events from Clerk to sync data with your backend. Webhooks allow you to receive real-time notifications when events occur in your Clerk application.

Key Information for Agents

Required Setup:

  • CLERK_WEBHOOK_SIGNING_SECRET environment variable (or pass via signingSecret option)
  • Webhook endpoint configured in Clerk Dashboard
  • Route handler to receive POST requests (App Router or Pages Router)
  • Body parsing must be disabled for Pages Router (App Router handles automatically)

Default Behaviors:

  • verifyWebhook() verifies request signature using Svix
  • Verification uses CLERK_WEBHOOK_SIGNING_SECRET from environment if not provided
  • verifyWebhook() throws error if verification fails (must catch and return 400)
  • Webhook events are typed (TypeScript support for event types)
  • All webhook events have type and data properties
  • Event types are string literals (e.g., 'user.created', 'user.updated')

Threading Model:

  • verifyWebhook() executes in server runtime (Node.js)
  • Verification is synchronous operation (checks signature)
  • Event handling can be async (await database operations)
  • Multiple webhook events can be processed concurrently (different requests)

Lifecycle:

  • Webhook endpoint receives POST request from Clerk
  • verifyWebhook() verifies request signature
  • Event type determines which handler to call
  • Event data is processed (sync to database, etc.)
  • Response status code indicates success/failure
  • Clerk retries on 500 errors, doesn't retry on 400 errors

Edge Cases:

  • Verification failure: Return 400 status (Clerk won't retry)
  • Processing failure: Return 500 status (Clerk will retry)
  • Idempotency: Handle duplicate events (use event ID or upsert operations)
  • Missing signing secret: Causes verification to fail
  • Invalid request format: Causes verification to fail
  • Event ordering: Events may arrive out of order (handle accordingly)
  • Rate limiting: Too many webhook requests can be rate limited

Exceptions:

  • verifyWebhook() throws error if signature verification fails
  • Missing CLERK_WEBHOOK_SIGNING_SECRET causes verification to fail
  • Invalid request format causes verification to throw
  • Network errors during event processing should return 500 (for retry)

Capabilities

verifyWebhook() Function

Verifies the authenticity of webhook requests from Clerk using Svix signatures.

/**
 * Verifies webhook authenticity using Svix signatures
 * Supports NextRequest, NextApiRequest, and Web API Request objects
 * @param request - Incoming webhook request
 * @param options - Optional verification configuration
 * @returns Promise resolving to verified WebhookEvent
 * @throws Error if verification fails
 */
function verifyWebhook(
  request: RequestLike,
  options?: VerifyWebhookOptions
): Promise<WebhookEvent>;

type RequestLike = NextRequest | NextApiRequest | Request;

interface VerifyWebhookOptions {
  /**
   * Custom signing secret
   * If not provided, uses CLERK_WEBHOOK_SIGNING_SECRET or WEBHOOK_SECRET env variable
   */
  signingSecret?: string;
}

Usage Example - App Router:

import { verifyWebhook } from '@clerk/nextjs/webhooks';
import { headers } from 'next/headers';

export async function POST(req: Request) {
  try {
    const evt = await verifyWebhook(req);

    // Access event data
    const { id, type, data } = evt;

    // Handle specific event types
    if (type === 'user.created') {
      console.log('New user created:', data.id);
      // Handle user creation
      await createUserInDatabase(data);
    }

    if (type === 'user.updated') {
      console.log('User updated:', data.id);
      // Handle user update
      await updateUserInDatabase(data);
    }

    return new Response('Webhook processed', { status: 200 });
  } catch (err) {
    console.error('Webhook verification failed:', err);
    return new Response('Webhook verification failed', { status: 400 });
  }
}

Usage Example - Pages Router:

import { verifyWebhook } from '@clerk/nextjs/webhooks';
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const evt = await verifyWebhook(req);

    // Handle event
    switch (evt.type) {
      case 'user.created':
        await handleUserCreated(evt.data);
        break;
      case 'user.updated':
        await handleUserUpdated(evt.data);
        break;
      case 'user.deleted':
        await handleUserDeleted(evt.data);
        break;
      default:
        console.log('Unhandled event type:', evt.type);
    }

    return res.status(200).json({ received: true });
  } catch (err) {
    console.error('Webhook error:', err);
    return res.status(400).json({ error: 'Webhook verification failed' });
  }
}

Usage Example - With Custom Secret:

import { verifyWebhook } from '@clerk/nextjs/webhooks';

export async function POST(req: Request) {
  const signingSecret = process.env.CLERK_WEBHOOK_SIGNING_SECRET;

  if (!signingSecret) {
    return new Response('Missing signing secret', { status: 500 });
  }

  try {
    const evt = await verifyWebhook(req, {
      signingSecret,
    });

    // Process webhook
    console.log('Webhook event:', evt.type, evt.data);

    return new Response('Success', { status: 200 });
  } catch (err) {
    return new Response('Verification failed', { status: 400 });
  }
}

Webhook Event Types

All webhook events follow a consistent structure with type-specific data.

/**
 * Union of all possible webhook event types
 */
type WebhookEvent =
  | UserWebhookEvent
  | SessionWebhookEvent
  | OrganizationWebhookEvent
  | OrganizationMembershipWebhookEvent
  | OrganizationInvitationWebhookEvent
  | OrganizationDomainWebhookEvent
  | EmailWebhookEvent
  | SMSWebhookEvent
  | PermissionWebhookEvent
  | RoleWebhookEvent
  | WaitlistEntryWebhookEvent;

/**
 * Webhook event type string literals
 */
type WebhookEventType =
  | 'user.created'
  | 'user.updated'
  | 'user.deleted'
  | 'session.created'
  | 'session.ended'
  | 'session.removed'
  | 'session.revoked'
  | 'email.created'
  | 'sms.created'
  | 'organization.created'
  | 'organization.updated'
  | 'organization.deleted'
  | 'organizationDomain.created'
  | 'organizationDomain.updated'
  | 'organizationDomain.deleted'
  | 'organizationInvitation.created'
  | 'organizationInvitation.accepted'
  | 'organizationInvitation.revoked'
  | 'organizationMembership.created'
  | 'organizationMembership.updated'
  | 'organizationMembership.deleted'
  | 'permission.created'
  | 'permission.updated'
  | 'permission.deleted'
  | 'role.created'
  | 'role.updated'
  | 'role.deleted'
  | 'waitlistEntry.created';

User Webhook Events

Events related to user account changes.

/**
 * User webhook event
 */
interface UserWebhookEvent {
  /**
   * Event type
   */
  type: 'user.created' | 'user.updated' | 'user.deleted';

  /**
   * Event object type
   */
  object: 'event';

  /**
   * User data
   */
  data: UserJSON;
}

interface UserJSON {
  /**
   * User ID
   */
  id: string;

  /**
   * User's email addresses
   */
  email_addresses: EmailAddressJSON[];

  /**
   * User's phone numbers
   */
  phone_numbers: PhoneNumberJSON[];

  /**
   * User's web3 wallets
   */
  web3_wallets: Web3WalletJSON[];

  /**
   * User's external accounts
   */
  external_accounts: ExternalAccountJSON[];

  /**
   * User's passkeys
   */
  passkeys: PasskeyJSON[];

  /**
   * User's first name
   */
  first_name: string | null;

  /**
   * User's last name
   */
  last_name: string | null;

  /**
   * User's username
   */
  username: string | null;

  /**
   * User's profile image URL
   */
  image_url: string;

  /**
   * Whether user has uploaded a profile image
   */
  has_image: boolean;

  /**
   * Primary email address ID
   */
  primary_email_address_id: string | null;

  /**
   * Primary phone number ID
   */
  primary_phone_number_id: string | null;

  /**
   * Primary web3 wallet ID
   */
  primary_web3_wallet_id: string | null;

  /**
   * Public metadata
   */
  public_metadata: Record<string, any>;

  /**
   * Private metadata
   */
  private_metadata: Record<string, any>;

  /**
   * Unsafe metadata
   */
  unsafe_metadata: Record<string, any>;

  /**
   * Whether user is banned
   */
  banned: boolean;

  /**
   * Whether user is locked
   */
  locked: boolean;

  /**
   * Whether user account is locked
   */
  lockout_expires_in_seconds: number | null;

  /**
   * Verification attempts remaining
   */
  verification_attempts_remaining: number | null;

  /**
   * Timestamp when user was created (milliseconds)
   */
  created_at: number;

  /**
   * Timestamp when user was last updated (milliseconds)
   */
  updated_at: number;

  /**
   * Timestamp of last sign-in (milliseconds)
   */
  last_sign_in_at: number | null;

  /**
   * Timestamp of last active session (milliseconds)
   */
  last_active_at: number | null;

  /**
   * Whether two-factor authentication is enabled
   */
  two_factor_enabled: boolean;

  /**
   * Whether backup codes are enabled
   */
  backup_code_enabled: boolean;

  /**
   * Whether password is enabled
   */
  password_enabled: boolean;

  /**
   * External ID
   */
  external_id: string | null;

  /**
   * TOTP enabled
   */
  totp_enabled: boolean;

  /**
   * Profile image ID
   */
  profile_image_id: string | null;

  /**
   * Create organization enabled
   */
  create_organization_enabled: boolean;

  /**
   * Delete self enabled
   */
  delete_self_enabled: boolean;
}

Usage Example - Handle User Events:

import { verifyWebhook } from '@clerk/nextjs/webhooks';
import type { UserWebhookEvent } from '@clerk/nextjs/webhooks';

export async function POST(req: Request) {
  const evt = await verifyWebhook(req);

  if (evt.type === 'user.created') {
    const user = evt.data;

    // Sync user to database
    await database.users.create({
      clerkId: user.id,
      email: user.email_addresses[0]?.email_address,
      firstName: user.first_name,
      lastName: user.last_name,
      imageUrl: user.image_url,
      metadata: user.public_metadata,
    });

    console.log('User synced to database:', user.id);
  }

  if (evt.type === 'user.updated') {
    const user = evt.data;

    // Update user in database
    await database.users.update({
      where: { clerkId: user.id },
      data: {
        email: user.email_addresses[0]?.email_address,
        firstName: user.first_name,
        lastName: user.last_name,
        imageUrl: user.image_url,
        metadata: user.public_metadata,
      },
    });

    console.log('User updated in database:', user.id);
  }

  if (evt.type === 'user.deleted') {
    const user = evt.data;

    // Delete user from database
    await database.users.delete({
      where: { clerkId: user.id },
    });

    console.log('User deleted from database:', user.id);
  }

  return new Response('Success', { status: 200 });
}

Session Webhook Events

Events related to user sessions.

/**
 * Session webhook event
 */
interface SessionWebhookEvent {
  type: 'session.created' | 'session.ended' | 'session.removed' | 'session.revoked';
  object: 'event';
  data: SessionJSON;
}

interface SessionJSON {
  id: string;
  client_id: string;
  user_id: string;
  status: 'active' | 'ended' | 'removed' | 'revoked' | 'expired' | 'abandoned';
  last_active_at: number;
  last_active_organization_id: string | null;
  actor: any | null;
  expire_at: number;
  abandon_at: number;
  created_at: number;
  updated_at: number;
}

Organization Webhook Events

Events related to organizations.

/**
 * Organization webhook event
 */
interface OrganizationWebhookEvent {
  type: 'organization.created' | 'organization.updated' | 'organization.deleted';
  object: 'event';
  data: OrganizationJSON;
}

interface OrganizationJSON {
  id: string;
  name: string;
  slug: string;
  image_url: string;
  has_image: boolean;
  created_by: string;
  created_at: number;
  updated_at: number;
  public_metadata: Record<string, any>;
  private_metadata: Record<string, any>;
  max_allowed_memberships: number;
  admin_delete_enabled: boolean;
  members_count: number;
}

Organization Membership Webhook Events

Events related to organization memberships.

/**
 * Organization membership webhook event
 */
interface OrganizationMembershipWebhookEvent {
  type:
    | 'organizationMembership.created'
    | 'organizationMembership.updated'
    | 'organizationMembership.deleted';
  object: 'event';
  data: OrganizationMembershipJSON;
}

interface OrganizationMembershipJSON {
  id: string;
  object: 'organization_membership';
  role: string;
  permissions: string[];
  public_metadata: Record<string, any>;
  private_metadata: Record<string, any>;
  organization: OrganizationJSON;
  public_user_data: {
    user_id: string;
    first_name: string | null;
    last_name: string | null;
    image_url: string;
    has_image: boolean;
    identifier: string;
  };
  created_at: number;
  updated_at: number;
}

Organization Invitation Webhook Events

Events related to organization invitations.

/**
 * Organization invitation webhook event
 */
interface OrganizationInvitationWebhookEvent {
  type:
    | 'organizationInvitation.created'
    | 'organizationInvitation.accepted'
    | 'organizationInvitation.revoked';
  object: 'event';
  data: OrganizationInvitationJSON;
}

interface OrganizationInvitationJSON {
  id: string;
  email_address: string;
  role: string;
  organization_id: string;
  status: 'pending' | 'accepted' | 'revoked';
  public_metadata: Record<string, any>;
  private_metadata: Record<string, any>;
  created_at: number;
  updated_at: number;
}

Organization Domain Webhook Events

Events related to organization domains.

/**
 * Organization domain webhook event
 */
interface OrganizationDomainWebhookEvent {
  type:
    | 'organizationDomain.created'
    | 'organizationDomain.updated'
    | 'organizationDomain.deleted';
  object: 'event';
  data: OrganizationDomainJSON;
}

interface OrganizationDomainJSON {
  id: string;
  object: 'organization_domain';
  name: string;
  organization_id: string;
  enrollment_mode: 'manual_invitation' | 'automatic_invitation' | 'automatic_suggestion';
  verification: OrganizationDomainVerificationJSON | null;
  affiliation_email_address: string | null;
  created_at: number;
  updated_at: number;
  total_pending_invitations: number;
  total_pending_suggestions: number;
}

interface OrganizationDomainVerificationJSON {
  status: 'unverified' | 'verified';
  strategy: 'email_code' | 'dns_record';
  attempts: number;
  expires_at: number;
}

Email and SMS Webhook Events

Events related to email and SMS communications.

/**
 * Email webhook event
 */
interface EmailWebhookEvent {
  type: 'email.created';
  object: 'event';
  data: EmailJSON;
}

interface EmailJSON {
  id: string;
  object: 'email';
  slug: string;
  from_email_name: string;
  to_email_address: string;
  email_address_id: string | null;
  user_id: string | null;
  subject: string;
  body: string;
  status: 'queued' | 'sent' | 'delivered' | 'failed';
  data: Record<string, any> | null;
}

/**
 * SMS webhook event
 */
interface SMSWebhookEvent {
  type: 'sms.created';
  object: 'event';
  data: SMSMessageJSON;
}

interface SMSMessageJSON {
  id: string;
  object: 'sms_message';
  slug: string;
  from_phone_number: string;
  to_phone_number: string;
  phone_number_id: string | null;
  user_id: string | null;
  message: string;
  status: 'queued' | 'sent' | 'delivered' | 'failed';
  data: Record<string, any> | null;
}

Other Webhook Events

/**
 * Permission webhook event
 */
interface PermissionWebhookEvent {
  type: 'permission.created' | 'permission.updated' | 'permission.deleted';
  object: 'event';
  data: any;
}

/**
 * Role webhook event
 */
interface RoleWebhookEvent {
  type: 'role.created' | 'role.updated' | 'role.deleted';
  object: 'event';
  data: any;
}

/**
 * Waitlist entry webhook event
 */
interface WaitlistEntryWebhookEvent {
  type: 'waitlistEntry.created';
  object: 'event';
  data: WaitlistEntryJSON;
}

interface WaitlistEntryJSON {
  id: string;
  object: 'waitlist_entry';
  email_address: string;
  status: 'pending' | 'approved' | 'rejected';
  created_at: number;
  updated_at: number;
}

/**
 * Deleted object webhook event
 */
interface DeletedObjectJSON {
  id: string;
  object: string;
  deleted: true;
}

Webhook Setup

1. Create Webhook Endpoint

Create a route handler to receive webhooks:

// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks';

export async function POST(req: Request) {
  try {
    const evt = await verifyWebhook(req);

    // Handle event
    console.log('Webhook received:', evt.type);

    return new Response('Success', { status: 200 });
  } catch (err) {
    console.error('Webhook error:', err);
    return new Response('Error', { status: 400 });
  }
}

2. Configure Webhook in Clerk Dashboard

  1. Go to Clerk Dashboard → Webhooks
  2. Click "Add Endpoint"
  3. Enter your webhook URL (e.g., https://yourdomain.com/api/webhooks/clerk)
  4. Select events to receive
  5. Copy the signing secret

3. Add Signing Secret to Environment

# .env.local
CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxx

4. Disable Body Parsing (App Router)

For App Router, Next.js automatically handles body parsing. No additional configuration needed.

5. Disable Body Parsing (Pages Router)

For Pages Router, disable body parsing:

// pages/api/webhooks/clerk.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks';
import type { NextApiRequest, NextApiResponse } from 'next';

export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const evt = await verifyWebhook(req);
    return res.status(200).json({ received: true });
  } catch (err) {
    return res.status(400).json({ error: 'Webhook verification failed' });
  }
}

Best Practices

1. Idempotent Event Handling

Ensure webhook handlers are idempotent (can be called multiple times safely):

import { verifyWebhook } from '@clerk/nextjs/webhooks';

export async function POST(req: Request) {
  const evt = await verifyWebhook(req);

  if (evt.type === 'user.created') {
    // Use upsert instead of create to handle duplicates
    await database.users.upsert({
      where: { clerkId: evt.data.id },
      create: {
        clerkId: evt.data.id,
        email: evt.data.email_addresses[0]?.email_address,
      },
      update: {
        email: evt.data.email_addresses[0]?.email_address,
      },
    });
  }

  return new Response('Success', { status: 200 });
}

2. Error Handling

Return appropriate status codes:

import { verifyWebhook } from '@clerk/nextjs/webhooks';

export async function POST(req: Request) {
  try {
    const evt = await verifyWebhook(req);

    try {
      await handleEvent(evt);
      return new Response('Success', { status: 200 });
    } catch (err) {
      console.error('Event handling error:', err);
      // Return 500 for processing errors (Clerk will retry)
      return new Response('Processing error', { status: 500 });
    }
  } catch (err) {
    console.error('Verification error:', err);
    // Return 400 for verification errors (Clerk won't retry)
    return new Response('Verification failed', { status: 400 });
  }
}

3. Event Routing

Route events to specific handlers:

import { verifyWebhook } from '@clerk/nextjs/webhooks';
import type { WebhookEvent } from '@clerk/nextjs/webhooks';

export async function POST(req: Request) {
  const evt = await verifyWebhook(req);

  const handlers: Record<string, (data: any) => Promise<void>> = {
    'user.created': handleUserCreated,
    'user.updated': handleUserUpdated,
    'user.deleted': handleUserDeleted,
    'organization.created': handleOrganizationCreated,
    'organizationMembership.created': handleMembershipCreated,
  };

  const handler = handlers[evt.type];

  if (handler) {
    await handler(evt.data);
  } else {
    console.log('Unhandled event type:', evt.type);
  }

  return new Response('Success', { status: 200 });
}

async function handleUserCreated(data: any) {
  await database.users.create({
    clerkId: data.id,
    email: data.email_addresses[0]?.email_address,
  });
}

async function handleUserUpdated(data: any) {
  await database.users.update({
    where: { clerkId: data.id },
    data: { email: data.email_addresses[0]?.email_address },
  });
}

async function handleUserDeleted(data: any) {
  await database.users.delete({
    where: { clerkId: data.id },
  });
}

async function handleOrganizationCreated(data: any) {
  await database.organizations.create({
    clerkId: data.id,
    name: data.name,
    slug: data.slug,
  });
}

async function handleMembershipCreated(data: any) {
  await database.memberships.create({
    userId: data.public_user_data.user_id,
    organizationId: data.organization.id,
    role: data.role,
  });
}

Requirements

Webhook verification requires:

  1. Signing secret from Clerk Dashboard
  2. CLERK_WEBHOOK_SIGNING_SECRET environment variable
  3. Webhook endpoint configured in Clerk Dashboard
  4. HTTPS URL for production webhooks