Python 3.10 is end of life: a stdlib Sume webhook verifier

Python 3.10 has reached end of life. A standard-library verifier for Sume's signed webhooks that refuses an empty secret and accepts rotated signatures.

4 min readSume
All posts

Python 3.10 has reached end of life according to the Python downloads page, so plan to run webhook receivers on a supported release. The code below is a Sume webhook verifier that uses only the standard library, so it runs unchanged on 3.11 and later and has nothing to upgrade except the interpreter.

Sume does not document a Python SDK; the official client is TypeScript. In Python you call the HTTP API directly and verify webhooks yourself.

What the Python page lists

Relevant items only.

Python downloads page (read 2026-10-03)
ItemWhat the page says
3.14.8Current 3.14 release
3.10End of life reached
3.15Pre-release

What the signature covers

Sume signs the raw body with HMAC SHA 256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex>. During a rotation the header carries one entry per live secret, newest first, comma separated, and you accept the delivery if any entry matches. Reject callbacks outside a replay window; five minutes is the documented default.

The verifier

Pass the raw request bytes, not a parsed and re-serialized object. The function refuses an empty secret, which would otherwise make every signature trivially forgeable, and it compares every entry so timing does not reveal which one matched.

import hashlib
import hmac
import time


def verify_sume_webhook(raw_body: bytes, timestamp: str, signature_header: str,
                        secret: str, tolerance: int = 300) -> bool:
    if not secret or not signature_header:
        return False
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(int(time.time()) - ts) > tolerance:
        return False
    signed = str(ts).encode() + b"." + raw_body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    expected = f"sume-v1={digest}".encode()
    matched = False
    for entry in signature_header.split(","):
        if hmac.compare_digest(entry.strip().encode(), expected):
            matched = True
    return matched

Using it in a handler

Whatever framework you use, the order is the same.

  • Read the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name the delivery worker signs with. You can reveal it on the Webhooks tab of the dashboard.
  • Call the verifier with the raw bytes and both headers. On False, return a non-2xx status and stop.
  • Parse the JSON only after verification, and store the event keyed by job_id before answering 2xx.
  • Job webhooks and run webhooks share one signing secret, so the same function covers both.

A quick self-test

Sign a sample body with a known secret and confirm the function accepts it, rejects a changed body, and rejects an empty secret. If you build the test header by hand, remember the format is sume-v1= followed by the hex digest.

if __name__ == "__main__":
    secret, body = "test-secret", b'{"event":"job.completed"}'
    ts = str(int(time.time()))
    sig = hmac.new(secret.encode(), ts.encode() + b"." + body,
                   hashlib.sha256).hexdigest()
    header = f"sume-v1={sig}"
    print(verify_sume_webhook(body, ts, header, secret))        # True
    print(verify_sume_webhook(body + b" ", ts, header, secret)) # False
    print(verify_sume_webhook(body, ts, header, ""))            # False

Moving off 3.10

The verifier does not depend on anything introduced after 3.10, so it is safe to deploy before the upgrade, which makes it a low-risk first piece to move. When you do upgrade, rerun the self-test on the new interpreter and check that the framework you use to read raw request bytes still returns bytes, not text. A framework that decodes the body for you will break the signature, because the signature is computed over the exact bytes Sume sent.

If your service polls jobs instead of receiving webhooks, the same standard library covers it. Poll GET /v1/jobs/{id}/status with backoff, stop on terminal, and read the result once result_ready is true.

Delivery behavior to remember

Sume retries a delivery up to 10 times with a fixed spacing, 30 seconds by default, and a 10 second timeout per attempt. Answer 2xx only after the event is stored. Delivery is an optimization, not the only recovery path, so keep status_url polling available for events that never arrive.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume