Face swap webhook receiver in Python that refuses an empty secret
Verify a Sume face swap job.completed webhook in Python: HMAC SHA-256 over timestamp.body, sume-v1 entries, a 5-minute window, and an empty secret refused.

A Sume face swap job can notify your server when it ends. Send mode webhook and a public HTTPS webhook_url, verify the signature on each delivery with HMAC SHA-256 over timestamp.raw_body, and return a 2xx after you store the event. The verifier below refuses to run with an empty secret, which would otherwise make every forged request valid (as of 2026-10-09).
What arrives
The face swap endpoint documents the same communication modes as other generation submits: async, sync or subscribe with wait_timeout_seconds, and webhook with a public HTTPS webhook_url. Webhook deliveries are terminal events only: job.completed, job.failed and job.canceled. There are no progress events.
| Item | Value |
|---|---|
| Signature header | x-sume-webhook-signature: sume-v1=<hex> |
| Timestamp header | x-sume-webhook-timestamp |
| Signed string | <timestamp>.<raw_body> |
| Replay window suggested | 5 minutes |
| Attempts | Up to 10, 30 s apart by default, 10 s timeout each |
| Your idempotency key | job_id |
The verifier
The signature is computed over the raw body, so read the body bytes before you parse JSON. During secret rotation the header can carry several comma-separated sume-v1 entries, newest first; accept any match. The example loops through all entries and compares each with hmac.compare_digest. The demo signs its own body so it runs on its own; set SUME_COM_WEBHOOK_SIGNING_SECRET first, or it prints False.
import asyncio, hashlib, hmac, os, time
def verify(raw_body: bytes, ts: str, header: str, secret: str, tol: int = 300) -> bool:
if not secret:
return False
try:
t = int(ts)
except ValueError:
return False
if abs(int(time.time()) - t) > tol:
return False
msg = ts.encode() + b"." + raw_body
digest = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
want = "sume-v1=" + digest
ok = False
for entry in header.split(","):
if hmac.compare_digest(entry.strip(), want):
ok = True
return ok
async def main():
secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
body = b'{"event":"job.completed","job_id":"job_1"}'
ts = str(int(time.time()))
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
print(verify(body, ts, sig, secret))
asyncio.run(main())Operational notes
Verify before parsing. Read the raw request bytes and never re-serialize the JSON, because a different key order or spacing changes the digest. Compare the timestamp first, so that a stale delivery is rejected without computing the HMAC.
Use job_id as the key in your own store, since a delivery can arrive more than once. Return 2xx only after the event is stored. If your handler is slow, return quickly and process the event in a queue: each attempt has a 10-second timeout, and a slow endpoint uses up the 10-attempt budget.
The Send test action on the dashboard posts a dummy webhook.test event to a URL you type, which is a safe way to check this verifier before a real face swap job runs. Redeliver re-sends the real event for a job with a fresh signature.
After you verify
Store the event by job_id, return 200, then read the result: for face swap that is a public media.sume.com video URL, and resource_status tells you whether the resource is ready. Keep polling the status URL as a fallback, since after ten refused attempts the job has still finished even though the delivery failed. The job-level details are in Jobs and results.
If you would rather not run a server, polling the status URL every 30 seconds works with no webhook at all.
A last point about the signing secret: Sume derives it for your workspace, and you read it in the dashboard Webhooks tab or from GET /v1/webhooks/signing-secret with a key that has account:read. Store it in an environment variable, not in source.
Sources
Related posts
More in Developers
- A failed Sume video poll has error as a string, not an error object
On /v1/videos, HTTP errors use {error:{code,message}} but a failed poll carries error as a string. Read both without a TypeError in Python and TypeScript.
- ffprobe check for Gemini Omni reference videos: 3 files, 3 s each
Before sending reference videos to Sume's gemini-omni-flash-1.1, check with ffprobe that you have one to three files and each is 3.0 s or shorter. Bash script.
- Find callers still sending nano-banana-2 or gemini-3.1-flash-image
Sume runs the retired nano-banana-2 id as Nano Banana 2.1 and echoes your id back. A repo scan finds stale ids and gemini-3.1-flash-image before you migrate.
- Find exhausted Sume webhook deliveries and redeliver them in Python
Page GET /v1/jobs for completed and failed jobs, keep webhook_delivery.status exhausted, and call POST /v1/jobs/{id}/webhook/redeliver for each.
Written by Sume