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.

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.
| Moment | Newest signature | Also signed with | A still verifies? |
|---|---|---|---|
| Before any rotation | A | none | Yes |
| After rotating once (B) | B | A for 24 h | Yes, until the window ends |
| After rotating again inside the window (C) | C | B | No, 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"])) # TrueWhat to check around a rotation
- The
x-sume-webhook-secret-fingerprintheader names the new secret from the moment you rotate. It does not tell you which secrets Sume still accepts. rotation.previous_valid_untilon both rotation API responses gives the deadline of the open window.- Rotation needs a key with
account:write. Reading the secret needsaccount: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
- Seedance 2.5 job failed: refund, new idempotency key, and rerun cost
A failed Seedance 2.5 job is refunded on Sume. Retry with a new Idempotency-Key; the old one replays the failed job. A Python handler and the rerun cost.
- Low-latency TTS without streaming: one job per sentence
Sume TTS has no streaming. To start playback early, split the script by sentence, submit the jobs in parallel and play each file as it finishes, in order.
- Shadow-run gpt-image-2.5 beside your current model before October 23
Render one prompt on a Sume image id and your current model with a separate idempotency key per model, then compare cost and output before gpt-image-1 ends.
- Shortest and longest values Sume accepts for a Short: one table
Minimum clip, slot, spine and fade values and the maximums for trim, transitions and soundtrack on Sume, in one table with an offline slot checker in Python.
Written by Sume