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.
Required Setup:
CLERK_WEBHOOK_SIGNING_SECRET environment variable (or pass via signingSecret option)Default Behaviors:
verifyWebhook() verifies request signature using SvixCLERK_WEBHOOK_SIGNING_SECRET from environment if not providedverifyWebhook() throws error if verification fails (must catch and return 400)type and data properties'user.created', 'user.updated')Threading Model:
verifyWebhook() executes in server runtime (Node.js)Lifecycle:
verifyWebhook() verifies request signatureEdge Cases:
Exceptions:
verifyWebhook() throws error if signature verification failsCLERK_WEBHOOK_SIGNING_SECRET causes verification to failVerifies 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 });
}
}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';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 });
}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;
}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;
}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;
}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;
}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;
}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;
}/**
* 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;
}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 });
}
}https://yourdomain.com/api/webhooks/clerk)# .env.local
CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxxFor App Router, Next.js automatically handles body parsing. No additional configuration needed.
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' });
}
}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 });
}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 });
}
}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,
});
}Webhook verification requires: