Sonny Labs Docs
SDKs

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.scanEvent operation 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...c2a
  • t is the Unix timestamp (in seconds) at which Sonny Labs signed the request.
  • v1 is the hex-encoded HMAC-SHA256 of {t}.{body} keyed by the webhook's signing secret (the value returned exactly once by POST /v1/webhooks and rotatable via POST /v1/webhooks/{id}:rotate-secret).
  • body is the raw JSON body bytes — verify on the bytes you received off the wire, not on a re-serialised JSON.stringify / json.dumps of 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:

  1. Do not parse or process the body. A bad signature means the payload may have been forged or tampered with in transit.
  2. 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 400 is the correct signal.
  3. Log the Sonny-Event-Id, the receiver-local timestamp, and the first few bytes of the body hash so you can correlate against GET /v1/webhooks/{id}/deliveries while debugging. Never log the full signing secret.
  4. 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 a grace_expires_at timestamp — 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.

On this page