Clerk Plugin
Receive and verify Clerk webhooks with Restaq.
The @restaq/clerk plugin handles Clerk webhook verification and provides typed Restaq event names for common Clerk webhook events. Clerk delivers webhooks via Svix, so verification follows the Svix signature scheme.
Installation
pnpm add @restaq/clerknpm install @restaq/clerkyarn add @restaq/clerkbun add @restaq/clerkimport { clerk } from '@restaq/clerk';Setup
// relay.ts
import { restaq as createRestaq } from 'restaq';
import { clerk } from '@restaq/clerk';
export const restaq = createRestaq({
plugins: [
clerk(), // reads CLERK_WEBHOOK_SECRET automatically
],
});- In your Clerk Dashboard, go to Webhooks and add an endpoint pointing at
/api/webhook/clerkon your deployed app — see Sync Clerk data to your app with webhooks for the full walkthrough. - Copy the Signing Secret (starts with
whsec_) and set it asCLERK_WEBHOOK_SECRET.
Pass webhookSecret explicitly instead if you'd rather not rely on the env var:
clerk({ webhookSecret: 'whsec_...', toleranceSeconds: 300 }),Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| webhookSecret | string | process.env.CLERK_WEBHOOK_SECRET | The endpoint's signing secret from the Clerk dashboard |
| toleranceSeconds | number | 300 | Max age of a signed timestamp before rejection |
Signature verification
Clerk signs webhooks the way Svix does: three headers (svix-id, svix-timestamp, svix-signature) instead of Stripe/GitHub's single-header scheme.
- Reads the
svix-id,svix-timestamp, andsvix-signatureheaders — rejects the request if any are missing - Rejects if the timestamp is older than
toleranceSeconds - Computes HMAC-SHA256 of
${svix-id}.${svix-timestamp}.${rawBody}, using the webhook secret's base64-decoded bytes after stripping itswhsec_prefix svix-signaturecan contain multiple space-separatedv1,<signature>values (for secret rotation) — the request is valid if any of them match, compared using constant-time comparison
Event types
Events are namespaced as clerk.<clerk-event-type> — see the Event Catalog tab on your Clerk Dashboard's Webhooks page for the full list Clerk can send:
// relay.handlers.ts
import type { AppRelay } from './relay';
export function registerHandlers(relay: AppRelay): void {
relay.on('clerk.user.created', async (event, ctx) => {
ctx.log.info('user created', { id: event.data.id });
});
relay.on('clerk.session.created', async (event, ctx) => {
ctx.log.info('session started', { userId: event.data.user_id });
});
}Deduplication
Every Svix-delivered webhook carries a svix-id header that stays the same across delivery retries. The plugin uses it as the Restaq event ID, so a redelivered event collapses into the same execution instead of running your handler twice.
Testing locally
Use relay trigger to simulate a signed Clerk webhook:
relay trigger clerk user.created --data '{"id":"user_123"}'Make sure CLERK_WEBHOOK_SECRET is set to the same value on both relay dev and relay trigger, or unset on both — see the Local Development gotcha for why.