Python receiver for a Sume /v1/videos callback_url, signature checked

A standard-library Python webhook receiver for Sume video jobs: checks x-sume-webhook-signature, refuses an empty secret, rejects stale timestamps.

4 min readSume
All posts

A Sume webhook receiver does four things: read the raw body, check the timestamp is within five minutes, compare an HMAC-SHA256 of timestamp.dot.body against the sume-v1 entries in x-sume-webhook-signature, and only then parse the JSON and return a fast 2xx. The code below does that with the Python standard library and refuses to start without a secret.

What Sume sends on a video job

Add callback_url, an HTTPS public URL, to the POST /v1/videos body, and Sume posts to it when the job is terminal. Sume's job webhook has three events, job.completed, job.failed and job.canceled, and no progress events. The envelope is Sume's own, not the OpenRouter video.generation.* names, and the signature header is x-sume-webhook-signature rather than X-OpenRouter-Signature.

The signing secret is per workspace. Read it from the dashboard Webhooks tab, or from GET /v1/webhooks/signing-secret with a key that has account:read, and export it as SUME_COM_WEBHOOK_SIGNING_SECRET.

Sizing the receiver is simple. A video job produces exactly one terminal event, so a 100-clip batch produces about 100 posts over minutes, not a flood. A single-threaded server handles that. The real risk is slowness: each attempt has a 10-second timeout, so do the durable write and return, and do the heavy work, such as downloading the clip, from a queue.

The signature rules

sume-v1 verification rules (Sume docs, read 2026-10-05)
ItemValue
Signed stringtimestamp, a dot, then the raw body
AlgorithmHMAC-SHA256, hex, prefixed sume-v1=
Headersx-sume-webhook-timestamp, x-sume-webhook-signature
Replay window300 seconds is a reasonable default
During secret rotationSeveral comma-separated sume-v1 entries; accept any match
DeliveryUp to 10 attempts, 10 s timeout each; return 2xx after storing

The receiver

Save as hook.py, export the secret, and run it. It answers 401 on a bad or stale signature and 204 otherwise. Replace the print with a durable write keyed on job_id.

import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
    raise SystemExit("SUME_COM_WEBHOOK_SIGNING_SECRET is empty")

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

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("content-length", 0)))
        h = self.headers
        if not verify(raw, h.get("x-sume-webhook-timestamp", ""), h.get("x-sume-webhook-signature", "")):
            return self.send_response(401) or self.end_headers()
        ev = json.loads(raw)
        print(ev.get("event"), ev.get("job_id"))  # store durably, dedupe on job_id
        self.send_response(204); self.end_headers()

HTTPServer(("", 8080), H).serve_forever()

Why it is written this way

The empty-secret check matters. With an empty key, HMAC still produces a value, and an attacker who knows that can forge a matching signature. Failing at startup removes the case. The loop compares every entry, even after a match, so timing does not tell a caller which secret matched during a rotation window.

The raw bytes are the signed data. If your framework parses JSON before your code runs, the key order and whitespace are gone and nothing verifies, which is why this server reads the body itself.

Finally, test the receiver before real money flows. The dashboard Send test action posts a dummy signed webhook.test payload to a URL you type, and it never replays a real job. Point it at a tunnel to port 8080, and a 204 tells you the secret, the clock and the raw-body handling are all correct.

Before you point Sume at it

  • Put it behind HTTPS on a public host. Sume rejects localhost, private networks and plain HTTP.
  • Treat job_id as the idempotency key on your side, because redelivery and retries can repeat an event.
  • Keep polling as a backup. Delivery is an optimization and the job reaches its real state either way.
  • If a signature fails, compare x-sume-webhook-secret-fingerprint with the fingerprint in the dashboard. Do not paste the secret into a ticket.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume