diff --git a/skills/clerk-webhooks/SKILL.md b/skills/clerk-webhooks/SKILL.md index 04e5820..2e487a2 100644 --- a/skills/clerk-webhooks/SKILL.md +++ b/skills/clerk-webhooks/SKILL.md @@ -24,88 +24,49 @@ metadata: ### Express Webhook Handler +Clerk uses the [Standard Webhooks](https://www.standardwebhooks.com/) protocol (Clerk sends `svix-*` headers; same format). Use the `standardwebhooks` npm package: + ```javascript const express = require('express'); -const crypto = require('crypto'); +const { Webhook } = require('standardwebhooks'); const app = express(); -// CRITICAL: Use express.raw() for webhook endpoint - Clerk needs raw body +// CRITICAL: Use express.raw() for webhook endpoint - verification needs raw body app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => { - // Get Svix headers + const secret = process.env.CLERK_WEBHOOK_SECRET || process.env.CLERK_WEBHOOK_SIGNING_SECRET; + if (!secret || !secret.startsWith('whsec_')) { + return res.status(500).json({ error: 'Server configuration error' }); + } const svixId = req.headers['svix-id']; const svixTimestamp = req.headers['svix-timestamp']; const svixSignature = req.headers['svix-signature']; - - // Verify we have required headers if (!svixId || !svixTimestamp || !svixSignature) { - return res.status(400).json({ error: 'Missing required Svix headers' }); + return res.status(400).json({ error: 'Missing required webhook headers' }); } - - // Manual signature verification (recommended approach) - const secret = process.env.CLERK_WEBHOOK_SECRET; // whsec_xxxxx from Clerk dashboard - const signedContent = `${svixId}.${svixTimestamp}.${req.body}`; - + // standardwebhooks expects webhook-* header names; Clerk sends svix-* (same protocol) + const headers = { + 'webhook-id': svixId, + 'webhook-timestamp': svixTimestamp, + 'webhook-signature': svixSignature + }; try { - // Extract base64 secret after 'whsec_' prefix - const secretBytes = Buffer.from(secret.split('_')[1], 'base64'); - const expectedSignature = crypto - .createHmac('sha256', secretBytes) - .update(signedContent) - .digest('base64'); - - // Svix can send multiple signatures, check each one - const signatures = svixSignature.split(' ').map(sig => sig.split(',')[1]); - const isValid = signatures.some(sig => { - try { - return crypto.timingSafeEqual( - Buffer.from(sig), - Buffer.from(expectedSignature) - ); - } catch { - return false; // Different lengths = invalid - } - }); - - if (!isValid) { - return res.status(400).json({ error: 'Invalid signature' }); - } - - // Check timestamp to prevent replay attacks (5-minute window) - const timestamp = parseInt(svixTimestamp, 10); - const currentTime = Math.floor(Date.now() / 1000); - if (currentTime - timestamp > 300) { - return res.status(400).json({ error: 'Timestamp too old' }); + const wh = new Webhook(secret); + const event = wh.verify(req.body, headers); + if (!event) return res.status(400).json({ error: 'Invalid payload' }); + switch (event.type) { + case 'user.created': console.log('User created:', event.data.id); break; + case 'user.updated': console.log('User updated:', event.data.id); break; + case 'session.created': console.log('Session created:', event.data.user_id); break; + case 'organization.created': console.log('Organization created:', event.data.id); break; + default: console.log('Unhandled:', event.type); } + res.status(200).json({ success: true }); } catch (err) { - console.error('Signature verification error:', err); - return res.status(400).json({ error: 'Invalid signature' }); + res.status(400).json({ error: err.name === 'WebhookVerificationError' ? err.message : 'Webhook verification failed' }); } - - // Parse the verified webhook body - const event = JSON.parse(req.body.toString()); - - // Handle the event - switch (event.type) { - case 'user.created': - console.log('User created:', event.data.id); - break; - case 'user.updated': - console.log('User updated:', event.data.id); - break; - case 'session.created': - console.log('Session created:', event.data.user_id); - break; - case 'organization.created': - console.log('Organization created:', event.data.id); - break; - default: - console.log('Unhandled event:', event.type); - } - - res.status(200).json({ success: true }); } ); ``` @@ -176,15 +137,22 @@ async def clerk_webhook(request: Request): | `organization.created` | New organization created | | `organization.updated` | Organization settings updated | | `organizationMembership.created` | User added to organization | +| `organizationInvitation.created` | Invite sent to join organization | -> **For full event reference**, see [Clerk Webhook Events](https://clerk.com/docs/integrations/webhooks/overview#event-types) +> **For full event reference**, see [Clerk Webhook Events](https://clerk.com/docs/integrations/webhooks/overview#event-types) and [Dashboard → Webhooks → Event Catalog](https://dashboard.clerk.com/~/webhooks). ## Environment Variables ```bash -CLERK_WEBHOOK_SECRET=whsec_xxxxx # From webhook endpoint settings in Clerk Dashboard +# Official name (used by @clerk/nextjs and Clerk docs) +CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxx + +# Alternative name (used in this skill's examples) +CLERK_WEBHOOK_SECRET=whsec_xxxxx ``` +From Clerk Dashboard → Webhooks → your endpoint → Signing Secret. + ## Local Development ```bash @@ -195,11 +163,14 @@ brew install hookdeck/hookdeck/hookdeck hookdeck listen 3000 --path /webhooks/clerk ``` +Use the tunnel URL in Clerk Dashboard when adding your endpoint. For production, set your live URL and copy the signing secret to production env vars. + ## Reference Materials - [references/overview.md](references/overview.md) - Clerk webhook concepts - [references/setup.md](references/setup.md) - Dashboard configuration - [references/verification.md](references/verification.md) - Signature verification details +- [references/patterns.md](references/patterns.md) - Quick start, when to sync, key patterns, common pitfalls ## Attribution diff --git a/skills/clerk-webhooks/examples/express/README.md b/skills/clerk-webhooks/examples/express/README.md index 2b091bd..72a289b 100644 --- a/skills/clerk-webhooks/examples/express/README.md +++ b/skills/clerk-webhooks/examples/express/README.md @@ -1,6 +1,6 @@ # Clerk Webhooks - Express Example -Minimal example of receiving Clerk webhooks with signature verification. +Minimal example of receiving Clerk webhooks with signature verification using the [standardwebhooks](https://www.npmjs.com/package/standardwebhooks) package (Standard Webhooks protocol). ## Prerequisites diff --git a/skills/clerk-webhooks/examples/express/package.json b/skills/clerk-webhooks/examples/express/package.json index 4e4071c..35ebb2d 100644 --- a/skills/clerk-webhooks/examples/express/package.json +++ b/skills/clerk-webhooks/examples/express/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "express": "^5.2.1", - "dotenv": "^16.0.3" + "dotenv": "^16.0.3", + "standardwebhooks": "^1.0.0" }, "devDependencies": { "jest": "^29.0.0", diff --git a/skills/clerk-webhooks/examples/express/src/index.js b/skills/clerk-webhooks/examples/express/src/index.js index cbc7bb3..f1a8648 100644 --- a/skills/clerk-webhooks/examples/express/src/index.js +++ b/skills/clerk-webhooks/examples/express/src/index.js @@ -3,7 +3,7 @@ require('dotenv').config(); const express = require('express'); -const crypto = require('crypto'); +const { Webhook } = require('standardwebhooks'); const app = express(); const PORT = process.env.PORT || 3000; @@ -14,24 +14,12 @@ app.get('/health', (req, res) => { }); // Clerk webhook endpoint +// Clerk uses Standard Webhooks (same as Svix); we verify with the standardwebhooks package. // IMPORTANT: Use express.raw() to get raw body for signature verification app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => { - // Get Svix headers - const svixId = req.headers['svix-id']; - const svixTimestamp = req.headers['svix-timestamp']; - const svixSignature = req.headers['svix-signature']; - - // Verify required headers are present - if (!svixId || !svixTimestamp || !svixSignature) { - return res.status(400).json({ - error: 'Missing required Svix headers' - }); - } - - // Verify signature - const secret = process.env.CLERK_WEBHOOK_SECRET; + const secret = process.env.CLERK_WEBHOOK_SECRET || process.env.CLERK_WEBHOOK_SIGNING_SECRET; if (!secret || !secret.startsWith('whsec_')) { console.error('Invalid webhook secret configuration'); return res.status(500).json({ @@ -39,57 +27,29 @@ app.post('/webhooks/clerk', }); } - try { - // Construct the signed content - const signedContent = `${svixId}.${svixTimestamp}.${req.body}`; - - // Extract the base64 secret (everything after 'whsec_') - const secretBytes = Buffer.from(secret.split('_')[1], 'base64'); - - // Calculate expected signature - const expectedSignature = crypto - .createHmac('sha256', secretBytes) - .update(signedContent) - .digest('base64'); - - // Svix can send multiple signatures separated by spaces - // Each signature is in format "v1,actualSignature" - const signatures = svixSignature.split(' ').map(sig => sig.split(',')[1]); - - // Use timing-safe comparison - const isValid = signatures.some(sig => { - try { - return crypto.timingSafeEqual( - Buffer.from(sig), - Buffer.from(expectedSignature) - ); - } catch (err) { - // timingSafeEqual throws if buffers are different lengths - return false; - } + // Clerk/Svix send svix-* headers; standardwebhooks expects webhook-* (same protocol) + const svixId = req.headers['svix-id']; + const svixTimestamp = req.headers['svix-timestamp']; + const svixSignature = req.headers['svix-signature']; + if (!svixId || !svixTimestamp || !svixSignature) { + return res.status(400).json({ + error: 'Missing required webhook headers (svix-id, svix-timestamp, svix-signature)' }); + } - if (!isValid) { - return res.status(400).json({ - error: 'Invalid signature' - }); - } - - // Check timestamp to prevent replay attacks (5 minute window) - const timestamp = parseInt(svixTimestamp, 10); - const currentTime = Math.floor(Date.now() / 1000); - const fiveMinutes = 5 * 60; + const headers = { + 'webhook-id': svixId, + 'webhook-timestamp': svixTimestamp, + 'webhook-signature': svixSignature + }; - if (currentTime - timestamp > fiveMinutes) { - return res.status(400).json({ - error: 'Timestamp too old' - }); + try { + const wh = new Webhook(secret); + const event = wh.verify(req.body, headers); + if (!event) { + return res.status(400).json({ error: 'Invalid webhook payload' }); } - // Parse the verified event - const event = JSON.parse(req.body.toString()); - - // Handle different event types console.log(`Received Clerk webhook: ${event.type}`); switch (event.type) { @@ -98,62 +58,45 @@ app.post('/webhooks/clerk', userId: event.data.id, email: event.data.email_addresses?.[0]?.email_address }); - // TODO: Add your user creation logic here break; - case 'user.updated': - console.log('User updated:', { - userId: event.data.id - }); - // TODO: Add your user update logic here + console.log('User updated:', { userId: event.data.id }); break; - case 'user.deleted': - console.log('User deleted:', { - userId: event.data.id - }); - // TODO: Add your user deletion logic here + console.log('User deleted:', { userId: event.data.id }); break; - case 'session.created': console.log('Session created:', { sessionId: event.data.id, userId: event.data.user_id }); - // TODO: Add your session creation logic here break; - case 'session.ended': console.log('Session ended:', { sessionId: event.data.id, userId: event.data.user_id }); - // TODO: Add your session end logic here break; - case 'organization.created': console.log('Organization created:', { orgId: event.data.id, name: event.data.name }); - // TODO: Add your organization creation logic here break; - default: console.log('Unhandled event type:', event.type); } - // Return success response res.status(200).json({ success: true, type: event.type }); - } catch (err) { - console.error('Webhook processing error:', err); - res.status(400).json({ - error: 'Invalid webhook payload' - }); + console.error('Webhook verification failed:', err.message || err); + const message = err.name === 'WebhookVerificationError' + ? (err.message === 'Message timestamp too old' ? 'Timestamp too old' : err.message === 'No matching signature found' ? 'Invalid signature' : err.message) + : 'Webhook verification failed'; + res.status(400).json({ error: message }); } } ); @@ -164,5 +107,4 @@ const server = app.listen(PORT, () => { console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/clerk`); }); -// For testing -module.exports = { app, server }; \ No newline at end of file +module.exports = { app, server }; diff --git a/skills/clerk-webhooks/examples/express/test/webhook.test.js b/skills/clerk-webhooks/examples/express/test/webhook.test.js index a9c7c84..4081ac5 100644 --- a/skills/clerk-webhooks/examples/express/test/webhook.test.js +++ b/skills/clerk-webhooks/examples/express/test/webhook.test.js @@ -101,7 +101,7 @@ describe('Clerk Webhook Handler', () => { .send(payload); expect(response.status).toBe(400); - expect(response.body.error).toBe('Missing required Svix headers'); + expect(response.body.error).toContain('Missing required'); }); test('rejects invalid signature', async () => { diff --git a/skills/clerk-webhooks/examples/nextjs/README.md b/skills/clerk-webhooks/examples/nextjs/README.md index 0b98c28..268e21f 100644 --- a/skills/clerk-webhooks/examples/nextjs/README.md +++ b/skills/clerk-webhooks/examples/nextjs/README.md @@ -1,6 +1,6 @@ # Clerk Webhooks - Next.js Example -Next.js App Router example for receiving Clerk webhooks with signature verification. +Next.js App Router example for receiving Clerk webhooks using the Clerk SDK (`verifyWebhook` from `@clerk/backend/webhooks`). ## Prerequisites diff --git a/skills/clerk-webhooks/examples/nextjs/app/webhooks/clerk/route.ts b/skills/clerk-webhooks/examples/nextjs/app/webhooks/clerk/route.ts index e768169..2dc3135 100644 --- a/skills/clerk-webhooks/examples/nextjs/app/webhooks/clerk/route.ts +++ b/skills/clerk-webhooks/examples/nextjs/app/webhooks/clerk/route.ts @@ -1,156 +1,70 @@ // Generated with: clerk-webhooks skill // https://github.com/hookdeck/webhook-skills -import { NextRequest, NextResponse } from 'next/server'; -import crypto from 'crypto'; +import { NextResponse } from 'next/server'; +import { verifyWebhook } from '@clerk/backend/webhooks'; -// Disable Next.js body parsing to access raw body export const dynamic = 'force-dynamic'; -export async function POST(request: NextRequest) { - // Get Svix headers - const svixId = request.headers.get('svix-id'); - const svixTimestamp = request.headers.get('svix-timestamp'); - const svixSignature = request.headers.get('svix-signature'); - - // Verify required headers are present - if (!svixId || !svixTimestamp || !svixSignature) { - return NextResponse.json( - { error: 'Missing required Svix headers' }, - { status: 400 } - ); - } - - // Get raw body as text - const body = await request.text(); - - // Verify signature - const secret = process.env.CLERK_WEBHOOK_SECRET; - if (!secret || !secret.startsWith('whsec_')) { - console.error('Invalid webhook secret configuration'); - return NextResponse.json( - { error: 'Server configuration error' }, - { status: 500 } - ); - } - +export async function POST(request: Request) { try { - // Construct the signed content - const signedContent = `${svixId}.${svixTimestamp}.${body}`; - - // Extract the base64 secret (everything after 'whsec_') - const secretBytes = Buffer.from(secret.split('_')[1], 'base64'); - - // Calculate expected signature - const expectedSignature = crypto - .createHmac('sha256', secretBytes) - .update(signedContent) - .digest('base64'); - - // Svix can send multiple signatures separated by spaces - // Each signature is in format "v1,actualSignature" - const signatures = svixSignature.split(' ').map(sig => sig.split(',')[1]); - - // Use timing-safe comparison - const isValid = signatures.some(sig => { - try { - return crypto.timingSafeEqual( - Buffer.from(sig), - Buffer.from(expectedSignature) - ); - } catch { - // timingSafeEqual throws if buffers are different lengths - return false; - } + const evt = await verifyWebhook(request, { + signingSecret: process.env.CLERK_WEBHOOK_SIGNING_SECRET || process.env.CLERK_WEBHOOK_SECRET }); + const event = evt as { type: string; data: Record }; - if (!isValid) { - return NextResponse.json( - { error: 'Invalid signature' }, - { status: 400 } - ); - } - - // Check timestamp to prevent replay attacks (5 minute window) - const timestamp = parseInt(svixTimestamp, 10); - const currentTime = Math.floor(Date.now() / 1000); - const fiveMinutes = 5 * 60; - - if (currentTime - timestamp > fiveMinutes) { - return NextResponse.json( - { error: 'Timestamp too old' }, - { status: 400 } - ); - } - - // Parse the verified event - const event = JSON.parse(body); - - // Handle different event types console.log(`Received Clerk webhook: ${event.type}`); switch (event.type) { case 'user.created': console.log('New user created:', { userId: event.data.id, - email: event.data.email_addresses?.[0]?.email_address + email: (event.data as { email_addresses?: Array<{ email_address: string }> }).email_addresses?.[0]?.email_address }); - // TODO: Add your user creation logic here break; - case 'user.updated': - console.log('User updated:', { - userId: event.data.id - }); - // TODO: Add your user update logic here + console.log('User updated:', { userId: event.data.id }); break; - case 'user.deleted': - console.log('User deleted:', { - userId: event.data.id - }); - // TODO: Add your user deletion logic here + console.log('User deleted:', { userId: event.data.id }); break; - case 'session.created': console.log('Session created:', { sessionId: event.data.id, - userId: event.data.user_id + userId: (event.data as { user_id: string }).user_id }); - // TODO: Add your session creation logic here break; - case 'session.ended': - console.log('Session ended:', { + console.log('Session ended', { sessionId: event.data.id, - userId: event.data.user_id + userId: (event.data as { user_id: string }).user_id }); - // TODO: Add your session end logic here break; - case 'organization.created': console.log('Organization created:', { orgId: event.data.id, - name: event.data.name + name: (event.data as { name: string }).name }); - // TODO: Add your organization creation logic here break; - default: console.log('Unhandled event type:', event.type); } - // Return success response return NextResponse.json( { success: true, type: event.type }, { status: 200 } ); - } catch (err) { - console.error('Webhook processing error:', err); + const message = err instanceof Error ? err.message : 'Webhook verification failed'; + let errorResponse = 'Webhook verification failed'; + if (message.includes('Missing required Svix headers')) errorResponse = 'Missing required Svix headers'; + else if (message === 'No matching signature found') errorResponse = 'Invalid signature'; + else if (message === 'Message timestamp too old') errorResponse = 'Timestamp too old'; + else errorResponse = message; + console.error('Webhook verification failed:', err); return NextResponse.json( - { error: 'Invalid webhook payload' }, + { error: errorResponse }, { status: 400 } ); } -} \ No newline at end of file +} diff --git a/skills/clerk-webhooks/examples/nextjs/package.json b/skills/clerk-webhooks/examples/nextjs/package.json index 3ea871d..c4ea100 100644 --- a/skills/clerk-webhooks/examples/nextjs/package.json +++ b/skills/clerk-webhooks/examples/nextjs/package.json @@ -9,9 +9,11 @@ "test": "vitest run" }, "dependencies": { + "@clerk/backend": "^1.21.0", "next": "^16.1.6", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "svix": "^1.84.1" }, "devDependencies": { "@types/node": "^20.0.0", @@ -22,4 +24,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/skills/clerk-webhooks/examples/nextjs/test/webhook.test.ts b/skills/clerk-webhooks/examples/nextjs/test/webhook.test.ts index 9bbb29e..807995f 100644 --- a/skills/clerk-webhooks/examples/nextjs/test/webhook.test.ts +++ b/skills/clerk-webhooks/examples/nextjs/test/webhook.test.ts @@ -21,6 +21,7 @@ import { NextRequest } from 'next/server'; describe('Clerk Webhook Handler', () => { beforeAll(() => { + process.env.CLERK_WEBHOOK_SIGNING_SECRET = TEST_SECRET; process.env.CLERK_WEBHOOK_SECRET = TEST_SECRET; }); diff --git a/skills/clerk-webhooks/references/patterns.md b/skills/clerk-webhooks/references/patterns.md new file mode 100644 index 0000000..29b085c --- /dev/null +++ b/skills/clerk-webhooks/references/patterns.md @@ -0,0 +1,88 @@ +# Clerk Webhook Patterns and Pitfalls + +> **Prerequisite**: Webhooks are asynchronous. Use for background tasks (sync, notifications), not synchronous flows. + +## Official Documentation + +| Task | Link | +|------|------| +| Overview | https://clerk.com/docs/guides/development/webhooks/overview | +| Sync to database | https://clerk.com/docs/guides/development/webhooks/syncing | +| Debugging | https://clerk.com/docs/guides/development/webhooks/debugging | +| Event catalog | https://dashboard.clerk.com/~/webhooks (Event Catalog tab) | + +## Quick Start (Next.js) + +1. Create endpoint at `app/api/webhooks/clerk/route.ts` (or `app/webhooks/clerk/route.ts`) +2. Use `verifyWebhook(req)` from `@clerk/backend/webhooks` (Next.js), or the `standardwebhooks` package (Express) — see [verification.md](verification.md) +3. Dashboard → Webhooks → Add Endpoint +4. Set `CLERK_WEBHOOK_SIGNING_SECRET` in env (or `CLERK_WEBHOOK_SECRET`) +5. Make route public (ensure `clerkMiddleware()` does not protect your webhook path) + +For Express and FastAPI, see [SKILL.md](../SKILL.md) Essential Code and the [examples/](../examples/) directory. + +## When to Sync + +**Do sync when:** + +- Need other users' data (social features, profiles) +- Storing extra custom fields (birthday, country, bio) +- Building notifications or integrations + +**Don't sync when:** + +- Only need current user data (use session token) +- No custom fields (Clerk has everything) +- Need immediate access (webhooks are eventual consistency) + +## Key Patterns + +### Make Route Public + +Webhooks are sent unsigned by Clerk; your route must be public. Ensure `clerkMiddleware()` (or similar) does not protect `/api/webhooks/*` or `/webhooks/clerk`. + +### Verify Webhook + +- **Next.js**: Use `verifyWebhook(req)` from `@clerk/backend/webhooks` and pass the request directly. +- **Express**: Use the `standardwebhooks` npm package (Clerk uses Standard Webhooks; map `svix-*` headers to `webhook-*` when calling `wh.verify()`). See [verification.md](verification.md). +- **FastAPI**: Use manual verification (see [verification.md](verification.md)) or a Standard Webhooks library; you need the raw body. + +### Type-Safe Events + +Narrow to a specific event so TypeScript knows the payload shape: + +```typescript +if (evt.type === 'user.created') { + // evt.data is typed for user.created +} +``` + +### Handle All Three User Events + +Don't only listen to `user.created`. Also handle `user.updated` and `user.deleted` so updates and deletions are reflected in your database. + +### Queue Async Work + +Return 200 quickly; queue long operations: + +```typescript +await queue.enqueue('process-webhook', evt); +return new Response('Received', { status: 200 }); +``` + +## Webhook Reliability + +**Retries**: Svix retries failed webhooks for up to 3 days. Return 2xx to acknowledge success; 4xx/5xx triggers a retry. + +**Replay**: Failed webhooks can be replayed from the Clerk Dashboard. + +## Common Pitfalls + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Verification fails | Wrong import or usage | Use `@clerk/nextjs/webhooks` and pass `req` directly (Next.js), or use raw body + manual verification | +| Route not found (404) | Wrong path | Use `/api/webhooks/clerk` or `/webhooks/clerk` consistently | +| Not authorized (401) | Route is protected | Make webhook route public (exclude from auth middleware) | +| No data in DB | Async job pending | Wait or check logs; ensure you return 200 before heavy work | +| Duplicate entries | Only handling `user.created` | Also handle `user.updated` and `user.deleted` | +| Timeouts | Handler too slow | Return 200 immediately and queue async work | diff --git a/skills/clerk-webhooks/references/setup.md b/skills/clerk-webhooks/references/setup.md index 0202888..73a72ab 100644 --- a/skills/clerk-webhooks/references/setup.md +++ b/skills/clerk-webhooks/references/setup.md @@ -60,10 +60,15 @@ After setting up your endpoint: Add to your `.env` file: ```bash -# From Clerk Dashboard > Webhooks > Your Endpoint +# Official name (used by @clerk/nextjs and Clerk docs) +CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Alternative (used in some examples) CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` +From Clerk Dashboard → Webhooks → your endpoint → Signing Secret. + ## Production Considerations ### Security diff --git a/skills/clerk-webhooks/references/verification.md b/skills/clerk-webhooks/references/verification.md index e15c8b5..453dbbf 100644 --- a/skills/clerk-webhooks/references/verification.md +++ b/skills/clerk-webhooks/references/verification.md @@ -1,5 +1,32 @@ # Clerk Signature Verification +## Next.js: Clerk SDK + +For Next.js App Router, use `verifyWebhook(request)` from `@clerk/backend/webhooks`. Pass the request directly; it uses `CLERK_WEBHOOK_SIGNING_SECRET`. See [Clerk webhook docs](https://clerk.com/docs/guides/development/webhooks/overview). + +## Express: standardwebhooks package + +Clerk uses the [Standard Webhooks](https://www.standardwebhooks.com/) protocol (Clerk sends `svix-id`, `svix-timestamp`, `svix-signature`; same format). Use the `standardwebhooks` npm package (not under the svix namespace): + +```bash +npm install standardwebhooks +``` + +```javascript +const { Webhook } = require('standardwebhooks'); + +// Clerk sends svix-* headers; standardwebhooks expects webhook-* (same protocol) +const headers = { + 'webhook-id': req.headers['svix-id'], + 'webhook-timestamp': req.headers['svix-timestamp'], + 'webhook-signature': req.headers['svix-signature'] +}; +const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET); // accepts whsec_... format +const event = wh.verify(req.body, headers); // throws on invalid +``` + +The manual implementation below works for FastAPI or when you need full control without adding a dependency. + ## How It Works Clerk uses Svix to sign webhooks with HMAC-SHA256. Each webhook request includes three headers that work together to ensure authenticity and prevent replay attacks: @@ -168,54 +195,9 @@ def verify_clerk_webhook(body: bytes, headers: dict) -> dict: return json.loads(body) ``` -## Using Svix Libraries (Alternative) - -Instead of manual verification, you can use Svix libraries: - -### Node.js - -```bash -npm install svix -``` - -```javascript -const { Webhook } = require('svix'); - -const webhook = new Webhook(process.env.CLERK_WEBHOOK_SECRET); - -try { - const event = webhook.verify(req.body, { - 'svix-id': req.headers['svix-id'], - 'svix-timestamp': req.headers['svix-timestamp'], - 'svix-signature': req.headers['svix-signature'] - }); - // event is verified -} catch (err) { - // Invalid signature -} -``` +## Standard Webhooks (Express / Node) -### Python - -```bash -pip install svix -``` - -```python -from svix import Webhook - -webhook = Webhook(os.environ['CLERK_WEBHOOK_SECRET']) - -try: - event = webhook.verify(body, { - 'svix-id': headers['svix-id'], - 'svix-timestamp': headers['svix-timestamp'], - 'svix-signature': headers['svix-signature'] - }) - # event is verified -except Exception: - # Invalid signature -``` +For Node/Express, use the [standardwebhooks](https://www.npmjs.com/package/standardwebhooks) package (Standard Webhooks spec; not published under the svix npm namespace). Clerk’s webhooks use the same protocol; map `svix-*` headers to `webhook-*` as shown in the Express section above. ## Common Gotchas