Sonny Labs Docs
SDK ReferencePython

verify_webhook

Constant-time HMAC-SHA256 verification for inbound Sonny Labs webhook deliveries, plus the tolerance and clock-injection knobs.

verify_webhook validates the Sonny-Signature header on an inbound webhook delivery. The backend signs each delivery with Sonny-Signature: t=<unix>,v1=<hex> where <hex> is HMAC-SHA256(secret, "{t}.{body}"). Receivers must verify the MAC and reject deliveries whose timestamp falls outside a 5-minute replay window — the helper enforces both in one call.

Signature

from sonnylabs import verify_webhook

def verify_webhook(
    body: bytes,
    signature_header: str,
    secret: str,
    *,
    tolerance_s: int = 300,
    now: float | None = None,
) -> bool: ...

Returns True if both the HMAC and the timestamp window check pass; False otherwise. Never raises on a malformed header — that's just an invalid signature.

Arguments

Prop

Type

Example: FastAPI

from fastapi import FastAPI, Header, HTTPException, Request
from sonnylabs import verify_webhook

app = FastAPI()

@app.post("/webhooks/sonny")
async def receive(
    request: Request,
    sonny_signature: str = Header(...),
):
    # Read the *raw* bytes — re-serialising the JSON breaks HMAC
    # verification because the digest is over the exact bytes the
    # server signed.
    body = await request.body()
    ok = verify_webhook(body, sonny_signature, settings.SONNY_WEBHOOK_SECRET)
    if not ok:
        raise HTTPException(status_code=400, detail="invalid signature")
    # ... process the event
    return {"ok": True}

Notes

  • Pass body as raw bytes — never a parsed-and-re-serialised representation. Different JSON serialisers reorder keys / re-quote strings differently and HMAC is sensitive to every byte.
  • Multiple v1= entries are tolerated in the header — the SDK checks every candidate so secret rotation (server signing with both old and new secrets) keeps working without coupling the receiver to which secret matched.
  • The now keyword argument exists for tests; production callers should leave it unset so the helper reads from time.time().

On this page