YeetCode
Convex App

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
  1. Clerk sends a signed webhook to your Convex HTTP endpoint
  2. The HTTP action validates the signature using Svix headers (svix-id, svix-timestamp, svix-signature)
  3. On success, it schedules an internal mutation via ctx.scheduler.runAfter(0, ...) — this decouples the HTTP response from processing
  4. 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

EventHandlerWhat It Does
user.createdhandleUserCreatedStores webhook event, creates user record
user.deletedhandleUserDeletedStores 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, timestamps
  • user.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:

  1. Go to your Clerk dashboard → Webhooks
  2. Add an endpoint pointing to https://<your-convex-deployment>.convex.site/webhook-events/clerk
  3. Subscribe to user.created and user.deleted events
  4. Copy the Signing Secret and set it as CLERK_WEBHOOK_SECRET in your Convex environment variables

On this page