Sume webhook timestamp window: reject stale deliveries (Python)

The Sume signature covers a timestamp, and the SDK default rejects anything over 300 seconds old. Verify at the edge, then queue, so a late worker never fails.

4 min readSume
All posts

A Sume job webhook is signed over <timestamp>.<raw_body> with HMAC-SHA256, and the timestamp travels in the x-sume-webhook-timestamp header. That timestamp is not decoration. The SDK's verifyWebhook rejects a delivery when the timestamp is more than 300 seconds from the receiver's clock, and it treats a tolerance of 0 as "skip the check". If you write your own verifier, the window is yours to enforce.

The trap is where you enforce it. Teams that push the raw request onto a queue and verify in a worker find that a backlog of ten minutes turns every signed delivery into a failure, even though nothing was forged. Verify at the edge, in the HTTP handler, before you enqueue anything. Store the parsed event, not the headers.

The verifier

This function uses only the Python standard library. It refuses an empty secret, a missing header, a non-numeric timestamp and a stale timestamp, then compares every sume-v1= entry in the header. During a secret rotation Sume can send two comma-separated entries, so a loop that compares all of them keeps both the old and the new secret working.

import hashlib, hmac, time

TOLERANCE = 300  # seconds, the SDK default


def verify(body: bytes, headers: dict, secret: str, now=time.time) -> bool:
    if not secret:
        return False  # never verify against an empty secret
    h = {k.lower(): v for k, v in headers.items()}
    sig, ts = h.get("x-sume-webhook-signature"), h.get("x-sume-webhook-timestamp")
    if not sig or not ts:
        return False
    try:
        sent = int(ts)
    except ValueError:
        return False
    if abs(now() - sent) > TOLERANCE:
        return False  # stale: reject before queueing
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
    want = "sume-v1=" + mac.hexdigest()
    ok = False
    for part in sig.split(","):  # rotation can send two entries
        ok |= hmac.compare_digest(part.strip(), want)
    return ok

Test it without a network

The now argument lets a test move the clock instead of sleeping. The block below signs a body, then checks four cases: a fresh delivery passes, a 301-second-old delivery fails, an empty secret fails, and a body that changed by one byte fails. Run it after the verifier above and it prints ok.

def sign(body, secret, ts):
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
    return {"X-Sume-Webhook-Timestamp": str(ts),
            "X-Sume-Webhook-Signature": "sume-v1=" + mac.hexdigest()}


body, secret, t = b'{"event":"job.completed"}', "whsec_test", 1_000_000
assert verify(body, sign(body, secret, t), secret, now=lambda: t + 10)
assert not verify(body, sign(body, secret, t), secret, now=lambda: t + 301)
assert not verify(body, sign(body, secret, t), "", now=lambda: t)
assert not verify(body + b" ", sign(body, secret, t), secret, now=lambda: t)
print("ok")

What the window does and does not cover

  • It limits replay of a captured request to 300 seconds. It does not make a replay harmless inside that window, so keep a dedupe table keyed on job_id.
  • It depends on your clock. If a host drifts by more than five minutes, valid deliveries fail. Run NTP and alert on 401s from this route.
  • Redelivery from POST /v1/jobs/{job_id}/webhook/redeliver sends a fresh timestamp, so recovering from a late worker does not need a wider window.
  • Do not widen the tolerance to hide a slow queue. Move the check earlier instead.

Keep polling as a backup

Webhooks tell you a job ended. They are not the only record. The jobs guide recommends keeping the status URL as a fallback, and a stale-timestamp rejection is exactly the case where a poll of /v1/jobs/{id}/status recovers the result without anyone resending anything. For the delivery rules themselves, including the terminal-only events and the signature headers, read the webhooks page.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume