Verifying signatures
Validate the svix-id, svix-timestamp, and svix-signature headers on every Svix delivery before trusting it, with an official Svix library.
Every Svix delivery is signed, and your handler must verify the signature before doing anything else.
A webhook endpoint is a public URL that accepts POSTs from the internet. Without signature verification, anyone who learns the URL can post a forged sale.completed and have your system ship goods for a payment that never happened. Never skip this.
If your deliveries carry x-fsk-wh-chksm instead of the svix-* headers, you are on legacy webhooks - see Verifying the checksum.
The headers
Every Svix delivery includes three headers used to verify authenticity:
| Header | Meaning |
|---|---|
svix-id |
The unique message id for this delivery. Constant across retries of the same message. |
svix-timestamp |
The time the message was sent (Unix seconds). |
svix-signature |
One or more space-delimited signatures, each in the form v1,<base64 signature>. |
How the signature is computed
The signature is an HMAC-SHA256 over the string {svix-id}.{svix-timestamp}.{raw request body}, keyed with your endpoint's signing secret (the base64-decoded portion after the whsec_ prefix). The result is base64-encoded and must match one of the signatures in the svix-signature header - multiple signatures may be present while a secret rotation is in progress.
Use the raw JSON exactly as received in the HTTP request body - no reformatting, indentation changes, or whitespace modifications. Any framework that parses and re-serializes the body before you compute the HMAC will produce a signature that never matches.
Use an official library
Verify signatures with one of the official Svix libraries rather than implementing the check by hand. The libraries also enforce a timestamp tolerance that protects against replay attacks - a hand-rolled HMAC comparison that ignores svix-timestamp will happily accept a genuine signed payload that an attacker captured and replayed days later.
The libraries handle, in one call: the exact signing string, base64 handling, multiple signatures during rotation, constant-time comparison, and timestamp tolerance.
import { Webhook } from "svix";
import express from "express";
const app = express();
const wh = new Webhook(process.env.WEBHOOK_SIGNING_SECRET);
// express.raw is important: the verifier needs the unparsed body.
app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = wh.verify(req.body, {
"svix-id": req.header("svix-id"),
"svix-timestamp": req.header("svix-timestamp"),
"svix-signature": req.header("svix-signature"),
});
} catch {
return res.sendStatus(400);
}
try {
await enqueue(event); // persist before acknowledging
} catch {
return res.sendStatus(503);
}
return res.sendStatus(202);
});
from svix.webhooks import Webhook, WebhookVerificationError
from flask import Flask, request, abort
import os
app = Flask(__name__)
wh = Webhook(os.environ["WEBHOOK_SIGNING_SECRET"])
@app.post("/webhooks")
def webhooks():
try:
# request.data is the raw body, not the parsed JSON.
wh.verify(request.data, dict(request.headers))
except WebhookVerificationError:
abort(400)
enqueue(request.get_json())
return "", 202
app.MapPost("/webhooks", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var payload = await reader.ReadToEndAsync();
var wh = new Svix.Webhook(
Environment.GetEnvironmentVariable("WEBHOOK_SIGNING_SECRET"));
try
{
var headers = new System.Net.WebHeaderCollection();
foreach (var name in new[] { "svix-id", "svix-timestamp", "svix-signature" })
headers[name] = request.Headers[name].ToString();
wh.Verify(payload, headers);
}
catch (Svix.Exceptions.WebhookVerificationException)
{
return Results.BadRequest();
}
Enqueue(payload);
return Results.Accepted();
});
The examples use an application-provided enqueue / Enqueue function. It must save the event durably before returning, and fail the request if the save fails. Keep JSON-parsing middleware after the raw-body webhook route.
Order of operations
Read the raw body
Before any JSON parsing or middleware transforms it.
Verify the signature
Reject with 400 if verification fails. Do not log the payload contents of an unverified request as though it were genuine.
Deduplicate on event ID
Retries, replays from the portal, and dual delivery while you move from legacy all mean the same event can arrive more than once. See Deduplicate on event.id.
Save, acknowledge, then process
Save the verified event to a durable queue, then return 2xx promptly. Process the queued event asynchronously. Return a non-2xx response if saving fails.
This is not the same as redirect verification
The Hosted Payment Form redirect hash is a separate mechanism with a different input, secret, and encoding. Verifying one does not verify the other. See the comparison table.