Clerk, Resend and many other platforms send webhooks through Svix. Each request carries three headers: svix-id, svix-timestamp and svix-signature. The signed content is {svix-id}.{svix-timestamp}.{raw body}. The key is your endpoint secret: drop the whsec_ prefix and base64-decode the rest. The result is HMAC-SHA256, base64-encoded. svix-signature is a space-separated list like v1,<base64> v1,<base64> (more than one during secret rotation). If no entry matches, the Svix libraries throw WebhookVerificationError: No matching signature found.
JSON.stringify(req.body) after express.json(), or await req.json() in Next.js, changes whitespace and key order. Use express.raw() or await req.text().whsec_...), and dev and prod instances differ. Copy it from that endpoint's page in the Clerk or Resend dashboard. API keys won't work.whsec_, not the whole string as UTF-8 text. The digest is base64, not hex.svix-signature gives "Missing required headers". Pass all three.clerkMiddleware so it isn't redirected or blocked before your handler runs.svix package)const { Webhook } = require('svix');
app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), (req, res) => {
const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_...
let evt;
try {
evt = wh.verify(req.body.toString('utf8'), {
'svix-id': req.get('svix-id'),
'svix-timestamp': req.get('svix-timestamp'),
'svix-signature': req.get('svix-signature'),
});
} catch (err) { return res.status(400).send('invalid signature'); }
// evt is the parsed JSON payload; de-duplicate on svix-id
res.sendStatus(200);
});
Next.js route handler: const payload = await req.text(); then wh.verify(payload, Object.fromEntries(req.headers)). Clerk also ships verifyWebhook(req) in @clerk/nextjs/webhooks, which does the same check with CLERK_WEBHOOK_SIGNING_SECRET.
svix package)from fastapi import Request, HTTPException
from svix.webhooks import Webhook, WebhookVerificationError
@app.post('/webhooks/clerk')
async def clerk_webhook(request: Request):
payload = await request.body() # raw bytes
try:
evt = Webhook(os.environ['WEBHOOK_SECRET']).verify(payload, dict(request.headers))
except WebhookVerificationError:
raise HTTPException(status_code=400, detail='invalid signature')
return {'ok': True}
const [id, ts, body] = [headers['svix-id'], headers['svix-timestamp'], rawBody];
const key = Buffer.from(secret.split('_')[1], 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');
const ok = headers['svix-signature'].split(' ').some(p => {
const s = p.split(',')[1] || '';
return s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected));
}); // also reject if |now - ts| > 300 s
The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. Its built-in schemes cover Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square, Twilio (status callbacks) and GitHub. Svix webhooks (Clerk, Resend) aren't one of them, so you would add a small verifier like the one above.
Fix Your Stripe Webhooks guide ($9) · Self-Hosted Webhook Relay Kit ($19)
Free download: Webhook debugging cheat sheet.
Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint. More fixes: all webhook error pages.