Clerk Webhooks
How Clerk webhook events are received, verified, and processed.
YeetCode uses Clerk webhooks to sync user lifecycle events (creation, deletion) from Clerk into the Convex database. Webhooks are verified using Svix and processed asynchronously via Convex's scheduler.
Request Flow
Clerk → POST /webhook-events/clerk → Svix Verification → Schedule Internal Mutation → Process Event- Clerk sends a signed webhook to your Convex HTTP endpoint
- The HTTP action validates the signature using Svix headers (
svix-id,svix-timestamp,svix-signature) - On success, it schedules an internal mutation via
ctx.scheduler.runAfter(0, ...)— this decouples the HTTP response from processing - The internal mutation creates a webhook record and syncs user data
HTTP Router
The webhook endpoint is registered in apps/convex/http.ts:
import { httpRouter } from "convex/server";
import { onClerkWebhookEventReceived } from "./clerk/webhookEvents";
const http = httpRouter();
http.route({
path: "/webhook-events/clerk",
method: "POST",
handler: onClerkWebhookEventReceived,
});
export default http;Signature Verification
Every incoming webhook is verified before processing:
import { Webhook } from "svix";
async function validateRequest(req: Request): Promise<WebhookEvent | null> {
const payloadString = await req.text();
const svixHeaders = {
"svix-id": req.headers.get("svix-id")!,
"svix-timestamp": req.headers.get("svix-timestamp")!,
"svix-signature": req.headers.get("svix-signature")!,
};
const wh = new Webhook(env.CLERK_WEBHOOK_SECRET);
try {
return wh.verify(payloadString, svixHeaders) as unknown as WebhookEvent;
} catch (error) {
console.error("problem verifying webhook event", error);
return null;
}
}If verification fails, the handler returns a 400 response.
Supported Events
| Event | Handler | What It Does |
|---|---|---|
user.created | handleUserCreated | Stores webhook event, creates user record |
user.deleted | handleUserDeleted | Stores webhook event, removes user record |
Async Processing
The HTTP action doesn't process events inline — it schedules internal mutations:
await ctx.scheduler.runAfter(0, internal.clerk.webhookEvents.handleUserCreated, {
webhookData: {
externalId: clerkUserId,
payload: requestObj.data,
type: 'user.created',
}
});runAfter(0, ...) means "run immediately, but asynchronously." This keeps the HTTP response fast and allows the mutation to run within a proper Convex transaction.
Idempotency
The createWebhookEvent helper checks for existing records before inserting:
async function createWebhookEvent(ctx, data) {
const existingWebhook = await Webhooks.getByExternalId(ctx, data.externalId);
if (existingWebhook) return; // Already processed
await Webhooks.create(ctx, data);
}This prevents duplicate processing if Clerk retries a webhook delivery.
Payload Validation
Webhook payloads are validated with Zod schemas in clerk/webhookSchemas.ts. The schema uses a discriminated union on the type field:
user.created— Validates email addresses, name fields, metadata, image URL, timestampsuser.deleted— Validates the deletion flag and user ID
Validation happens in the model layer (Webhooks.create()) before the event is persisted.
Clerk Dashboard Setup
To configure webhooks in Clerk:
- Go to your Clerk dashboard → Webhooks
- Add an endpoint pointing to
https://<your-convex-deployment>.convex.site/webhook-events/clerk - Subscribe to
user.createdanduser.deletedevents - Copy the Signing Secret and set it as
CLERK_WEBHOOK_SECRETin your Convex environment variables