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.

5 min readSume
All posts

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.

Delivery facts from the webhooks page, as of 2026-10-09
ItemValue
Signature headerx-sume-webhook-signature: sume-v1=<hex>
Timestamp headerx-sume-webhook-timestamp
Signed string<timestamp>.<raw_body>
Replay window suggested5 minutes
AttemptsUp to 10, 30 s apart by default, 10 s timeout each
Your idempotency keyjob_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

All Developers posts

Written by Sume