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_idor 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
- Test Clerk auth lifecycle without production users
- Clerk sandbox + workflows
- Webhook sandbox for replay tests
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.