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

HeaderDescription
svix-idUnique ID of the event, such as msg_2f7a.... The same on every retry.
svix-timestampUnix time in seconds when this attempt was sent. It changes on each retry.
svix-signaturev1, 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 with GET /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.

TypeScript
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:

TypeScript
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:

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 200

Verify 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.

Text
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) )
TypeScript
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:

Shell
curl -X POST https://api.sooner.dev/webhooks/jw6k9d3h2s5f8g1j4l7z0x2c/signing-secret/rotate \
  -H "Authorization: Bearer $SOONER_API_KEY"
JSON
{
  "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:

TypeScript
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

SymptomLikely cause
Verification always failsThe body was parsed and re-serialized. Use the raw bytes.
Fails only in productionThe wrong secret, for example one from another webhook, or a secret with extra whitespace.
Message timestamp too oldYour server clock is off by more than five minutes, or you are replaying a saved request.
Fails after rotationYour app still has the old secret.