Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 38 additions & 67 deletions skills/clerk-webhooks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
);
```
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion skills/clerk-webhooks/examples/express/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion skills/clerk-webhooks/examples/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 29 additions & 87 deletions skills/clerk-webhooks/examples/express/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,82 +14,42 @@ 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({
error: 'Server configuration error'
});
}

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) {
Expand All @@ -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 });
}
}
);
Expand All @@ -164,5 +107,4 @@ const server = app.listen(PORT, () => {
console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/clerk`);
});

// For testing
module.exports = { app, server };
module.exports = { app, server };
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion skills/clerk-webhooks/examples/nextjs/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading