Prabin Acharya
When building modern web applications, user management is a key aspect, and Clerk provides a powerful, ready-made authentication system.
But what if you also have a custom database (for example, PostgreSQL, MongoDB, or MySQL) where you want to keep a record of your users?
That’s where webhooks come in.
With Clerk Webhooks, you can automatically sync user data from Clerk to your database whenever an event occurs (like user creation, update, or deletion). In this guide, we’ll walk through connecting Clerk to your backend via webhooks and testing it locally using ngrok.
Let’s dive in :)
When you use Clerk for authentication, all user data is securely managed by Clerk, but your app might still need to:
Webhooks act as messengers. Every time something happens in Clerk (like a user signs up), Clerk sends a payload to your backend.
Your backend can then update your database automatically, keeping everything in sync without manual intervention.
Let’s start by setting up a simple Express.js backend with clerk integration. You can skip this if you already have it.
mkdir clerk-webhook-demo
cd clerk-webhook-demo
npm init -ynpm install express dotenv @clerk/express Here’s what each package does:
clerk-webhook-demo/
├── server.js
├── .env
├── package.json
└── node_modules/Now let’s move on to Clerk setup.
Before receiving events, you’ll need your Clerk API keys.
(Dont forget to choose yout tech in top right corner, i am using express for this demo.)
.env FileCLERK_PUBLISHABLE_KEY=pk_test_*********************************
CLERK_SECRET_KEY=sk_test_*********************************server.js)import express from "express";
import { clerkMiddleware } from '@clerk/express';
import "dotenv/config";
const app = express();
app.use(express.json());
app.use(clerkMiddleware());
// Test route
app.get("/", (req, res) => {
res.send("Clerk Webhook Demo Running!");
});
app.listen(3000, () => {
console.log(`Server running on port 3000`);
});*Note that our server is running on port 3000. Remember this port as we will use it later with ngrok. Now run your server with:
node --watch server.jsNow that the backend works, let’s create the webhook route.
The webhook route is where Clerk will send user events like user.created or user.updated. Don't worry this is just your normal post api route just not used by our frontend but by clerk's server.
// server.js
import express from "express";
import { clerkMiddleware } from '@clerk/express';
import { verifyWebhook } from "@clerk/express/webhooks"
import "dotenv/config";
const app = express();
app.use(express.json())
app.use(clerkMiddleware());
// Test route
app.get("/", (req, res) => {
res.send("Clerk Webhook Demo Running!");
});
//Webhook route
app.post("/api/webhooks", express.raw({ type: 'application/json' }), async (req, res) => {
try{
const evt = await verifyWebhook(req);
//event data is received in evt. It contains user data if user event like create or updated is triggered.
//You can pretty much do anything with this data.
console.log(evt.data);
res.status(200).json({"message":"Webhook received"})
}catch (error){
console.log("Error occured while receiving webhook", error);
res.status(500).json("message":"Something went wrong");
}
})
app.listen(3000, () => {
console.log(`Server running on port 3000`);
});Our server is running locally but clerk's server can’t reach your localhost — so we’ll fix that with ngrok. We will essentially create temporary deployment for out backend so that clerk's server can access it. This is not as complicated as it sounds, so just stick with me.
Clerk needs a public URL to send webhook events. ngrok creates a secure tunnel to your local server.
npm install -g ngrokngrok http 3000You’ll get an output like this:
Forwarding https://abcd1234.ngrok-free.dev -> http://localhost:3000Copy the https://abcd1234.ngrok-free.dev URL — we’ll use it in Clerk Dashboard.
Now our server is publicly availabe in internet in this url, so clerk can access it. Note that the webhook endpoint should be publicly available i.e. the route should not be protected.
Now that you have your public URL, let’s create a webhook in Clerk.
https://abcd1234.ngrok-free.dev/api/webhooksuser.created this event is fired when new user is created in clerkuser.updated this event is fired when a user's info is updated like name or imageuser.deleted this event is fired when a user is deletedsession.created this event is fired everytime a user logs inAfter creating, Clerk will show you a Webhook Secret Key — copy it and update .env:
WEBHOOK_SECRET=whsec_**********************
Now, let’s actually handle the events that Clerk sends.
We’ll check event types like user.created,user.updated, user.deleted and update our database accordingly. You can use any branching operation but i will use switch statement for evt.type.
app.post("/api/webhooks/clerk", express.raw({ type: 'application/json' }), async (req, res) => {
try {
const evt = await verifyWebhook(req)
switch (evt.type) {
case "user.created":
console.log("🟢 New user created:", evt.data.email_addresses[0].email_address);
// TODO: Insert user into your DB
break;
case "user.updated":
console.log("🟠 User updated:", evt.data.id);
// TODO: Update user data in your DB
break;
case "user.deleted":
console.log("🔴 User deleted:", evt.data.id);
// TODO: Remove user from your DB
break;
default:
console.log("Unhandled event:", evt.type);
}
res.status(200).json({ message: "Webhook processed successfully" });
} catch (error) {
console.error('Error verifying webhook:', err)
return res.status(400).send('Error verifying webhook')
}
});💡 Pro Tip: If you’re using PostgreSQL, Prisma, or Mongoose, this is where you’d perform your
INSERT,UPDATE, orDELETEoperations to keep the data synced.
Congrats, you’ve successfully connected Clerk to your custom backend using webhooks!
Let’s recap what we achieved:
user.created, user.updated, and user.deleted eventsYou can now expand this setup by integrating your ORM (Prisma, Sequelize, or Mongoose) to persist user data, or deploy your webhook endpoint to production (e.g., Vercel, Render, or Railway).
And that’s it! You now have a seamless system where Clerk and your database stay in sync automatically