Webhooks
Verify Sonny Labs outbound webhook signatures (HMAC-SHA256 over {t}.{body}) using the helpers shipped in the Python and TypeScript SDKs.
Outbound webhook delivery is coming soon. The HMAC-SHA256 verification
helpers documented below ship today in both SDKs, but Sonny Labs is not yet
sending live deliveries and webhook registration is not yet open. This page is
the forward-looking reference so you can wire your receiver and unit-test the
verifier ahead of launch. Email support@sonnylabs.ai if you want a heads-up
when deliveries start firing.
Once delivery is live, Sonny Labs will sign every scan event
(scan.allowed, scan.flagged, scan.warned, scan.blocked) and
POST it 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.
Observability (OpenTelemetry)
Send Sonny Labs SDK traces and metrics to your existing OpenTelemetry-compatible observability stack — Datadog, Honeycomb, Grafana Tempo, New Relic — with zero Sonny Labs-specific configuration.
Summarizer
End-to-end recipe for wrapping a Sonny Labs scan around an LLM summarizer — input scan, model call, output scan, decision handling, and same-pattern snippets for OpenAI, Anthropic, LangChain, LlamaIndex, Vercel AI SDK, and Semantic Kernel.