Skip to main content

Clerk

Clerk webhook replay can duplicate users in your app state

Replayed user.created events can duplicate local user rows unless your handler upserts by Clerk user ID and idempotency-keys side effects.

FetchSandbox EngineeringFetchSandbox Engineering

The login worked. The webhook worked. Then the same event arrived again.

Clerk webhooks are easy to treat like a notification: user.created arrives, you insert a local user, done.

That works until the event is retried, replayed, or delivered after another part of your app already created the user.

Then your app has two local rows for the same Clerk user. Or onboarding runs twice and sends the same welcome email twice.

The bug is not unreliable Clerk delivery. Retrying webhooks is normal.

The bug is trusting delivery count instead of provider state.

The narrow fix

Your handler should not mean:

on user.created -> insert user

It should mean:

on user.created -> upsert by clerk_user_id

The stable field is the Clerk user ID, not the email address and not your local row ID.

await db.user.upsert({
  where: { clerkUserId: event.data.id },
  update: {
    email: event.data.email_addresses?.[0]?.email_address,
    firstName: event.data.first_name,
    lastName: event.data.last_name,
    clerkUpdatedAt: new Date(event.data.updated_at),
  },
  create: {
    clerkUserId: event.data.id,
    email: event.data.email_addresses?.[0]?.email_address,
    firstName: event.data.first_name,
    lastName: event.data.last_name,
    clerkUpdatedAt: new Date(event.data.updated_at),
  },
});

Add a database constraint:

UNIQUE(clerk_user_id)

A replay updates the same row instead of creating a second one.

Side effects need their own idempotency keys

The user upsert is only one piece of local state.

If your handler also creates a workspace, starts a trial, or sends email, those side effects need dedupe keys too:

  • workspace owner keyed by clerk_user_id
  • welcome email keyed by clerk_event_id or Svix message ID
  • trial grant keyed by clerk_user_id + plan_id

Otherwise the user row stays correct while everything else duplicates on replay.

The test most handlers skip

Deliver the same user.created payload twice.

First delivery:

  • local user count: 0 → 1
  • workspace count: 0 → 1

Replay:

  • local user count: 1 → 1
  • workspace count: 1 → 1

That is the whole test. Most unit tests only parse one payload.

Related reads

Takeaway

Write Clerk webhook handlers as reconciliation:

given this Clerk user ID, what should my app state be now?

If the answer changes when the same event arrives twice, the bug is already there. Production is just waiting to replay it.