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.

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