Clerk webhooks on Next.js App Router: verifyWebhook(req), public route, 200 only after handling
Import verifyWebhook from @clerk/nextjs/webhooks, keep /api/webhooks out of every auth check, and return 200 after your side effects, not before. A 400 means Clerk retries.
TL;DR: Import verifyWebhook from @clerk/nextjs/webhooks, keep /api/webhooks out of every auth check, and return 200 after your side effects, not before. A 400 means Clerk retries. Keep the route public. Incoming webhooks are never signed in; they come from Clerk's servers.
App Router webhook handler
The handler
import { verifyWebhook } from '@clerk/nextjs/webhooks'
export async function POST(req: Request) {
try {
const evt = await verifyWebhook(req)
const { id } = evt.data
console.log('got webhook', id, evt.type)
// write to your DB here, then:
return new Response('Webhook received', { status: 200 })
} catch (err) {
console.error('webhook verification failed', err)
return new Response('Error verifying webhook', { status: 400 })
}
}The three rules
- Keep the route public. Incoming webhooks are never signed in; they come from Clerk's servers. If your middleware or any auth check gates
/api/webhooks, delivery fails.clerkMiddleware()protects nothing by default, so the failure mode is always an auth check you added yourself. Exclude the route explicitly. - Do not parse the body first.
verifyWebhook(req)needs the raw request. Callingawait req.json()before it breaks the Svix signature. - Return 200 after handling. Clerk retries on 4xx/5xx or no response. Return 200 only after your DB write succeeds; a 200 before the write means a crashed write loses the event silently.
Checklist
- Signing secret value
CLERK_WEBHOOK_SIGNING_SECRETin env, copied from the endpoint's settings page in the Dashboard. Dev and prod endpoints have different secrets. - Test from the Dashboard: Webhooks page, endpoint settings, Testing tab, Send Example for
user.created, then check Message Attempts for Succeeded. - Local dev needs a tunnel (ngrok or similar); Clerk cannot reach YOUR_HOST directly.
- Narrow
evt.type(e.g.user.created) for typedevt.data. Handleuser.updatedanduser.deletedtoo if you mirror users; syncing is eventually consistent, so design for out-of-order delivery.
When to use this
- This covers exactly what the title says: Clerk webhooks on Next.js App Router.
- You are setting this up for the first time, or auditing an existing setup.
- You want the key gotchas in one place before you start.
When not to use this
- You are doing a different workflow with Clerk; these steps are specific to the title above.
- You need the full reference docs; this is the short path, not the manual.
Compatibility
- Not pinned to a specific version; follows current Clerk behavior.