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:
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:
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:
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:
npm run devThe webhook endpoint will be available locally at:
http://localhost:3000/api/webhooks/clerkExpose Localhost With ngrok
Clerk needs a public URL to call your local webhook. Start an ngrok tunnel:
npx ngrok http 3000If you use an ngrok static domain, run the command ngrok provides from your dashboard. It will look like this:
ngrok http --url=your-static-domain.ngrok-free.app 3000Copy the HTTPS forwarding URL from ngrok and append the webhook path:
https://your-ngrok-domain.ngrok-free.app/api/webhooks/clerkUse the HTTPS ngrok URL. Clerk cannot deliver webhooks to plain
localhost.
Create the Endpoint in Clerk
In the Clerk Dashboard:
- Open your application.
- Go to the Webhooks page.
- Click Add Endpoint.
- Set the endpoint URL to your production URL or ngrok URL.
- Subscribe to
user.created,user.updated, anduser.deleted. - Save the endpoint.
- Open the endpoint settings and copy the Signing Secret into
CLERK_WEBHOOK_SECRET.
For production, use your deployed domain:
https://yourdomain.com/api/webhooks/clerkTest the Flow
Create a test user through /sign-up, then check:
- Your terminal logs for webhook delivery errors.
- Clerk Dashboard webhook delivery logs.
- The
Usertable in Prisma Studio.
npx prisma studioYou 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:
- A user signs up with Clerk.
- Clerk sends
user.createdto/api/webhooks/clerk. - Plainform creates or updates
User { id, email }. - During checkout, Plainform creates or reuses a Stripe customer and stores
stripeCustomerIdon that user. - 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.
Related Resources
How is this guide ?
Last updated on