Clerk SDK for Next.js providing authentication and user management with support for both App Router and Pages Router architectures
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:
Install with Tessl CLI
npx tessl i tessl/npm-clerk--nextjs