Webhooks
Verify Sonny Labs outbound webhook signatures (HMAC-SHA256 over {t}.{body}) using the helpers shipped in the Python and TypeScript SDKs.
Sonny Labs delivers scan events (scan.allowed, scan.flagged,
scan.warned, scan.blocked) to the receiver URL you registered via
POST /v1/webhooks. Every delivery is signed with HMAC-SHA256 so the
receiver can prove the request was issued by Sonny Labs and was not
replayed.
Customer-facing reference for the outbound webhook signing scheme. The contract is defined by the
webhooks.scanEventoperation in REST API reference; this page documents the customer-side verification that both SDKs ship as a helper.
Signature header
Sonny-Signature: t=1712345678,v1=4f9c3...c2atis the Unix timestamp (in seconds) at which Sonny Labs signed the request.v1is the hex-encoded HMAC-SHA256 of{t}.{body}keyed by the webhook's signingsecret(the value returned exactly once byPOST /v1/webhooksand rotatable viaPOST /v1/webhooks/{id}:rotate-secret).bodyis the raw JSON body bytes — verify on the bytes you received off the wire, not on a re-serialisedJSON.stringify/json.dumpsof a parsed object. Re-serialisation reorders keys, changes whitespace, and breaks the signature.
Sonny Labs also sets two correlation headers on every delivery:
Sonny-Event-Id— the canonical event ID. Use it as your dedup key on the receiver side; the same event may be redelivered up to 6 times under the retry schedule, and you should treat any duplicate as a no-op.Sonny-Api-Version— the date version of the event envelope (e.g.2026-06-01).
Replay window
Each SDK helper rejects deliveries whose t differs from the
receiver's local clock by more than 5 minutes (300 seconds). The
window is symmetric and defends against an attacker who captures a
valid request and replays it later. Combine the window check with
Sonny-Event-Id-based dedup for full at-least-once-with-no-duplicates
semantics.
If your receiver runs in an environment with significant clock skew
(some serverless platforms drift), bump the tolerance via the helper's
tolerance argument, but do not exceed a few minutes — every second
you add is a second longer a stolen signature stays valid.
Python: verify_webhook
import os
from fastapi import FastAPI, Header, HTTPException, Request
from sonnylabs.webhooks import (
verify_webhook,
InvalidSignature,
SignatureExpired,
)
app = FastAPI()
SIGNING_SECRET = os.environ["SONNY_WEBHOOK_SECRET"]
@app.post("/webhooks/sonnylabs")
async def receive(
request: Request,
sonny_signature: str = Header(..., alias="Sonny-Signature"),
):
body = await request.body() # raw bytes — do not JSON.parse first
try:
event = verify_webhook(
body=body,
signature=sonny_signature,
secret=SIGNING_SECRET,
tolerance=300, # seconds; default is 300
)
except SignatureExpired:
# Outside the 5-minute replay window. Reject loudly.
raise HTTPException(status_code=400, detail="signature expired")
except InvalidSignature:
# Signature did not match. Do NOT process the body.
raise HTTPException(status_code=400, detail="invalid signature")
# event is the parsed WebhookEventEnvelope from the OpenAPI spec.
if event.type == "scan.blocked":
...
return {"ok": True}verify_webhook returns the parsed event envelope on success. On
failure it raises InvalidSignature (bad HMAC) or SignatureExpired
(timestamp outside the tolerance window). Both inherit from a base
WebhookVerificationError if you want a single except.
TypeScript: verifyWebhook
import express from "express";
import {
verifyWebhook,
InvalidSignatureError,
SignatureExpiredError,
} from "@sonnylabs/sdk/webhooks";
const app = express();
const SIGNING_SECRET = process.env.SONNY_WEBHOOK_SECRET!;
// Mount express.raw so req.body is a Buffer of the bytes off the wire.
// Do NOT use express.json() on the webhook route — re-serialising the
// parsed object will not produce the same bytes the signature covers.
app.post(
"/webhooks/sonnylabs",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("Sonny-Signature");
if (!signature) return res.status(400).send("missing signature");
let event;
try {
event = verifyWebhook({
body: req.body, // Buffer — raw bytes
signature,
secret: SIGNING_SECRET,
toleranceSeconds: 300, // default is 300
});
} catch (err) {
if (err instanceof SignatureExpiredError) {
// Outside the 5-minute replay window.
return res.status(400).send("signature expired");
}
if (err instanceof InvalidSignatureError) {
// Signature did not match. Do NOT process the body.
return res.status(400).send("invalid signature");
}
throw err;
}
if (event.type === "scan.blocked") {
// ...
}
res.status(204).end();
},
);verifyWebhook returns the parsed event envelope on success. On
failure it throws InvalidSignatureError (bad HMAC) or
SignatureExpiredError (timestamp outside the tolerance window). Both
extend a base WebhookVerificationError if you want a single catch.
On signature mismatch
Treat any verification failure as a security event, not a transport hiccup. Specifically:
- Do not parse or process the body. A bad signature means the payload may have been forged or tampered with in transit.
- Do not retry from the receiver side. Sonny Labs already retries
non-2xx responses on the documented schedule (30s, 2m, 10m, 1h, 6h,
24h after the initial attempt). Returning
400is the correct signal. - Log the
Sonny-Event-Id, the receiver-local timestamp, and the first few bytes of the body hash so you can correlate againstGET /v1/webhooks/{id}/deliverieswhile debugging. Never log the full signing secret. - Rotate the signing secret if you suspect the secret leaked. Use
POST /v1/webhooks/{webhook_id}:rotate-secret. The endpoint returns the new secret and agrace_expires_attimestamp — Sonny Labs will accept either the old or the new secret until that time so you have a window to roll the new value to your receiver.
A persistent stream of mismatches typically means one of three things:
the wrong signing secret is configured on the receiver, an upstream
proxy is rewriting the body (decompressing, re-pretty-printing, or
stripping a trailing newline), or clock skew has pushed your receiver
outside the tolerance window. The
GET /v1/webhooks/{webhook_id}/deliveries endpoint surfaces the
attempted-status of each delivery so you can confirm Sonny Labs
believes the signature is correct on its side.
MCP server
Run @sonnylabs/mcp inside Claude Desktop, Cursor, or Claude Code. Lets your agent scan prompts, manage API keys, and scaffold SonnyLabs into a customer codebase — all without leaving the chat.
TypeScript SDK reference
Auto-generated symbol reference for @sonnylabs/sdk — every public class, method, options bag, error subclass, and helper, rendered from the JSDoc on the source.