GetLaunchpad
Back to blog
5 min read

Syncing Clerk users to your database with webhooks

Clerk handles auth, but your app lives in your own database. Here's how to set up the /api/webhooks/clerk route, verify Svix signatures, handle user.created/updated/deleted events, and upsert records to Supabase with the admin client.

Clerk handles authentication, but your application lives in your own database. User preferences, subscription data, audit logs — none of it can live in Clerk. That means you need a reliable way to keep the two in sync. The right tool for this is Clerk webhooks: every time a user is created, updated, or deleted in Clerk, your app receives an HTTP event and can react to it immediately.

Why not poll or sync on login?

A common pattern is calling POST /api/user on every dashboard load to upsert the Clerk user into your database. That works for basic cases, but it has gaps: users who are deleted in the Clerk dashboard, email address changes made outside your app, and organization membership changes all go unnoticed. Webhooks close every gap.

Install the Svix verification library

Clerk uses Svix to deliver webhooks. To verify that incoming requests are genuinely from Clerk and not forged, you need the Svix SDK:

npm install svix

Add the webhook secret to your environment

In the Clerk dashboard, go to Webhooks → Add Endpoint. Set the URL to https://yourdomain.com/api/webhooks/clerk and subscribe to the user.created, user.updated, and user.deleted events. Copy the signing secret and add it to .env.local:

CLERK_WEBHOOK_SECRET=whsec_...

Create the webhook route handler

Create app/api/webhooks/clerk/route.ts. This route must be public — Clerk cannot sign in to call it. Make sure your proxy.ts matcher does not protect /api/webhooks/*.

import { Webhook } from "svix";
import { headers } from "next/headers";
import { WebhookEvent } from "@clerk/nextjs/server";
import { adminClient } from "@/lib/supabase/admin";

export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
  if (!WEBHOOK_SECRET) {
    return new Response("Webhook secret not configured", { status: 500 });
  }

  // Read Svix signature headers
  const headerPayload = await headers();
  const svix_id = headerPayload.get("svix-id");
  const svix_timestamp = headerPayload.get("svix-timestamp");
  const svix_signature = headerPayload.get("svix-signature");

  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response("Missing Svix headers", { status: 400 });
  }

  // Verify the signature
  const payload = await req.json();
  const body = JSON.stringify(payload);
  const wh = new Webhook(WEBHOOK_SECRET);

  let event: WebhookEvent;
  try {
    event = wh.verify(body, {
      "svix-id": svix_id,
      "svix-timestamp": svix_timestamp,
      "svix-signature": svix_signature,
    }) as WebhookEvent;
  } catch (err) {
    console.error("Webhook verification failed:", err);
    return new Response("Invalid signature", { status: 400 });
  }

  // Route to the correct handler
  switch (event.type) {
    case "user.created":
      await handleUserCreated(event.data);
      break;
    case "user.updated":
      await handleUserUpdated(event.data);
      break;
    case "user.deleted":
      await handleUserDeleted(event.data);
      break;
  }

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

Handle user.created and user.updated

Both events use an upsert so the handler is idempotent — safe to replay if Clerk retries delivery:

async function handleUserCreated(data: WebhookEvent["data"]) {
  if (data.object !== "user") return;

  const primaryEmail = data.email_addresses?.find(
    (e) => e.id === data.primary_email_address_id
  )?.email_address ?? "";

  await adminClient.from("users").upsert(
    {
      clerk_id: data.id,
      email: primaryEmail,
      first_name: data.first_name ?? null,
      last_name: data.last_name ?? null,
    },
    { onConflict: "clerk_id" }
  );
}

async function handleUserUpdated(data: WebhookEvent["data"]) {
  // Same upsert — Clerk sends the full user object on updates
  await handleUserCreated(data);
}

Always find the primary email address by matching primary_email_address_id against the email_addresses array. Never assume index 0 is primary — users can add and reorder addresses.

Handle user.deleted

async function handleUserDeleted(data: WebhookEvent["data"]) {
  if (!data.id) return;

  // Soft delete — preserve subscription history for billing reconciliation
  await adminClient
    .from("users")
    .update({ deleted_at: new Date().toISOString() })
    .eq("clerk_id", data.id);
}

Use a soft delete rather than a hard DELETE. If the user had an active subscription, you want the row to exist for Stripe reconciliation and refund processing. Add a deleted_at timestamptz column to your users table and filter it out of queries with .is("deleted_at", null).

Protect the route from unauthenticated callers

The Svix signature check is your only authentication here. Make sure the route is excluded from Clerk's middleware matcher so Clerk does not redirect webhook calls to the sign-in page. In proxy.ts:

const isProtected = createRouteMatcher([
  "/dashboard(.*)",
  // Do NOT add /api/webhooks/(.*)
]);

Testing with the Clerk dashboard

Clerk's webhook UI has a built-in tester. Go to Webhooks → your endpoint → Testing and send a user.createdevent. You'll see the request body, your response status, and any retry attempts. For local development, use the Clerk CLI or ngrok to expose your dev server:

# expose localhost:3000 publicly
npx ngrok http 3000

# then set your Clerk webhook endpoint to:
# https://<random>.ngrok-free.app/api/webhooks/clerk

After registering the endpoint, trigger a real user creation through your sign-up flow and verify the row appears in your Supabase users table within a second or two.

Idempotency and retry safety

Svix retries delivery if your endpoint returns anything other than a 2xx status. Your handlers must be idempotent — running the same event twice should produce the same result. The upsert pattern above satisfies this. For user.deleted, guard with a null check on deleted_at before updating to avoid overwriting a more recent timestamp on retries.


The full Clerk webhook pipeline — signature verification, upsert to Supabase, soft deletes, and the correct proxy.ts matcher — ships ready to use in GetLaunchpad, a Next.js 16 SaaS boilerplate. Stop wiring infrastructure and start building your product.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles