Verify a Sume webhook in Python when the secret is rotating

A Sume webhook can carry two sume-v1 signatures for 24 hours after rotation. A FastAPI verifier that accepts either, rejects an empty secret, checks the clock.

5 min readSume
All posts

Split the signature header on commas, and accept the delivery if any sume-v1= entry matches the HMAC SHA-256 of <timestamp>.<raw_body>. For 24 hours after you rotate the signing secret, Sume signs each delivery with both secrets, newest first, so a verifier that compares the whole header for equality fails on every request.

The Python below uses only the standard library plus FastAPI, refuses an empty secret, and checks a 300-second replay window.

What the rotation window does

You rotate with the dashboard Webhooks tab or POST /v1/webhooks/signing-secret/rotate using a key with account:write. The header then looks like sume-v1=<new>,sume-v1=<old>. After the window the old secret stops working.

x-sume-webhook-secret-fingerprint names the new secret from the moment you rotate. It tells you which secret to move to, not which ones Sume still accepts.

Rotation timeline for webhook verification (read 2026-10-07)
WhenSignature headerVerifier needs
Before rotationOne sume-v1 entryCurrent secret
First 24 hoursTwo entries, newest firstEither secret
After the windowOne entryNew secret only

The verifier

Run it with uvicorn app:app after pip install fastapi uvicorn. It compares every entry so the timing does not reveal which one matched.

import hashlib, hmac, os, time
from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
    raise RuntimeError("SUME_COM_WEBHOOK_SIGNING_SECRET is required")
app = FastAPI()

def verify(raw: bytes, ts: str, header: str, tolerance=300) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    digest = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    want = f"sume-v1={digest}"
    ok = False
    for part in header.split(","):
        ok |= hmac.compare_digest(part.strip(), want)
    return ok

@app.post("/hooks/sume", status_code=204)
async def hook(request: Request):
    raw = await request.body()
    if not verify(raw, request.headers.get("x-sume-webhook-timestamp", ""),
                  request.headers.get("x-sume-webhook-signature", "")):
        raise HTTPException(status_code=401)

Upgrade before you rotate

Deploy the multi-entry verifier first, rotate second. If you rotate twice inside one window, Sume retires the older secret at once, which is how you make a leak really stop.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume