Seedance webhook on Sume: callback_url and a Python signature check
Get a signed webhook when a Seedance or Kling job ends: send callback_url to /v1/videos, verify the HMAC in Python, and keep polling as the fallback.

To get a webhook when a Seedance video finishes on Sume, add callback_url (a public HTTPS URL) to the POST /v1/videos body. Sume POSTs one signed event when the job reaches a terminal state, and your server checks the x-sume-webhook-signature header against the raw body before trusting it. The same works for Kling: only the model id changes.
Webhooks save you from a polling loop on clips that take minutes, but they are a delivery hint, not a guarantee. This post shows the request, a verifier in Python that refuses to run without a secret, and the polling fallback the webhooks docs tell you to keep.
How do you ask Sume for a webhook on a video job?
On the OpenRouter-shaped route, the field is callback_url. The video generation docs say it must be HTTPS and that Sume POSTs to it once the job reaches a terminal state. On the older Video Router route the equivalent is mode: "webhook" with webhook_url; the docs list callback_url as an alias for webhook_url on the generation surface. Localhost, private-network and non-HTTPS URLs are rejected.
Send an Idempotency-Key too, so a retried submit returns the original job instead of a second paid one. The model id is a bare catalog id such as seedance-2.5 or kling-3.
curl -X POST https://api.sume.com/v1/videos \
-H "Authorization: Bearer $SUME_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: spot-0042-v1" \
-d '{
"model": "seedance-2.5",
"prompt": "A barista pours oat milk into a latte, slow push-in, warm morning light",
"duration": 8,
"resolution": "720p",
"aspect_ratio": "9:16",
"callback_url": "https://hooks.example.com/sume"
}'What does Sume send, and when?
Sume sends terminal events only. There are no progress or partial deliveries, so a webhook cannot tell you that a clip is 40 percent done. The payload is Sume's standard job envelope, not OpenRouter's video.generation.* envelope, and the signature header is x-sume-webhook-signature, not OpenRouter's.
| Event | When it is sent | What the body carries |
|---|---|---|
| job.completed | The job completed and a public result is available | job_id, status OK, payload.artifacts with the hosted video URL |
| job.failed | The job failed with a public error | job_id, status ERROR, an error object |
| job.canceled | The job reached canceled state | job_id, status ERROR, an error object |
How do you verify the signature in Python?
When signing is configured, Sume computes an HMAC SHA-256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex>. During a secret rotation the header carries one sume-v1= entry per live secret, comma-separated, so accept the delivery if any entry matches. Read the secret from the dashboard Webhooks tab or from GET /v1/webhooks/signing-secret, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET.
Verify against the raw bytes you received, before any JSON parsing, and reject timestamps outside a window (five minutes is the docs' suggested default). The function below returns False when the secret is empty, so a missing environment variable can never turn into accept-everything.
import hashlib, hmac, os, time
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
def verify(raw: bytes, timestamp: str, header: str, tolerance: int = 300) -> bool:
if not SECRET or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw
digest = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
want = "sume-v1=" + digest
matched = False
for entry in header.split(","):
if hmac.compare_digest(entry.strip(), want):
matched = True
return matched
# In your handler: verify(request_body_bytes,
# headers["x-sume-webhook-timestamp"],
# headers["x-sume-webhook-signature"])Why keep polling next to the webhook?
A receiver can be down, a deploy can swallow a request, or a signature can fail during a rotation. The docs recommend keeping the polling fallback in place: store the job id from the submit response and, if no event has arrived after a sensible time, read GET /v1/jobs/{id}/status and then /result. Both routes see the same job, so the poll is safe to run beside the webhook.
If a signature does not verify, compare x-sume-webhook-secret-fingerprint on the delivery with the fingerprint shown next to the secret in the dashboard. Neither side has to send the secret itself.
Treat the job as the source of truth and the webhook as the nudge: on job.completed, fetch the result from the job rather than trusting the body alone, and make your handler idempotent on job_id in case an event is delivered twice.
What does this not do?
Sume does not push progress, and it does not tell you which upstream provider ran the clip for sume/auto. A webhook also cannot rescue a request that was rejected at submit: a 400, 402 insufficient_credits or 429 queue_full comes back synchronously, before any job exists, so check the submit response status before you wait for an event.
For how queued and processing states behave under your plan's concurrency, see Generation admission; for the four communication modes side by side, see Jobs and results.
Sources
Related posts
More in Developers
- Shotstack render statuses vs Sume job statuses: a map
Shotstack renders go queued, fetching, rendering, saving, done or failed. Sume jobs go queued, processing, completed, failed or canceled. Map them in code.
- Cartesia sonic-3.6-2026-08-27 snapshot: which id Sume accepts
Cartesia's dated snapshot ids never change, but Sume's TTS Router lists only sonic-3.6, 3.5, 3, latest and preview. Here is what that means for repeat takes.
- Sonic 3.5 to 3.6 on Sume TTS: change model, keep the voice id
Cartesia says Sonic 3.6 keeps the voice ids of 3.5. On Sume's TTS Router, move a call from sonic-3.5 to sonic-3.6 by changing only the model field.
- sonic-preview voice_model_mismatch: use sonic-3.6 for clones
A TTS job on sonic-preview fails with voice_model_mismatch when the voice is a pro voice clone. Send model sonic-3.6 to the Sume TTS Router instead.
Written by Sume