One Python verifier for Sume job and run webhooks, routed on event

Job webhooks and run webhooks share one signing secret and one signature scheme, so one verify function plus a router on the event field covers both.

4 min readSume
All posts

You need one signature check for every Sume webhook, because job webhooks and run webhooks sign the same way with the same workspace secret. What differs is the event name, so route on event after the check passes.

The Webhooks page says job webhooks and run webhooks share that one secret, so a single verifier covers both. Splitting them into two code paths is the usual way a receiver ends up verifying one kind and silently rejecting the other.

Two surfaces, one scheme

Which events you get depends on what you called. A generation model endpoint sends job events; an Action, Format or Agent Completion run endpoint sends run events (read 2026-10-03).

Which Sume webhook events come from which call (read 2026-10-03)
You calledEventsIdentifier to dedupe on
A model endpoint such as POST /v1/avatar-1.0/generatejob.completed, job.failed, job.canceledjob_id
A Format run endpointformat.run.terminalrun_id (request_id repeats it)
An Action run endpointaction.run.terminalrun_id
An Agent Completion runagent.run.terminalrun_id

The verify function and the router

The signature is HMAC-SHA256 over <timestamp>.<raw_body>, sent as x-sume-webhook-signature: sume-v1=<hex>, with the timestamp in x-sume-webhook-timestamp. During a secret rotation the header holds one entry per live secret, newest first, so the function compares every entry. It returns False for an empty secret rather than signing with an empty key.

The block below is self-contained: it signs a sample body, verifies it with a stale old entry in front, checks the empty-secret refusal and routes the event. It prints True, False, then the routed tuple.

import hashlib, hmac, json, time

def verify(secret, raw, ts, header, tolerance=300):
    if not secret or not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256)
    want = "sume-v1=" + mac.hexdigest()
    return any([hmac.compare_digest(e.strip(), want) for e in header.split(",")])

def route(event):
    kind = event["event"]
    if kind.startswith("job."):
        return ("job", event["job_id"], kind.split(".")[1])
    if kind.endswith(".run.terminal"):
        return ("run", event["run_id"], event.get("outcome"))
    return ("ignored", None, kind)

secret, raw = "s3cret", b'{"event":"format.run.terminal","run_id":"r1","outcome":"ok"}'
ts = str(int(time.time()))
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
old = "sume-v1=" + "0" * 64
print(verify(secret, raw, ts, f"{old},{sig}"))
print(verify("", raw, ts, sig))
print(route(json.loads(raw)))

Using it in a handler

In a web framework, pass await request.body() or the equivalent raw bytes as raw, never a re-serialized dict, because re-encoding changes whitespace and key order and the HMAC no longer matches. Reject with 401 when verify is False, store the event, then answer 2xx.

Treat the route result as a hint, not a contract: unknown events fall into ignored, so a new event type does not crash the receiver. When a signature fails that you expected to pass, compare x-sume-webhook-secret-fingerprint with the fingerprint beside the secret in the dashboard, as described in compare the secret fingerprint.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume