The login worked. The webhook worked. Then the same event arrived again.
Clerk webhooks are easy to treat like a notification:
user.created arrived
create 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 one row has the right email and the other has the right metadata. Or your onboarding job runs twice and sends the same welcome email twice.
The bug is not "Clerk webhooks are unreliable." Retrying delivery is normal webhook behavior.
The bug is trusting delivery count instead of provider state.
The narrow fix
Your webhook handler should not mean:
on user.created -> insert user
It should mean:
on user.created -> upsert by clerk_user_id
The important field is the stable provider ID, not the local row ID and not the email address.
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),
},
});
That one constraint does most of the work:
UNIQUE(clerk_user_id)
Now a replay updates the same user instead of creating a second one.
The part people skip
The handler can still be wrong even after the upsert.
If your app grants access, creates a workspace, starts a trial, or sends email inside the same handler, those side effects need their own idempotency rule too.
For example:
workspace owner = clerk_user_id
welcome email key = clerk_event_id
trial grant key = clerk_user_id + plan_id
The user row is only one piece of local state.
How I would test it
I would test the same user.created event 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.
FetchSandbox has a Clerk sandbox and runnable Clerk workflow docs for this exact kind of replay check. It is also where a stateful sandbox is more useful than a static mock response: the second delivery is the thing you need to observe.
Takeaway
Do not write Clerk webhook handlers as if events happen once.
Write them 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.
Top comments (0)