Skip to main content
Docs

Sync Clerk data to your app with webhooks

The recommended way to sync Clerk data to your app is through webhooks.

In this guide, you'll set up a webhook in your app to listen for the user.created event, create an endpoint in the Clerk Dashboard, build a handler for verifying the webhook, and test it locally using ngrok and the Clerk Dashboard.

Clerk offers many events, but three key events include:

  • user.created: Triggers when a new user registers in the app or is created via the Clerk Dashboard or Backend API. Listening to this event allows the initial insertion of user information in your database.
  • user.updated: Triggers when user information is updated via Clerk components, the Clerk Dashboard, or Backend API. Listening to this event keeps data synced between Clerk and your external database. It is recommended to only sync what you need to simplify this process.
  • user.deleted: Triggers when a user deletes their account, or their account is removed via the Clerk Dashboard or Backend API. Listening to this event allows you to delete the user from your database or add a deleted: true flag.

These steps apply to any Clerk event. To make the setup process easier, it's recommended to keep two browser tabs open: one for your Clerk Webhooks page and one for your ngrok dashboard.

Set up ngrok

To test a webhook locally, you need to expose your local server to the internet. This guide uses ngrok which creates a forwarding URL that sends the webhook payload to your local server.

  1. Navigate to the ngrok dashboard to create an account.
  2. On the ngrok dashboard homepage, follow the setup guide instructions. Under Deploy your app online, select Static domain. Run the provided command, replacing the port number with your server's port. For example, if your development server runs on port 3000, the command should resemble ngrok http --url=<YOUR_FORWARDING_URL> 3000. This creates a free static domain and starts a tunnel.
  3. Save your Forwarding URL somewhere secure.

Set up a webhook endpoint

  1. In the Clerk Dashboard, navigate to the Webhooks page.
  2. Select Add Endpoint.
  3. In the Endpoint URL field, paste the ngrok Forwarding URL you saved earlier, followed by /api/webhooks. This is the endpoint that Clerk uses to send the webhook payload. The full URL should resemble https://fawn-two-nominally.ngrok-free.app/api/webhooks.
  4. In the Subscribe to events section, scroll down and select user.created.
  5. Select Create. You'll be redirected to your endpoint's settings page. Keep this page open.

Add your Signing Secret to .env

To verify the webhook payload, you'll need your endpoint's Signing Secret. Since you don't want this secret exposed in your codebase, store it as an environment variable in your .env file during local development.

  1. On the endpoint's settings page in the Clerk Dashboard, copy the Signing Secret. You may need to select the eye icon to reveal the secret.
  2. In your project's root directory, open or create an .env file, which should already include your Clerk API keys. Assign your Signing Secret to CLERK_WEBHOOK_SIGNING_SECRET. The file should resemble:

Important

Prefix CLERK_WEBHOOK_SIGNING_SECRET with NUXT_.

.env
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
CLERK_SECRET_KEY=YOUR_SECRET_KEY
CLERK_WEBHOOK_SIGNING_SECRET=whsec_123

Make sure the webhook route is public

Incoming webhook events don't contain auth information. They come from an external source and aren't signed in or out, so the route must be public to allow access. If you're using clerkMiddleware(), ensure that the /api/webhooks(.*) route is set as public. For information on configuring routes, see the clerkMiddleware() guide.

Install svix

Clerk uses svix to deliver and verify webhooks. Run the following command in your terminal to install the package:

terminal
npm install svix
terminal
yarn add svix
terminal
pnpm add svix
terminal
bun add svix

Create a route handler to verify the webhook

Set up a Route Handler that uses Clerk's verifyWebhook() function to verify the incoming Clerk webhook and process the payload.

For this guide, the payload will be logged to the console. In a real app, you'd use the payload to trigger an action. For example, if listening for the user.created event, you might perform a database create or upsert to add the user's Clerk data to your database's user table.

If the route handler returns a 4xx or 5xx code, or no code at all, the webhook event will be retried. If the route handler returns a 2xx code, the event will be marked as successful, and retries will stop.

Note

The following Route Handler can be used for any webhook event you choose to listen to. It is not specific to user.created.

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

export async function POST(req: Request) {
  try {
    const evt = await verifyWebhook(req)

    // Do something with payload
    // For this guide, log payload to console
    const { id } = evt.data
    const eventType = evt.type
    console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
    console.log('Webhook payload:', evt.data)

    return new Response('Webhook received', { status: 200 })
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error verifying webhook', { status: 400 })
  }
}
src/pages/api/webhooks.ts
import { verifyWebhook } from '@clerk/astro/webhooks'
import type { APIRoute } from 'astro'

export const POST: APIRoute = async ({ request }) => {
  try {
    const evt = await verifyWebhook(request, {
      signingSecret: import.meta.env.CLERK_WEBHOOK_SIGNING_SECRET,
    })

    // Do something with payload
    // For this guide, log payload to console
    const { id } = evt.data
    const eventType = evt.type
    console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
    console.log('Webhook payload:', evt.data)

    return new Response('Webhook received', { status: 200 })
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error verifying webhook', { status: 400 })
  }
}
index.ts
import { verifyWebhook } from '@clerk/express/webhooks'
import express from 'express'

const app = express()

app.post('/api/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const evt = await verifyWebhook(req)

    // Do something with payload
    // For this guide, log payload to console
    const { id } = evt.data
    const eventType = evt.type
    console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
    console.log('Webhook payload:', evt.data)

    return res.send('Webhook received')
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return res.status(400).send('Error verifying webhook')
  }
})
index.ts
import { verifyWebhook } from '@clerk/fastify/webhooks'
import Fastify from 'fastify'

const fastify = Fastify()

fastify.post('/api/webhooks', async (request, reply) => {
  try {
    const evt = await verifyWebhook(request)

    // Do something with payload
    // For this guide, log payload to console
    const { id } = evt.data
    const eventType = evt.type
    console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
    console.log('Webhook payload:', evt.data)

    return 'Webhook received'
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return reply.code(400).send('Error verifying webhook')
  }
})

First, configure Vite to allow the ngrok host in your nuxt.config.ts. You only need to do this in development when tunneling your local server (e.g. localhost:3000/api/webhooks) to a public URL (e.g. https://fawn-two-nominally.ngrok-free.app/api/webhooks). In production, you won't need this configuration because your webhook endpoint will be hosted on your app's production domain (e.g. https://your-app.com/api/webhooks).

nuxt.config.ts
export default defineNuxtConfig({
  // ... other config
  vite: {
    server: {
      // Replace with your ngrok host
      allowedHosts: ['fawn-two-nominally.ngrok-free.app'],
    },
  },
})

Then create your webhook handler:

server/api/webhooks.post.ts
import { verifyWebhook } from '@clerk/nuxt/webhooks'

export default defineEventHandler(async (event) => {
  try {
    const evt = await verifyWebhook(event)

    // Do something with payload
    // For this guide, log payload to console
    const { id } = evt.data
    const eventType = evt.type
    console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
    console.log('Webhook payload:', evt.data)

    return 'Webhook received'
  } catch (err) {
    console.error('Error verifying webhook:', err)
    setResponseStatus(event, 400)
    return 'Error verifying webhook'
  }
})

First, configure Vite to allow the ngrok host in your vite.config.ts. You only need to do this in development when tunneling your local server (e.g. localhost:3000/api/webhooks) to a public URL (e.g. https://fawn-two-nominally.ngrok-free.app/api/webhooks). In production, you won't need this configuration because your webhook endpoint will be hosted on your app's production domain (e.g. https://your-app.com/api/webhooks).

vite.config.ts
export default defineConfig({
  // ... other config
  server: {
    // Replace with your ngrok host
    allowedHosts: ['fawn-two-nominally.ngrok-free.app'],
  },
})

Then create your webhook handler:

app/routes/webhooks.ts
import { verifyWebhook } from '@clerk/react-router/webhooks'
import type { Route } from './+types/webhooks'

export const action = async ({ request }: Route.ActionArgs) => {
  try {
    const evt = await verifyWebhook(request)

    // Do something with payload
    // For this guide, log payload to console
    const { id } = evt.data
    const eventType = evt.type
    console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
    console.log('Webhook payload:', evt.data)

    return new Response('Webhook received', { status: 200 })
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error verifying webhook', { status: 400 })
  }
}

Don't forget to add the route to your router.ts file:

router.ts
import { type RouteConfig, route, index } from '@react-router/dev/routes'

export default [
  index('routes/home.tsx'),
  route('api/webhooks', 'routes/webhooks.ts'),
] satisfies RouteConfig

First, configure Vite to allow the ngrok host in your app.config.ts. You only need to do this in development when tunneling your local server (e.g. localhost:3000/api/webhooks) to a public URL (e.g. https://fawn-two-nominally.ngrok-free.app/api/webhooks). In production, you won't need this configuration because your webhook endpoint will be hosted on your app's production domain (e.g. https://your-app.com/api/webhooks).

app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
import tsConfigPaths from 'vite-tsconfig-paths'
import type { InlineConfig } from 'vite'

// `vite` must be typed as `InlineConfig` in order
// to support the type for `server`
// as vinxi currently doesn't expose it
const vite: InlineConfig = {
  server: {
    // Replace with your ngrok host
    allowedHosts: ['fawn-two-nominally.ngrok-free.app'],
  },
  plugins: [
    tsConfigPaths({
      projects: ['./tsconfig.json'],
    }),
  ],
}

export default defineConfig({
  vite,
})

Then create your webhook handler:

app/routes/api/webhooks.ts
import { verifyWebhook } from '@clerk/tanstack-react-start/webhooks'
import { createAPIFileRoute } from '@tanstack/react-start/api'

export const APIRoute = createAPIFileRoute('/api/webhooks')({
  POST: async ({ request }) => {
    try {
      const evt = await verifyWebhook(request)

      // Do something with payload
      // For this guide, log payload to console
      const { id } = evt.data
      const eventType = evt.type
      console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
      console.log('Webhook payload:', evt.data)

      return new Response('Webhook received', { status: 200 })
    } catch (err) {
      console.error('Error verifying webhook:', err)
      return new Response('Error verifying webhook', { status: 400 })
    }
  },
})

Narrow to a webhook event for type inference

WebhookEvent encompasses all possible webhook types. Narrow down the event type for accurate typing for specific events.

In the following example, the if statement narrows the type to user.created, enabling type-safe access to evt.data with autocompletion.

app/api/webhooks/route.ts
console.log(`Received webhook with ID ${id} and event type of ${eventType}`)
console.log('Webhook payload:', body)

if (evt.type === 'user.created') {
  console.log('userId:', evt.data.id)
}

To handle types manually, import the following types from your backend SDK (e.g., @clerk/nextjs/webhooks):

  • DeletedObjectJSON
  • EmailJSON
  • OrganizationInvitationJSON
  • OrganizationJSON
  • OrganizationMembershipJSON
  • SessionJSON
  • SMSMessageJSON
  • UserJSON

Test the webhook

  1. Start your Next.js server.
  2. In your endpoint's settings page in the Clerk Dashboard, select the Testing tab.
  3. In the Select event dropdown, select user.created.
  4. Select Send Example.
  5. In the Message Attempts section, confirm that the event's Status is labeled with Succeeded. In your server's terminal where your app is running, you should see the webhook's payload.

Handling failed messages

  1. In the Message Attempts section, select the event whose Status is labeled with Failed.
  2. Scroll down to the Webhook Attempts section.
  3. Toggle the arrow next to the Status column.
  4. Review the error. Solutions vary by error type. For more information, refer to the guide on debugging your webhooks.

Trigger the webhook

To trigger the user.created event, create a new user in your app.

In the terminal where your app is running, you should see the webhook's payload logged. You can also check the Clerk Dashboard to see the webhook attempt, the same way you did when testing the webhook.

Configure your production instance

  1. When you're ready to deploy your app to production, follow the guide on deploying your Clerk app to production.
  2. Create your production webhook by following the steps in the previous Set up a webhook endpoint section. In the Endpoint URL field, instead of pasting the ngrok URL, paste your production app URL.
  3. After you've set up your webhook endpoint, you'll be redirected to your endpoint's settings page. Copy the Signing Secret.
  4. On your hosting platform, update your environment variables on your hosting platform by adding Signing Secret with the key of CLERK_WEBHOOK_SIGNING_SECRET.
  5. Redeploy your app.

Feedback

What did you think of this content?

Last updated on