Rotate the Sume webhook secret twice in 24 hours: the oldest one dies

One rotation keeps the old secret valid for 24 hours. A second rotation inside that window retires the secret from two rotations ago. Verifier in Python.

5 min readSume
All posts

A single rotation of the Sume webhook signing secret is not a cutover: for 24 hours Sume signs each delivery with both the new and the previous secret. If you rotate again inside that window, Sume retires the secret from two rotations before immediately. The SDK webhook docs call this the way to make a leak really stop.

That matters if you rotate because a secret leaked. One rotation leaves the leaked secret accepted for up to a day. Two rotations back to back leave only the newest two secrets live, and the leaked one is gone.

What is live after each rotation

Call the starting secret A. Rotate once to get B, and again to get C inside the same window.

Secret states per the Sume SDK webhook docs, read 2026-10-06
MomentNewest signatureAlso signed withA still verifies?
Before any rotationAnoneYes
After rotating once (B)BA for 24 hYes, until the window ends
After rotating again inside the window (C)CBNo, retired at once

A verifier that shows it

The delivery carries x-sume-webhook-signature: sume-v1=<newest>,sume-v1=<previous>. Accept it when any entry matches any secret you hold. The sample refuses an empty secret and checks the 300-second timestamp window.

import hashlib, hmac, time

def sign(secret, ts, body):
    return hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()

def verify(body, headers, secrets, tolerance=300):
    if not secrets or not all(secrets):
        raise ValueError("refusing to verify with an empty secret")
    ts = headers["x-sume-webhook-timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False
    got = [p.removeprefix("sume-v1=") for p in headers["x-sume-webhook-signature"].split(",")]
    return any(hmac.compare_digest(sign(s, ts, body), g) for s in secrets for g in got)

body, ts = b'{"event":"job.completed","job_id":"job_1"}', str(int(time.time()))
# Rotated twice in one window: Sume now signs with C (newest) and B only.
header = {"x-sume-webhook-timestamp": ts,
          "x-sume-webhook-signature": f"sume-v1={sign('C', ts, body)},sume-v1={sign('B', ts, body)}"}
print("receiver still on A:", verify(body, header, ["A"]))  # False: A is retired
print("receiver on B:", verify(body, header, ["B"]))        # True until the window ends
print("receiver on C:", verify(body, header, ["C"]))        # True

What to check around a rotation

  • The x-sume-webhook-secret-fingerprint header names the new secret from the moment you rotate. It does not tell you which secrets Sume still accepts.
  • rotation.previous_valid_until on both rotation API responses gives the deadline of the open window.
  • Rotation needs a key with account:write. Reading the secret needs account:read.
  • After a double rotation, any receiver still holding A rejects every delivery. Roll B or C out first, then rotate again.

Recovering a rejected delivery

Once the receiver holds the right secret, POST /v1/jobs/{id}/webhook/redeliver (needs jobs:write) sends the terminal event again with a fresh timestamp and signature. Deduplicate on job_id, since a redelivery is the same event.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume