Verifying Webhook Signatures
Every webhook delivery is signed, so you can confirm it actually came from CELITECH before acting on it. Never process a webhook payload without verifying its signature first.
Your Signing Secret
Each endpoint has its own signing secret, available under Developers → Webhooks in the dashboard. Treat it like any other credential — keep it out of client-side code, logs, and version control. If a secret is ever compromised, rotate it from the same page — your endpoint should always verify using its current secret.
Request Headers
Every delivery includes the following headers:
| Header | Description |
|---|---|
svix-id | A unique identifier for this delivery. Every retry attempt of the same delivery carries this same identifier — use it to de-duplicate if you receive it more than once. |
svix-timestamp | The Unix timestamp (in seconds) the delivery was sent. |
svix-signature | One or more space-delimited signatures for this delivery, used to verify authenticity. |
Verifying the Signature
- Your signing secret has a
whsec_prefix — strip it, then base64-decode the remainder to get the raw key bytes. Don't use thewhsec_...string itself as the key. - Concatenate the
svix-id,svix-timestamp, and the raw request body, separated by periods (.). - Compute an HMAC-SHA256 of that string using the decoded key from step 1, then base64-encode the result.
- The
svix-signatureheader contains one or more space-delimited entries, each formatted asv1,<base64 signature>(thev1is a version tag, and multiple entries can appear during a secret rotation). Compare your computed signature against the value afterv1,in each entry, using a constant-time comparison (not a plain===/string-equality check) to avoid leaking timing information — if any one matches, the request is authentic.
Also reject any request whose svix-timestamp is too far in the past or future (a tolerance of a few minutes is reasonable), to protect against replay attacks.
Always compute the signature over the raw, unparsed request body. Re-serializing a parsed JSON object before verifying can produce a different byte sequence and cause verification to fail incorrectly.
Responding to Webhook Deliveries
- Respond with a 2xx status code as soon as you've accepted the delivery — do the minimal work needed (verify the signature, enqueue for processing) before responding. See Delivery & Retries for what happens otherwise.
- Process deliveries idempotently. Retries can result in the same event being delivered more than once. Use the
svix-idheader as a de-duplication key — if you've already processed a delivery with that identifier, skip it. See the Event Catalog for per-event delivery notes.
Example Verification Code
const crypto = require('crypto');
function verifyWebhookSignature(payload, headers, secret) {
const id = headers['svix-id'];
const timestamp = headers['svix-timestamp'];
const signatureHeader = headers['svix-signature'];
// Secrets are formatted as `whsec_<base64>` — strip the prefix and
// base64-decode the rest to get the raw HMAC key.
const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
const signedContent = `${id}.${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Header looks like "v1,<sig1> v1,<sig2>" — one or more space-delimited
// entries, each prefixed with a version tag.
const receivedSignatures = signatureHeader.split(' ').map((entry) => entry.split(',')[1]);
const expectedBuffer = Buffer.from(expectedSignature);
// Use a constant-time comparison so verification doesn't leak timing
// information about how much of the signature matched.
return receivedSignatures.some((sig) => {
const receivedBuffer = Buffer.from(sig);
return (
receivedBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(receivedBuffer, expectedBuffer)
);
});
}
Testing Your Integration
Developers → Webhooks in the dashboard includes a way to send an example event to your endpoint, so you can test your signature verification and handling logic before relying on it in production.
Failed example-event deliveries sent this way are not retried, unlike real deliveries — see Delivery & Retries.
