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.

5 min readSume
All posts

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.

Job webhook events (Sume docs, read 2026-10-02)
EventWhen it is sentWhat the body carries
job.completedThe job completed and a public result is availablejob_id, status OK, payload.artifacts with the hosted video URL
job.failedThe job failed with a public errorjob_id, status ERROR, an error object
job.canceledThe job reached canceled statejob_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

All Developers posts

Written by Sume