Skip to content

Commit 4d37059

Browse files
authored
Merge pull request #34 from hookdeck/chore/clerk-webhook-update
clerk-webhooks: align with Clerk skill, SDKs, standardwebhooks, Hookdeck
2 parents 2d5c9da + 640647e commit 4d37059

12 files changed

Lines changed: 222 additions & 316 deletions

File tree

skills/clerk-webhooks/SKILL.md

Lines changed: 38 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -24,88 +24,49 @@ metadata:
2424

2525
### Express Webhook Handler
2626

27+
Clerk uses the [Standard Webhooks](https://www.standardwebhooks.com/) protocol (Clerk sends `svix-*` headers; same format). Use the `standardwebhooks` npm package:
28+
2729
```javascript
2830
const express = require('express');
29-
const crypto = require('crypto');
31+
const { Webhook } = require('standardwebhooks');
3032

3133
const app = express();
3234

33-
// CRITICAL: Use express.raw() for webhook endpoint - Clerk needs raw body
35+
// CRITICAL: Use express.raw() for webhook endpoint - verification needs raw body
3436
app.post('/webhooks/clerk',
3537
express.raw({ type: 'application/json' }),
3638
async (req, res) => {
37-
// Get Svix headers
39+
const secret = process.env.CLERK_WEBHOOK_SECRET || process.env.CLERK_WEBHOOK_SIGNING_SECRET;
40+
if (!secret || !secret.startsWith('whsec_')) {
41+
return res.status(500).json({ error: 'Server configuration error' });
42+
}
3843
const svixId = req.headers['svix-id'];
3944
const svixTimestamp = req.headers['svix-timestamp'];
4045
const svixSignature = req.headers['svix-signature'];
41-
42-
// Verify we have required headers
4346
if (!svixId || !svixTimestamp || !svixSignature) {
44-
return res.status(400).json({ error: 'Missing required Svix headers' });
47+
return res.status(400).json({ error: 'Missing required webhook headers' });
4548
}
46-
47-
// Manual signature verification (recommended approach)
48-
const secret = process.env.CLERK_WEBHOOK_SECRET; // whsec_xxxxx from Clerk dashboard
49-
const signedContent = `${svixId}.${svixTimestamp}.${req.body}`;
50-
49+
// standardwebhooks expects webhook-* header names; Clerk sends svix-* (same protocol)
50+
const headers = {
51+
'webhook-id': svixId,
52+
'webhook-timestamp': svixTimestamp,
53+
'webhook-signature': svixSignature
54+
};
5155
try {
52-
// Extract base64 secret after 'whsec_' prefix
53-
const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
54-
const expectedSignature = crypto
55-
.createHmac('sha256', secretBytes)
56-
.update(signedContent)
57-
.digest('base64');
58-
59-
// Svix can send multiple signatures, check each one
60-
const signatures = svixSignature.split(' ').map(sig => sig.split(',')[1]);
61-
const isValid = signatures.some(sig => {
62-
try {
63-
return crypto.timingSafeEqual(
64-
Buffer.from(sig),
65-
Buffer.from(expectedSignature)
66-
);
67-
} catch {
68-
return false; // Different lengths = invalid
69-
}
70-
});
71-
72-
if (!isValid) {
73-
return res.status(400).json({ error: 'Invalid signature' });
74-
}
75-
76-
// Check timestamp to prevent replay attacks (5-minute window)
77-
const timestamp = parseInt(svixTimestamp, 10);
78-
const currentTime = Math.floor(Date.now() / 1000);
79-
if (currentTime - timestamp > 300) {
80-
return res.status(400).json({ error: 'Timestamp too old' });
56+
const wh = new Webhook(secret);
57+
const event = wh.verify(req.body, headers);
58+
if (!event) return res.status(400).json({ error: 'Invalid payload' });
59+
switch (event.type) {
60+
case 'user.created': console.log('User created:', event.data.id); break;
61+
case 'user.updated': console.log('User updated:', event.data.id); break;
62+
case 'session.created': console.log('Session created:', event.data.user_id); break;
63+
case 'organization.created': console.log('Organization created:', event.data.id); break;
64+
default: console.log('Unhandled:', event.type);
8165
}
66+
res.status(200).json({ success: true });
8267
} catch (err) {
83-
console.error('Signature verification error:', err);
84-
return res.status(400).json({ error: 'Invalid signature' });
68+
res.status(400).json({ error: err.name === 'WebhookVerificationError' ? err.message : 'Webhook verification failed' });
8569
}
86-
87-
// Parse the verified webhook body
88-
const event = JSON.parse(req.body.toString());
89-
90-
// Handle the event
91-
switch (event.type) {
92-
case 'user.created':
93-
console.log('User created:', event.data.id);
94-
break;
95-
case 'user.updated':
96-
console.log('User updated:', event.data.id);
97-
break;
98-
case 'session.created':
99-
console.log('Session created:', event.data.user_id);
100-
break;
101-
case 'organization.created':
102-
console.log('Organization created:', event.data.id);
103-
break;
104-
default:
105-
console.log('Unhandled event:', event.type);
106-
}
107-
108-
res.status(200).json({ success: true });
10970
}
11071
);
11172
```
@@ -176,15 +137,22 @@ async def clerk_webhook(request: Request):
176137
| `organization.created` | New organization created |
177138
| `organization.updated` | Organization settings updated |
178139
| `organizationMembership.created` | User added to organization |
140+
| `organizationInvitation.created` | Invite sent to join organization |
179141

180-
> **For full event reference**, see [Clerk Webhook Events](https://clerk.com/docs/integrations/webhooks/overview#event-types)
142+
> **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).
181143
182144
## Environment Variables
183145

184146
```bash
185-
CLERK_WEBHOOK_SECRET=whsec_xxxxx # From webhook endpoint settings in Clerk Dashboard
147+
# Official name (used by @clerk/nextjs and Clerk docs)
148+
CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxx
149+
150+
# Alternative name (used in this skill's examples)
151+
CLERK_WEBHOOK_SECRET=whsec_xxxxx
186152
```
187153

154+
From Clerk Dashboard → Webhooks → your endpoint → Signing Secret.
155+
188156
## Local Development
189157

190158
```bash
@@ -195,11 +163,14 @@ brew install hookdeck/hookdeck/hookdeck
195163
hookdeck listen 3000 --path /webhooks/clerk
196164
```
197165

166+
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.
167+
198168
## Reference Materials
199169

200170
- [references/overview.md](references/overview.md) - Clerk webhook concepts
201171
- [references/setup.md](references/setup.md) - Dashboard configuration
202172
- [references/verification.md](references/verification.md) - Signature verification details
173+
- [references/patterns.md](references/patterns.md) - Quick start, when to sync, key patterns, common pitfalls
203174

204175
## Attribution
205176

skills/clerk-webhooks/examples/express/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Clerk Webhooks - Express Example
22

3-
Minimal example of receiving Clerk webhooks with signature verification.
3+
Minimal example of receiving Clerk webhooks with signature verification using the [standardwebhooks](https://www.npmjs.com/package/standardwebhooks) package (Standard Webhooks protocol).
44

55
## Prerequisites
66

skills/clerk-webhooks/examples/express/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
},
1010
"dependencies": {
1111
"express": "^5.2.1",
12-
"dotenv": "^16.0.3"
12+
"dotenv": "^16.0.3",
13+
"standardwebhooks": "^1.0.0"
1314
},
1415
"devDependencies": {
1516
"jest": "^29.0.0",

skills/clerk-webhooks/examples/express/src/index.js

Lines changed: 29 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
require('dotenv').config();
55
const express = require('express');
6-
const crypto = require('crypto');
6+
const { Webhook } = require('standardwebhooks');
77

88
const app = express();
99
const PORT = process.env.PORT || 3000;
@@ -14,82 +14,42 @@ app.get('/health', (req, res) => {
1414
});
1515

1616
// Clerk webhook endpoint
17+
// Clerk uses Standard Webhooks (same as Svix); we verify with the standardwebhooks package.
1718
// IMPORTANT: Use express.raw() to get raw body for signature verification
1819
app.post('/webhooks/clerk',
1920
express.raw({ type: 'application/json' }),
2021
async (req, res) => {
21-
// Get Svix headers
22-
const svixId = req.headers['svix-id'];
23-
const svixTimestamp = req.headers['svix-timestamp'];
24-
const svixSignature = req.headers['svix-signature'];
25-
26-
// Verify required headers are present
27-
if (!svixId || !svixTimestamp || !svixSignature) {
28-
return res.status(400).json({
29-
error: 'Missing required Svix headers'
30-
});
31-
}
32-
33-
// Verify signature
34-
const secret = process.env.CLERK_WEBHOOK_SECRET;
22+
const secret = process.env.CLERK_WEBHOOK_SECRET || process.env.CLERK_WEBHOOK_SIGNING_SECRET;
3523
if (!secret || !secret.startsWith('whsec_')) {
3624
console.error('Invalid webhook secret configuration');
3725
return res.status(500).json({
3826
error: 'Server configuration error'
3927
});
4028
}
4129

42-
try {
43-
// Construct the signed content
44-
const signedContent = `${svixId}.${svixTimestamp}.${req.body}`;
45-
46-
// Extract the base64 secret (everything after 'whsec_')
47-
const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
48-
49-
// Calculate expected signature
50-
const expectedSignature = crypto
51-
.createHmac('sha256', secretBytes)
52-
.update(signedContent)
53-
.digest('base64');
54-
55-
// Svix can send multiple signatures separated by spaces
56-
// Each signature is in format "v1,actualSignature"
57-
const signatures = svixSignature.split(' ').map(sig => sig.split(',')[1]);
58-
59-
// Use timing-safe comparison
60-
const isValid = signatures.some(sig => {
61-
try {
62-
return crypto.timingSafeEqual(
63-
Buffer.from(sig),
64-
Buffer.from(expectedSignature)
65-
);
66-
} catch (err) {
67-
// timingSafeEqual throws if buffers are different lengths
68-
return false;
69-
}
30+
// Clerk/Svix send svix-* headers; standardwebhooks expects webhook-* (same protocol)
31+
const svixId = req.headers['svix-id'];
32+
const svixTimestamp = req.headers['svix-timestamp'];
33+
const svixSignature = req.headers['svix-signature'];
34+
if (!svixId || !svixTimestamp || !svixSignature) {
35+
return res.status(400).json({
36+
error: 'Missing required webhook headers (svix-id, svix-timestamp, svix-signature)'
7037
});
38+
}
7139

72-
if (!isValid) {
73-
return res.status(400).json({
74-
error: 'Invalid signature'
75-
});
76-
}
77-
78-
// Check timestamp to prevent replay attacks (5 minute window)
79-
const timestamp = parseInt(svixTimestamp, 10);
80-
const currentTime = Math.floor(Date.now() / 1000);
81-
const fiveMinutes = 5 * 60;
40+
const headers = {
41+
'webhook-id': svixId,
42+
'webhook-timestamp': svixTimestamp,
43+
'webhook-signature': svixSignature
44+
};
8245

83-
if (currentTime - timestamp > fiveMinutes) {
84-
return res.status(400).json({
85-
error: 'Timestamp too old'
86-
});
46+
try {
47+
const wh = new Webhook(secret);
48+
const event = wh.verify(req.body, headers);
49+
if (!event) {
50+
return res.status(400).json({ error: 'Invalid webhook payload' });
8751
}
8852

89-
// Parse the verified event
90-
const event = JSON.parse(req.body.toString());
91-
92-
// Handle different event types
9353
console.log(`Received Clerk webhook: ${event.type}`);
9454

9555
switch (event.type) {
@@ -98,62 +58,45 @@ app.post('/webhooks/clerk',
9858
userId: event.data.id,
9959
email: event.data.email_addresses?.[0]?.email_address
10060
});
101-
// TODO: Add your user creation logic here
10261
break;
103-
10462
case 'user.updated':
105-
console.log('User updated:', {
106-
userId: event.data.id
107-
});
108-
// TODO: Add your user update logic here
63+
console.log('User updated:', { userId: event.data.id });
10964
break;
110-
11165
case 'user.deleted':
112-
console.log('User deleted:', {
113-
userId: event.data.id
114-
});
115-
// TODO: Add your user deletion logic here
66+
console.log('User deleted:', { userId: event.data.id });
11667
break;
117-
11868
case 'session.created':
11969
console.log('Session created:', {
12070
sessionId: event.data.id,
12171
userId: event.data.user_id
12272
});
123-
// TODO: Add your session creation logic here
12473
break;
125-
12674
case 'session.ended':
12775
console.log('Session ended:', {
12876
sessionId: event.data.id,
12977
userId: event.data.user_id
13078
});
131-
// TODO: Add your session end logic here
13279
break;
133-
13480
case 'organization.created':
13581
console.log('Organization created:', {
13682
orgId: event.data.id,
13783
name: event.data.name
13884
});
139-
// TODO: Add your organization creation logic here
14085
break;
141-
14286
default:
14387
console.log('Unhandled event type:', event.type);
14488
}
14589

146-
// Return success response
14790
res.status(200).json({
14891
success: true,
14992
type: event.type
15093
});
151-
15294
} catch (err) {
153-
console.error('Webhook processing error:', err);
154-
res.status(400).json({
155-
error: 'Invalid webhook payload'
156-
});
95+
console.error('Webhook verification failed:', err.message || err);
96+
const message = err.name === 'WebhookVerificationError'
97+
? (err.message === 'Message timestamp too old' ? 'Timestamp too old' : err.message === 'No matching signature found' ? 'Invalid signature' : err.message)
98+
: 'Webhook verification failed';
99+
res.status(400).json({ error: message });
157100
}
158101
}
159102
);
@@ -164,5 +107,4 @@ const server = app.listen(PORT, () => {
164107
console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/clerk`);
165108
});
166109

167-
// For testing
168-
module.exports = { app, server };
110+
module.exports = { app, server };

skills/clerk-webhooks/examples/express/test/webhook.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('Clerk Webhook Handler', () => {
101101
.send(payload);
102102

103103
expect(response.status).toBe(400);
104-
expect(response.body.error).toBe('Missing required Svix headers');
104+
expect(response.body.error).toContain('Missing required');
105105
});
106106

107107
test('rejects invalid signature', async () => {

skills/clerk-webhooks/examples/nextjs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Clerk Webhooks - Next.js Example
22

3-
Next.js App Router example for receiving Clerk webhooks with signature verification.
3+
Next.js App Router example for receiving Clerk webhooks using the Clerk SDK (`verifyWebhook` from `@clerk/backend/webhooks`).
44

55
## Prerequisites
66

0 commit comments

Comments
 (0)