Sume webhook 300-second tolerance: verify on receipt, not later

A queued Sume webhook fails the 300-second timestamp check if verified late. Verify at the edge, then queue the body. Python with a testable clock.

4 min readSume
All posts

A Sume webhook is signed over <timestamp>.<raw_body>, and the docs say to reject a delivery whose timestamp is outside a replay window. Five minutes, 300 seconds, is the suggested default. That check belongs at the moment the request arrives. If you push the raw request onto a queue and verify in a worker, a backlog of more than 5 minutes makes every valid event fail.

The failure pattern

It looks like a signature bug but is a clock bug. The HMAC is correct, the secret is right, and verify returns false only for old events. The same code passes in a test run where the worker picks up the message in a second. It fails in production when the worker was down for ten minutes.

The fix

  • Verify the signature and the timestamp in the HTTP handler, before you answer.
  • Queue the verified body, not the headers. After that the timestamp has done its job.
  • Dedupe on job_id. A redelivery carries a fresh timestamp and signature, but it is the same event.
  • Keep the server clock on NTP. A clock that is off by more than 300 seconds rejects good deliveries.

What redeliver changes

POST /v1/jobs/{job_id}/webhook/redeliver sends the real terminal event again with a fresh timestamp and a fresh signature. Thus a redelivery after an outage passes the tolerance check even though the first attempt was hours ago. It does not use one of the 10 automatic attempts.

Verifier with a clock you can control

The function takes now as an argument, so a test can prove both the accept and the reject paths. It refuses an empty secret and accepts any entry in a comma-separated rotation header.

import hashlib, hmac, time

def verify(raw: bytes, ts: str, header: str, secret: str, tolerance=300, now=None) -> bool:
    if not secret:
        raise RuntimeError("empty webhook secret")
    now = time.time() if now is None else now
    if abs(now - int(ts)) > tolerance:
        return False
    mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(p.strip(), "sume-v1=" + mac) for p in header.split(","))

raw, ts, secret = b'{"job_id":"job_1"}', "1780000000", "s3cret"
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
print(verify(raw, ts, sig, secret, now=1780000100))  # True, 100 s old
print(verify(raw, ts, sig, secret, now=1780000400))  # False, 400 s old

Choose the tolerance on purpose

Do not raise 300 to a day to make a slow queue pass. A wide window helps an attacker who has captured one delivery to replay it. Fix the order of operations instead.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume