Webhooks
Verify signatures
Verify that a webhook came from Sooner using the Svix-compatible signature headers, the svix library or your own HMAC check.
Anyone who knows your endpoint URL can send it a fake event. Every request from Sooner is signed, so you can check that it is genuine and unmodified before you act on it. The signing scheme is the same one Svix uses, so the svix library verifies it without changes.
The headers
| Header | Description |
|---|---|
svix-id | Unique ID of the event, such as msg_2f7a.... The same on every retry. |
svix-timestamp | Unix time in seconds when this attempt was sent. It changes on each retry. |
svix-signature | v1, followed by a base64 signature. |
The same values are also sent as webhook-id, webhook-timestamp and webhook-signature, for libraries that follow the Standard Webhooks spec. You can read either set.
The signing secret
Each webhook has its own signing secret, which looks like whsec_ followed by a base64 string. You receive it when you create the webhook:
- In the dashboard, the secret is shown once, right after you add the endpoint. Copy it then.
- With the API, the create response contains
signing_secret. You can read it again withGET /webhooks/{id}. The list endpoint does not include it.
Treat it like a password. Keep it in an environment variable or a secrets manager, and never in client-side code.
Verify with the svix library
Always verify against the raw request body, exactly as received. If a framework parses the JSON and you stringify it again, the bytes change and verification fails.
import { Webhook } from "svix";
const wh = new Webhook(process.env.SOONER_WEBHOOK_SECRET!);
// rawBody is the unparsed request body as a string or Buffer.
export function verify(rawBody: string | Buffer, headers: Headers) {
return wh.verify(rawBody, {
"svix-id": headers.get("svix-id")!,
"svix-timestamp": headers.get("svix-timestamp")!,
"svix-signature": headers.get("svix-signature")!,
});
}verify returns the parsed event and throws if the signature is wrong or the timestamp is more than five minutes away from your server's clock. The timestamp check stops an attacker from replaying a captured request later.
In a Next.js route handler:
export async function POST(req: Request) {
const rawBody = await req.text();
try {
const event = verify(rawBody, req.headers) as { type: string; data: unknown };
// handle event
} catch {
return new Response("invalid signature", { status: 400 });
}
return new Response("ok");
}In Python:
from svix.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["SOONER_WEBHOOK_SECRET"])
def handle(raw_body: bytes, headers: dict):
try:
event = wh.verify(raw_body, {
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"],
})
except WebhookVerificationError:
return 400
# handle event
return 200Verify without a library
The signature is a base64 HMAC-SHA256, computed with the decoded secret over three values joined by dots: the ID, the timestamp and the raw body.
signed content = svix-id + "." + svix-timestamp + "." + raw body
secret bytes = base64 decode of the secret after the whsec_ prefix
signature = "v1," + base64( HMAC-SHA256(secret bytes, signed content) )import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyManually(rawBody: string, headers: Record<string, string>, secret: string) {
const id = headers["svix-id"];
const timestamp = headers["svix-timestamp"];
const received = headers["svix-signature"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
// The header can hold several space-separated signatures, such as "v1,abc v1,def".
return received.split(" ").some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}Rotate the secret
Rotate the secret if it leaks, or on a schedule. Rotation is done with the API and needs a full_access key:
curl -X POST https://api.sooner.dev/webhooks/jw6k9d3h2s5f8g1j4l7z0x2c/signing-secret/rotate \
-H "Authorization: Bearer $SOONER_API_KEY"{
"object": "webhook",
"id": "jw6k9d3h2s5f8g1j4l7z0x2c",
"signing_secret": "whsec_t9Qm2Xv7LpRk4NwYb8Hc1ZaE5JdGfU0s"
}The old secret stops signing right away, with no overlap period. Deliveries that were queued can still be signed with the old secret for up to about 30 seconds while the change spreads, and retries after that use the new one. To avoid rejecting good events during the switch, accept both secrets briefly:
const secrets = [process.env.SOONER_WEBHOOK_SECRET!, process.env.SOONER_WEBHOOK_SECRET_PREVIOUS].filter(Boolean) as string[];
function verifyWithAny(rawBody: string, headers: Record<string, string>) {
for (const secret of secrets) {
try {
return new Webhook(secret).verify(rawBody, headers);
} catch {
// try the next secret
}
}
throw new Error("invalid signature");
}Remove the old secret from your configuration after a few minutes.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Verification always fails | The body was parsed and re-serialized. Use the raw bytes. |
| Fails only in production | The wrong secret, for example one from another webhook, or a secret with extra whitespace. |
Message timestamp too old | Your server clock is off by more than five minutes, or you are replaying a saved request. |
| Fails after rotation | Your app still has the old secret. |