Python handler for the Sume agent.run.terminal webhook

Verify the HMAC over timestamp.raw_body, refuse an empty secret, dedupe on request_id and branch on outcome. Runs offline with a self-test.

5 min readSume
All posts

An agent that calls a workflow step and waits on a webhook needs a handler that does four things in order: refuse to run with an empty secret, verify the signature over the raw body, drop duplicates, and branch on outcome. Sume signs run webhooks with HMAC-SHA256 over <timestamp>.<raw_body>, so the handler must hash the bytes it received, not a re-serialized copy of the JSON.

Delivery details are in Sume's Run webhooks; the event for Agent Completions is agent.run.terminal, described in Agent Completions.

What does Sume send?

Each delivery carries x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>. The envelope has status of OK or ERROR, an outcome of ok, degraded or error, and a request_id equal to the run id. The replay window is five minutes. Sume makes up to 10 attempts with a 10-second timeout and does not follow redirects, so answer with a 2xx fast and do the work after. A payload over 1 MiB arrives with payload: null and a result_url.

Handler decisions (read 2026-10-03)
CheckFail action
Secret is emptyRaise at startup
Timestamp older than 5 minutesReject with 400
Signature mismatchReject with 401
request_id seen beforeReturn 200, skip work
outcome is degraded or errorRoute to review, not to publish

What does the code look like?

The secret comes from the dashboard or GET /v1/webhooks/signing-secret; keep it as SUME_COM_WEBHOOK_SIGNING_SECRET. This sample includes a self-test, so it runs without a network.

import hashlib, hmac, json, time

def verify(secret, ts, sig, raw, now=None):
    if not secret:
        raise ValueError('empty webhook secret')
    if abs((now or time.time()) - int(ts)) > 300:
        return False
    mac = hmac.new(secret.encode(), ts.encode() + b'.' + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest('sume-v1=' + mac, sig)

seen = set()
def handle(secret, ts, sig, raw):
    if not verify(secret, ts, sig, raw):
        return 'rejected'
    body = json.loads(raw)
    if body['request_id'] in seen:
        return 'duplicate'
    seen.add(body['request_id'])
    return 'review' if body['outcome'] != 'ok' else 'publish'

raw = json.dumps({'request_id': 'r1', 'outcome': 'ok'}).encode()
ts = str(int(time.time()))
sig = 'sume-v1=' + hmac.new(b's', ts.encode() + b'.' + raw, hashlib.sha256).hexdigest()
print(handle('s', ts, sig, raw), handle('s', ts, sig, raw))

What are the common mistakes?

  • Parsing the JSON and re-serializing it before hashing. Use the exact bytes.
  • Treating status OK as success. A degraded outcome still needs a look.
  • Waiting for a webhook on a canceled run. A canceled run delivers none, so poll its status.
  • Keeping the dedupe set only in memory in production. Use a store that survives a restart.

What about the official SDK?

@sume-com/sdk ships verifyWebhook for Node. Use it if your handler is JavaScript; the Python above does the same arithmetic for services that are not.

Return the 2xx before slow work such as downloading media or calling another service. Sume waits at most 10 seconds for your response and retries up to 10 times, so a handler that blocks will produce duplicates, which the request_id check then absorbs. Store the id only after the work is queued, not before, so a crash in between leads to a retry instead of a lost event.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume