Rolling a webhook secret: Stripe's 24-hour overlap vs Sume's header
Stripe can keep an old signing secret live for up to 24 hours. Sume sends one sume-v1 entry per live secret; verify any match. Python verifier included.

To rotate a webhook signing secret without dropping events, verify against every signature in the header and accept the delivery if any one matches. Stripe's guide describes rolling a secret with an optional delay of up to 24 hours, during which it generates one signature per active secret. Sume's job and run webhooks work the same way: during a rotation the x-sume-webhook-signature header carries one sume-v1= entry per live secret, newest first, separated by commas.
What the header looks like
Sume signs <timestamp>.<raw_body> with HMAC SHA-256, and the timestamp arrives in x-sume-webhook-timestamp. In a rotation the header reads sume-v1=<new>,sume-v1=<previous>. A verifier that only reads the first entry works until the day you rotate, and then your old deployment (still holding the previous secret) rejects every delivery. Compare against all entries.
A verifier that survives rotation
The sketch refuses an empty secret, enforces the five-minute replay window, and compares every entry in constant time.
import hashlib, hmac, time
def verify(raw: bytes, ts: str, header: str, secret: str, tol: int = 300) -> bool:
if not secret:
raise ValueError("empty signing secret")
try:
age = abs(time.time() - int(ts))
except ValueError:
return False
if age > tol:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256)
want = "sume-v1=" + mac.hexdigest()
ok = False
for part in header.split(","):
if hmac.compare_digest(part.strip(), want):
ok = True
return ok
ts = str(int(time.time()))
body = b'{"event":"job.completed"}'
sig = "sume-v1=" + hmac.new(b"new", ts.encode() + b"." + body, hashlib.sha256).hexdigest()
print(verify(body, ts, "sume-v1=bad," + sig, "new")) # TrueRotation checklist
| Item | Stripe | Sume |
|---|---|---|
| Overlap | Old secret can stay active up to 24 hours | Header carries one entry per live secret |
| Header format | Stripe-Signature with t= and v1= | x-sume-webhook-signature: sume-v1=<hex> |
| Replay tolerance | Libraries default to 5 minutes | Reject outside your window; 5 minutes suggested |
| Debug a mismatch | Troubleshooting guide | Compare x-sume-webhook-secret-fingerprint with the dashboard |
Two reminders
Verify against the raw body, byte for byte. Any framework that parses and re-serializes JSON first breaks the signature, a point Stripe's guide stresses too. And read the secret from the dashboard Webhooks tab or GET /v1/webhooks/signing-secret; it is derived per workspace and shared by job and run webhooks, so one verifier covers both.
Rehearsing a rotation
Rotation fails in production for boring reasons: two services read the secret from different environment variables, a cache holds the old value, or the verifier trims the header at the first comma. Rehearse it. Send a test delivery from the dashboard or POST /v1/webhooks/test-deliveries (scope account:write) to a staging URL before and after the rotation. The test body is a dummy webhook.test payload with no job_id, so it exercises signature verification without touching real work.
If a signature does not verify after a rotation, compare fingerprints rather than secrets. Each delivery carries x-sume-webhook-secret-fingerprint, and the dashboard shows the fingerprint next to the secret. A mismatch means your service holds a different secret than the one signing. Neither side ever needs to paste the secret into a ticket.
Store the secret as SUME_COM_WEBHOOK_SIGNING_SECRET, the name the delivery worker uses, so the variable has one obvious home across services.
Sources
Related posts
More in Developers
- Route a Kling video job in Python: scene, motion clip or 30 seconds
A small Python router for Sume: performance copies go to Kling motion control, 4-15 s scenes to kling-3, and longer jobs to a catalog id that lists 30 s.
- Route low-confidence transcripts to review: STT language_probability
Sume STT returns language_code and language_probability. Flag results under a threshold you set and send them to a person. Python, about 10 cents per file.
- Ruby Net::HTTP never follows redirects: saving a Sume video
Net::HTTP returns the 302 from /v1/videos/{id}/content as-is. A 26-line Ruby script submits Seedance 2.5, polls, follows the redirect and saves the MP4.
- Ruby Net::HTTP: POST to Sume images and save the file
Net::HTTP.start with use_ssl and read_timeout, one POST to /v1/images, a string comparison on res.code, and File.binwrite for the image. No gems needed.
Written by Sume