Skip to main content
Articles

How to sync Clerk user data to your database

Author: Brian Morrison II
Published: (last updated )

Syncing Clerk user data to your own database enables analytics dashboards, custom user profiles, and reduced API dependency. This series guides you through implementing a robust, production-ready synchronization system. This first part covers webhook mechanics, database schema design, and handling failures through idempotency.

Syncing Clerk user data to your own database enables analytics dashboards, custom user profiles, and reduced API dependency — but introduces infrastructure complexity you must weigh carefully. Configure Clerk webhooks for user.created, user.updated, and user.deleted events, then create a webhook endpoint in your Next.js API route that verifies the Svix signature, parses the event payload, and upserts the user record into your PostgreSQL database using Prisma or Drizzle. For initial bulk migration, use Clerk's Backend API getUserList() with pagination to backfill existing users. This guide covers the webhook-based approach that Clerk officially recommends, complete with PostgreSQL schemas, Next.js handlers, and production-ready patterns.

Important

This article was updated March 11, 2026. The updates and changes reflect the major Core 3 release from March 3, 2026 and Clerk's new pricing launched February 5, 2026

AspectRecommendation
Primary sync methodWebhooks (e.g., user.created)
Bulk migrationBackend API with getUserList() pagination
DatabasePostgreSQL with unique index on clerk_id
ORMPrisma (most Next.js adoption) or Drizzle
VerificationClerk's verifyWebhook() helper
IdempotencyDatabase upserts + svix-id tracking
When to avoid syncingApps only needing current user data (use session data instead)

Before you sync: understand the trade-offs

Syncing Clerk user data to your database is not always necessary—and the Clerk team recommends avoiding it when possible. Adding a sync layer introduces infrastructure you must maintain, creates additional points of failure, and means your local database will always be eventually consistent with Clerk (which remains the source of truth).

Consider these alternatives first:

  • Session data: If you only need the currently authenticated user's data, access it directly from the session. This provides strong consistency without any database sync (Clerk Session Management, 2025).
  • User metadata: For small amounts of custom data, store it in Clerk's publicMetadata, privateMetadata, or unsafeMetadata fields instead of maintaining a separate table. Metadata holds up to 8KB per user, though session-token claims should stay under 1.2KB (Clerk User Metadata Guide, 2025).

When syncing makes sense:

  • Your application has social features displaying other users' information (names, avatars, bios)
  • You need to query user data frequently in ways that would exceed Clerk's rate limits (1,000 requests per 10 seconds in production)
  • You're building analytics dashboards or reporting systems that aggregate user data
  • Compliance requirements mandate audit logging of user changes
  • You need to integrate with external systems like CRMs, email platforms, or analytics tools
  • You want reduced latency for user data lookups in performance-critical paths

The primary method to sync Clerk user data to your database is by webhooks.

What are webhooks and how do they work?

A webhook is an event-driven method of communication between applications. Unlike traditional APIs where your application repeatedly polls for changes, webhooks push data to your application only when something actually happens. This makes them efficient for real-time synchronization without the overhead of constant API requests.

Webhooks have reached significant adoption in modern development—50% of development teams now use webhooks, alongside WebSockets (35%) and GraphQL (33%) (Postman, 2025). This widespread adoption reflects their efficiency: only 1.5% of polling requests find an update—meaning 98.5% of polling requests are wasted bandwidth and CPU cycles (Svix, 2025).

When you configure a webhook in Clerk, you're essentially telling Clerk: "When this event occurs, send an HTTP POST request to this URL with the event data." Your application receives the request, processes the payload, and responds with a status code indicating success or failure.

The data flow works like this:

  1. A user updates their profile in your application (or an admin makes changes via the Clerk Dashboard or Backend API)
  2. Clerk detects the change and packages the updated user data into a JSON payload
  3. Clerk sends an HTTP POST request to your configured webhook endpoint
  4. Your webhook handler receives the request, verifies its authenticity, and processes the data
  5. Your code updates your database with the new user information
  6. Your handler returns a 200 status code to confirm successful processing

This event-driven architecture means your database stays synchronized with Clerk without any polling—you receive updates within seconds of changes occurring.

Why webhook signatures matter

Webhooks introduce a security challenge: your endpoint is publicly accessible, which means anyone on the internet could theoretically send fake webhook payloads to your application. Without signature verification, an attacker could inject malicious user data, delete legitimate users, or corrupt your database.

Clerk uses Svix for webhook infrastructure, which signs every webhook payload using HMAC-SHA256. Each request includes three critical headers:

  • svix-id: A unique identifier for the webhook message
  • svix-timestamp: When the webhook was sent (used to prevent replay attacks)
  • svix-signature: The cryptographic signature computed from your secret key and the payload

The signature is computed by concatenating the svix-id, svix-timestamp, and raw request body, then signing this combination with your webhook secret using HMAC-SHA256. This follows the industry-standard pattern established by Stripe's webhook signatures, which includes timestamp validation for replay attack prevention (typically with a 5-minute tolerance window). (Stripe's webhook signatures, 2024)

HMAC-SHA256 has strong cryptographic foundations across three specification layers: IETF RFC 2104 provides the foundational HMAC definition, IETF RFC 4868 specifies HMAC-SHA-256 for IPsec with the note that "a brute force attack on such keys would take longer to mount than the universe has been in existence," and NIST FIPS 198-1 provides the federal standard. (IETF RFC 2104, 1997) (IETF RFC 4868, 2007) (NIST FIPS 198-1, 2008)

Treat your webhook signing secret like a password. Anyone who possesses this secret can craft valid-looking webhook payloads that your webhook endpoint will accept. Store it in environment variables, never commit it to source control, and rotate it if you suspect compromise. In the Clerk Dashboard, you can regenerate your signing secret at any time—just remember to update your environment variables immediately after.

Webhooks are the primary sync mechanism

Clerk uses Svix as its webhook infrastructure, providing reliable event delivery with automatic retries. When you configure webhooks in the Clerk Dashboard, you subscribe to specific events that trigger HTTP POST requests to your endpoint whenever users are created, modified, or deleted.

Svix implements an exponential backoff retry strategy to handle temporary failures gracefully. If your endpoint returns a non-2xx status code or doesn't respond within 15 seconds, Svix automatically retries delivery using an exponential backoff schedule that provides multiple retry attempts over an extended period.

This follows best practices established by major cloud providers. Amazon's Builders' Library documents their internal practices: "When failures are caused by overload or contention, backing off often doesn't help as much as it seems like it should... Our solution is jitter." The AWS Architecture Blog analysis concludes that "the no-jitter exponential backoff approach is the clear loser." The standard formula is: wait_time = min(((2^n)+random_number_milliseconds), maximum_backoff). (Amazon's Builders' Library, 2019) (AWS Architecture Blog, 2015)

The three essential events for user sync are:

EventTriggerDatabase action
user.createdNew registration, Dashboard creation, or Backend APIINSERT new user record
user.updatedProfile changes via any methodUPDATE existing record
user.deletedAccount deletion or admin removalDELETE or soft-delete record

Each webhook payload includes the complete user object with all fields—id, email_addresses, first_name, last_name, image_url, metadata fields, timestamps, and security status flags. The payload also includes svix-id, svix-timestamp, and svix-signature headers for verification.

Unlike the Backend API, webhooks have no rate limits, making them ideal for high-volume applications where syncing every user change matters.

Database schema design for Clerk users

The recommended approach stores only the user data you actually need while maintaining a clear link to Clerk via the clerkId field. Here's a production-ready Prisma schema for PostgreSQL:

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  clerkId   String   @unique  // Clerk user ID (e.g., "user_2NNEqL2nrIRdJ194ndJqAHwEfxC")
  email     String   @unique
  firstName String?
  lastName  String?
  imageUrl  String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Application-specific relations
  posts     Post[]
  comments  Comment[]

  @@index([clerkId])  // Critical for webhook lookups
  @@index([email])
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())

  @@index([authorId])
}

The unique constraint on clerkId serves dual purposes: it enforces data integrity and creates an index for fast lookups during webhook processing. Always index this column—every webhook handler query will use it (Prisma Docs, 2024).

Database indexing is critical for webhook performance. PostgreSQL's official constraints documentation states: "Since a DELETE or UPDATE of a referenced column will require a scan of the referencing table... it is often a good idea to index the referencing columns too." For foreign key relationships, PostgreSQL does not automatically create indexes on referencing columns, making explicit indexing essential for performance.

For teams using Drizzle ORM, the equivalent schema provides the same structure with TypeScript-first ergonomics:

db/schema.ts
import {
  pgTable,
  serial,
  text,
  varchar,
  timestamp,
  integer,
  uniqueIndex,
  index,
} from 'drizzle-orm/pg-core'

export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    clerkId: varchar('clerk_id', { length: 255 }).notNull().unique(),
    email: varchar('email', { length: 255 }).notNull().unique(),
    firstName: text('first_name'),
    lastName: text('last_name'),
    imageUrl: text('image_url'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => [uniqueIndex('clerk_id_idx').on(table.clerkId), index('email_idx').on(table.email)],
)

For flexible user metadata, PostgreSQL's JSONB type works well when you need to store varying attributes without schema migrations:

migrations/add_preferences.sql
ALTER TABLE users ADD COLUMN preferences JSONB DEFAULT '{}';
CREATE INDEX idx_users_preferences ON users USING GIN (preferences);

PostgreSQL's official JSONB documentation clarifies the tradeoff: JSONB is "slightly slower to input due to added conversion overhead, but significantly faster to process, since no reparsing is needed. JSONB also supports indexing." The jsonb_path_ops GIN index operator class is specifically noted as "usually much smaller than a jsonb_ops index over the same data."

However, prefer normalized columns for frequently queried fields—JSONB carries approximately 2x storage overhead and provides less efficient query planning than typed columns.

Building the webhook handler in Next.js

Use Clerk's built-in verifyWebhook() function to handle signature verification automatically. This is the recommended approach—it reads the CLERK_WEBHOOK_SIGNING_SECRET environment variable and validates the incoming request in a single function call:

app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'

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

    if (evt.type === 'user.created') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.upsert({
        where: { clerkId: id },
        update: {}, // No update on create - handles duplicate webhooks
        create: {
          clerkId: id,
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.updated') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.upsert({
        where: { clerkId: id },
        update: {
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
        create: {
          clerkId: id,
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.deleted') {
      const { id } = evt.data
      if (id) {
        await prisma.user
          .delete({
            where: { clerkId: id },
          })
          .catch(() => {}) // Ignore if already deleted
      }
    }

    return new Response('Webhook processed', { status: 200 })
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return new Response('Invalid webhook', { status: 400 })
  }
}

Notice the use of upsert instead of separate create/update operations. This pattern handles duplicate webhook deliveries gracefully—Svix uses at-least-once delivery, meaning you will occasionally receive the same event multiple times (Svix Docs, 2024).

This aligns with distributed systems fundamentals. As explained in the influential technical analysis at Brave New Geek: "There is no such thing as exactly-once delivery. We must choose between the lesser of two evils, which is at-least-once delivery in most cases. This can be used to simulate exactly-once semantics by ensuring idempotency." (Brave New Geek, 2016)

PostgreSQL's INSERT documentation guarantees atomicity: "ON CONFLICT DO UPDATE guarantees an atomic INSERT or UPDATE outcome; provided there is no independent error, one of those two outcomes is guaranteed, even under high concurrency."

The verifyWebhook() function automatically:

  • Reads the raw request body (required for signature verification)
  • Extracts svix-id, svix-timestamp, and svix-signature headers
  • Validates the signature using your CLERK_WEBHOOK_SIGNING_SECRET
  • Returns a typed WebhookEvent object for type-safe payload access

Node.js and Express implementation

For Express applications, import verifyWebhook from Clerk's Express SDK entry point, @clerk/express/webhooks. Unlike the generic backend helper, this build accepts the Express req directly—it reads the Svix headers and the raw body (provided by express.raw()), converts them internally, and returns the same typed WebhookEvent. The handler follows the same upsert-based approach as the Next.js route, using Prisma:

server.ts
import { verifyWebhook } from '@clerk/express/webhooks'
import express from 'express'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()
const app = express()

// Use the raw body parser ONLY for the webhook route
app.post('/api/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const evt = await verifyWebhook(req)

    if (evt.type === 'user.created' || evt.type === 'user.updated') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      const data = {
        email: email_addresses[0]?.email_address ?? '',
        firstName: first_name,
        lastName: last_name,
        imageUrl: image_url,
      }
      await prisma.user.upsert({
        where: { clerkId: id },
        update: data,
        create: { clerkId: id, ...data },
      })
    }

    if (evt.type === 'user.deleted') {
      const { id } = evt.data
      if (id) {
        await prisma.user.delete({ where: { clerkId: id } }).catch(() => {}) // Ignore if already deleted
      }
    }

    return res.json({ success: true })
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return res.status(400).json({ error: 'Invalid signature' })
  }
})

// Apply JSON parsing to other routes AFTER the webhook route
app.use(express.json())

A common mistake is applying express.json() globally before defining the webhook route, which parses the body and breaks verification. Always configure the raw body parser specifically for your webhook endpoint.

This follows Express.js best practices. The official middleware guide emphasizes: Order of middleware loading is critical—executed in order they're added. The body-parser documentation specifies that bodyParser.raw({ type: 'application/json' }) returns req.body as Buffer, which is essential for HMAC computation. (Express.js middleware guide, 2024) (body-parser documentation, 2024)

Webhook security and verification essentials

Svix constructs signatures using HMAC-SHA256 over the concatenation of the svix-id, timestamp, and body. The signature header may contain multiple signatures (for key rotation), any of which validates the payload.

Key security considerations:

  • Use Clerk's verifyWebhook() helper for automatic signature verification
  • Treat the signing secret like a password—anyone with access can forge valid webhooks
  • Return appropriate status codes: 400 for invalid signatures, 200 for success
  • Ensure your server clock is synchronized via NTP for timestamp validation

When verification fails, Svix retries with exponential backoff over an extended period, providing a substantial window for recovery before marking delivery as failed.

Webhook security vulnerabilities are well-documented. GitHub's webhook security documentation emphasizes using webhook secrets to ensure incoming payloads are authentic and haven't been tampered with. As noted in Snyk's webhook security guide, failing to implement proper webhook authentication allows attackers to trigger automations at will by sending malicious payloads to unprotected endpoints. (GitHub's webhook security documentation, 2024) (Snyk's webhook security guide, 2023)

Handling failures and ensuring idempotency

Because webhooks can be delivered multiple times and may arrive out of order, your webhook endpoints must be idempotent—processing the same event twice should produce the same result as processing it once.

This requirement stems from the distributed nature of webhook delivery. Martin Fowler's microservices article addresses the practical implications: "Distributed transactions are notoriously difficult to implement and as a consequence microservice architectures emphasize transactionless coordination between services, with explicit recognition that consistency may only be eventual consistency and problems are dealt with by compensating operations." (Martin Fowler's microservices article, 2014)

Strategy 1: Database upserts (recommended for most cases)

The upsert pattern shown earlier handles duplicates naturally. When user.created arrives twice, the second execution finds the existing record and performs no update.

Strategy 2: Webhook ID tracking (for complex processing)

Track processed webhook IDs in a dedicated table to prevent reprocessing. First, add this model to your Prisma schema:

prisma/schema.prisma
model ProcessedWebhook {
  id          String   @id  // svix-id
  processedAt DateTime @default(now())
  @@map("processed_webhooks")
}

This table stores the unique svix-id of each processed webhook, allowing your handler to skip duplicate deliveries. Here's the implementation:

app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function POST(req: NextRequest) {
  try {
    // Verify and process the webhook
    const evt = await verifyWebhook(req)

    const webhookId = req.headers.get('svix-id')

    if (!webhookId) {
      return new Response('Missing svix-id header', { status: 400 })
    }

    // Check if we've already processed this webhook
    const existing = await prisma.processedWebhook.findUnique({
      where: { id: webhookId },
    })

    if (existing) {
      return new Response('Already processed', { status: 200 })
    }

    // Process different event types
    if (evt.type === 'user.created') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.create({
        data: {
          clerkId: id,
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.updated') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.update({
        where: { clerkId: id },
        data: {
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.deleted') {
      const { id } = evt.data
      if (id) {
        await prisma.user
          .delete({
            where: { clerkId: id },
          })
          .catch(() => {}) // Ignore if already deleted
      }
    }

    // Mark webhook as processed
    await prisma.processedWebhook.create({
      data: { id: webhookId, processedAt: new Date() },
    })

    return new Response('Webhook processed', { status: 200 })
  } catch (err) {
    console.error('Webhook processing failed:', err)
    return new Response('Invalid webhook', { status: 400 })
  }
}

Strategy 3: Queue-based processing (for high reliability)

For production systems with complex webhook processing, acknowledge receipt immediately and process asynchronously:

app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { webhookQueue } from '@/lib/queue'

export async function POST(req: NextRequest) {
  try {
    // Verify webhook signature
    const evt = await verifyWebhook(req)
    const svixId = req.headers.get('svix-id')

    if (!svixId) {
      return new Response('Missing svix-id header', { status: 400 })
    }

    // Acknowledge immediately by queuing for async processing
    await webhookQueue.add(
      'clerk-event',
      {
        eventType: evt.type,
        data: evt.data,
        svixId: svixId,
        timestamp: new Date().toISOString(),
      },
      {
        // Queue options
        attempts: 3,
        backoff: {
          type: 'exponential',
          delay: 2000,
        },
        removeOnComplete: 100, // Keep last 100 completed jobs
        removeOnFail: 50, // Keep last 50 failed jobs
      },
    )

    return new Response('Queued', { status: 200 })
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return new Response('Invalid webhook', { status: 400 })
  }
}

The webhook handler above imports a webhookQueue to enqueue jobs. Define that producer in a shared module. BullMQ—the actively maintained successor to Bull—connects to Redis through a connection option:

lib/queue.ts
import { Queue } from 'bullmq'

// Shared Redis connection. The producer keeps ioredis's default retry behavior,
// so it fails fast if Redis is unreachable.
const connection = {
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
}

// The route imports this queue to add jobs (see the handler above).
export const webhookQueue = new Queue('webhook-processing', { connection })

A separate Worker consumes those jobs and performs the database writes. Workers run in their own process, so you can scale processing independently from your web server—run multiple worker instances to handle high volumes, restart workers without affecting webhook receipt, and deploy processing changes without downtime. The worker de-duplicates on svix-id before writing, which is what makes Svix's at-least-once redelivery safe:

scripts/worker.ts
import { Worker } from 'bullmq'
import { prisma } from '@/lib/prisma'

// A Worker issues blocking Redis commands, so its connection must set
// maxRetriesPerRequest to null to keep processing indefinitely.
const worker = new Worker(
  'webhook-processing',
  async (job) => {
    const { eventType, data, svixId } = job.data

    // Svix delivers at-least-once, so skip events already applied
    const existing = await prisma.processedWebhook.findUnique({
      where: { id: svixId },
    })

    if (existing) {
      console.log(`Webhook ${svixId} already processed, skipping`)
      return
    }

    switch (eventType) {
      case 'user.created':
        await prisma.user.create({
          data: {
            clerkId: data.id,
            email: data.email_addresses[0]?.email_address ?? '',
            firstName: data.first_name,
            lastName: data.last_name,
            imageUrl: data.image_url,
          },
        })
        break

      case 'user.updated':
        await prisma.user.update({
          where: { clerkId: data.id },
          data: {
            email: data.email_addresses[0]?.email_address ?? '',
            firstName: data.first_name,
            lastName: data.last_name,
            imageUrl: data.image_url,
          },
        })
        break

      case 'user.deleted':
        await prisma.user.delete({ where: { clerkId: data.id } }).catch(() => {}) // Ignore if already deleted
        break

      default:
        console.log(`Unhandled event type: ${eventType}`)
        return
    }

    // Record the svix-id last, so any error above leaves the job retryable
    await prisma.processedWebhook.create({
      data: { id: svixId, processedAt: new Date() },
    })
    console.log(`Successfully processed webhook ${svixId} (${eventType})`)
    // Any error thrown above leaves the job unrecorded and marks it failed,
    // triggering BullMQ's retry/backoff (attempts: 3, set when the job is added).
  },
  {
    connection: {
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379'),
      password: process.env.REDIS_PASSWORD,
      maxRetriesPerRequest: null,
    },
  },
)

console.log('Webhook worker started, waiting for jobs...')

// Monitoring — `job` may be undefined in the failed handler
worker.on('completed', (job) => {
  console.log(`Job ${job.id} completed`)
})

worker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message)
})

// Graceful shutdown — close() waits for in-flight jobs, then closes connections
const shutdown = async () => {
  console.log('Closing worker...')
  await worker.close()
  process.exit(0)
}

process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)

This pattern ensures you respond within Svix's 15-second timeout while handling database errors, external API calls, or other slow operations without triggering unnecessary retries.

Event-driven architecture patterns for webhooks

Martin Fowler's analysis distinguishes four event-driven patterns often conflated: Event Notification, Event-Carried State Transfer, Event Sourcing, and CQRS. For webhook synchronization, Event-Carried State Transfer applies: "This pattern shows up when you want to update clients of a system in such a way that they don't need to contact the source system in order to do further work." (Martin Fowler's analysis, 2017)

Clerk's webhook payloads exemplify this pattern—each webhook contains the complete user object, eliminating the need for additional API calls during processing. This reduces coupling between systems and improves resilience to temporary API outages.

Queue-based processing with BullMQ provides production-ready webhook handling. BullMQ documents "exactly once queue semantics"—it "attempts to deliver every message exactly one time, but it will deliver at least once in the worst case scenario," which is precisely why the worker de-duplicates on svix-id. It also provides automatic retry of failed jobs, priorities, delayed jobs, and per-worker concurrency. For a worker's Redis connection, set maxRetriesPerRequest: null so its blocking commands don't interrupt processing. (BullMQ documentation, 2026)

The separation between webhook receipt and processing follows the AWS Well-Architected principle of loose coupling: "Event-driven architectures use events to trigger and communicate between decoupled services and are common in modern applications built with microservices." (AWS Well-Architected, 2024)

Conclusion

With the webhook infrastructure and schemas established, your application is ready to receive real-time updates from Clerk. However, handling existing users and monitoring this infrastructure requires additional considerations.

In Part 2, we cover bulk data migration using the Backend API, privacy and GDPR considerations, handling alternative databases, and production monitoring for webhook handlers.

Frequently asked questions

In this series

  1. How to sync Clerk user data to your database (you are here)
  2. How to sync Clerk user data to your database - Part 2