Clerk Webhook

Sync Clerk users into the Plainform database with a verified webhook endpoint.

Plainform includes a Clerk webhook at app/api/webhooks/clerk/route.ts. It listens for Clerk user events, verifies the webhook signature, and keeps the local Prisma User table in sync with Clerk.

Why It Exists

Clerk owns authentication state, but Plainform also needs a durable local user record for app data and payments. The Clerk webhook creates that bridge.

The webhook helps with:

  • Subscriptions - Stripe checkout and subscription webhooks need a local user row to store stripeCustomerId, stripeSubscriptionId, plan data, and subscription status.
  • User lifecycle sync - When a Clerk user is created, updated, or deleted, the local database follows the same lifecycle.
  • Database relationships - Future app models can safely reference User.id, which is the Clerk user ID.

The User.id field is the Clerk user ID. This keeps Clerk, Prisma, and Stripe connected without a separate mapping table.

Webhooks are asynchronous. Use them to sync durable data, but do not block a sign-up or checkout screen while waiting for Clerk to deliver a webhook.

What The Webhook Handles

Plainform subscribes to these Clerk events:

  • user.created - Upserts the user into the database with the primary email.
  • user.updated - Upserts the user again so email changes are reflected locally.
  • user.deleted - Deletes matching local user rows by Clerk user ID.

The handler verifies each request with verifyWebhook() and CLERK_WEBHOOK_SECRET before touching the database:

app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks';
import { prisma } from '@/lib/prisma/prisma';
import { env } from '@/env';

export async function POST(req: NextRequest) {
  let event;

  try {
    event = await verifyWebhook(req, {
      signingSecret: env.CLERK_WEBHOOK_SECRET,
    });
  } catch (error) {
    return NextResponse.json(
      { message: 'Invalid webhook signature' },
      { status: 400 }
    );
  }

  switch (event.type) {
    case 'user.created':
      return await handleUserCreated(event.data);
    case 'user.updated':
      return await handleUserUpdated(event.data);
    case 'user.deleted':
      return await handleUserDeleted(event.data);
    default:
      return NextResponse.json({ message: 'Event type not handled' });
  }
}

Database Shape

The webhook writes to the User model in prisma/schema.prisma:

prisma/schema.prisma
model User {
  id                           String    @id
  email                        String    @unique @db.VarChar(320)
  stripeCustomerId             String?   @unique
  stripeSubscriptionId         String?   @unique
  stripePriceId                String?
  subscriptionStatus           String?   @db.VarChar(64)
  subscriptionCurrentPeriodEnd DateTime?
  cancelAtPeriodEnd            Boolean   @default(false)
  planKey                      String?   @db.VarChar(128)
  createdAt                    DateTime  @default(now())
  updatedAt                    DateTime  @updatedAt

  @@index([subscriptionStatus])
}

Only id and email are written by the Clerk webhook. Stripe fields are filled later by checkout and Stripe subscription webhooks.

Setup

Add the Webhook Secret

Add the Clerk webhook signing secret to .env:

.env
CLERK_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxx"

This value is validated by env.ts, so the app will fail fast if it is missing.

Start the App Locally

Run Plainform on port 3000:

Terminal
npm run dev

The webhook endpoint will be available locally at:

http://localhost:3000/api/webhooks/clerk

Expose Localhost With ngrok

Clerk needs a public URL to call your local webhook. Start an ngrok tunnel:

Terminal
npx ngrok http 3000

If you use an ngrok static domain, run the command ngrok provides from your dashboard. It will look like this:

Terminal
ngrok http --url=your-static-domain.ngrok-free.app 3000

Copy the HTTPS forwarding URL from ngrok and append the webhook path:

https://your-ngrok-domain.ngrok-free.app/api/webhooks/clerk

Use the HTTPS ngrok URL. Clerk cannot deliver webhooks to plain localhost.

Create the Endpoint in Clerk

In the Clerk Dashboard:

  1. Open your application.
  2. Go to the Webhooks page.
  3. Click Add Endpoint.
  4. Set the endpoint URL to your production URL or ngrok URL.
  5. Subscribe to user.created, user.updated, and user.deleted.
  6. Save the endpoint.
  7. Open the endpoint settings and copy the Signing Secret into CLERK_WEBHOOK_SECRET.

For production, use your deployed domain:

https://yourdomain.com/api/webhooks/clerk

Test the Flow

Create a test user through /sign-up, then check:

  • Your terminal logs for webhook delivery errors.
  • Clerk Dashboard webhook delivery logs.
  • The User table in Prisma Studio.
Terminal
npx prisma studio

You should see a User row with the Clerk user ID and email address.

How It Supports Payments

Subscriptions depend on this webhook because Stripe needs a local user record to update.

The flow is:

  1. A user signs up with Clerk.
  2. Clerk sends user.created to /api/webhooks/clerk.
  3. Plainform creates or updates User { id, email }.
  4. During checkout, Plainform creates or reuses a Stripe customer and stores stripeCustomerId on that user.
  5. Stripe subscription webhooks update the same user row with subscription status, plan, period end, and cancellation state.

Without the Clerk webhook, a user may authenticate successfully but not exist in your database. That breaks subscription persistence because Stripe has nowhere reliable to write entitlement state.

Common Issues

Invalid Webhook Signature

Use the signing secret from the exact Clerk endpoint that is sending requests. If you create separate development and production endpoints, each has its own secret.

User Not Created Locally

Check that user.created is selected in the Clerk webhook event list, then inspect the Clerk delivery logs. If Clerk reports a 500, check your app logs and database connection.

ngrok URL Stopped Working

Free ngrok URLs usually change when the tunnel restarts. Update the Clerk webhook endpoint URL after restarting ngrok.

Missing Email

The handler uses the primary Clerk email when available and falls back to the first email address. If Clerk sends a user event without an email, Plainform returns 400 and logs the Clerk user ID.

How is this guide ?

Last updated on

On this page