Webhook events arrive out of order: Sume sends one event per job
Stripe does not guarantee event order. Sume job webhooks send terminal events only, so key on job_id, dedupe, and poll status when a callback never arrives.

Do not write a webhook handler that depends on event order. Stripe's webhook guide says it does not guarantee that events arrive in the order they were generated, and it tells you not to use the created timestamp to decide order or whether you already processed an event. Sume avoids most of the problem by design: a generation job webhook is a terminal event only (job.completed, job.failed, job.canceled), with no progress or partial deliveries. One job produces one terminal state, so the only ordering question left is duplicates and late arrivals.
What can still go wrong with a terminal-only event
Even with one event per job, three things happen in practice. A retry can deliver the same event twice. A poll can see completed before the callback lands. And after ten refused attempts the job is still terminal, but your system never got told. Sume's docs say to use job_id as the idempotency key on your side, and to keep status_url polling as a backup because delivery is an optimization, never the only recovery path.
Stripe gives the same advice from the other direction: log the event IDs you have processed, and retrieve the object from the API when an event shows up before the thing it depends on.
- Key every write on
job_id(job webhooks) orrequest_id(run webhooks), never on arrival time. - Treat the webhook as a hint to read the job, not as the source of truth:
GET /v1/jobs/{job_id}/statusis authoritative. - Make the handler safe to run twice. The second run should change nothing.
- Return a
2xxonly after the event is stored durably.
A handler that ignores order
The sketch below records the first terminal event for a job and ignores every later copy. Swap SQLite for your own store. The point is the primary key on job_id.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("create table seen(job_id text primary key, status text)")
def first_delivery(event: dict) -> bool:
cur = db.execute(
"insert or ignore into seen values (?, ?)",
(event["job_id"], event["status"]),
)
db.commit()
return cur.rowcount == 1
event = {"event": "job.completed", "job_id": "job_123", "status": "OK"}
print(first_delivery(event)) # True: process it
print(first_delivery(event)) # False: duplicate, skipStripe and Sume side by side
| Question | Stripe | Sume job webhooks |
|---|---|---|
| Order guaranteed? | No. Do not use created for ordering | Terminal events only, one per job |
| Dedupe key | Event ID | job_id |
| Missing event | Retrieve the object from the API | Poll status_url, or redeliver |
| Success response | Any 2xx, returned quickly | Any 2xx after you store the event |
Build the poller before you need it
A cron that lists your open jobs and reads their status costs little and closes the gap that no retry policy closes. Do not resubmit a paid job because a callback was late: the job is already running and billing, and resubmitting only creates a second charge.
Why the status field helps you too
Sume's job webhook body carries status: "OK" for a completed job and status: "ERROR" plus an error object for failed and canceled jobs, alongside event, request_id and job_id. That makes a first-pass handler simple: branch on event, write the row keyed by job_id, and enqueue the real work for later. Do not fetch media, call a model or update an invoice inside the request, because the delivery attempt has a 10-second timeout and a slow endpoint spends the retry budget.
Run webhooks for Formats, Actions and Agent Completions have a different payload (a full run receipt) and a different event name (format.run.terminal and its siblings). The signature scheme is identical, so the verifier you write once covers both, but the dedupe key there is request_id. Mixing the two keys in one table is a common source of silent duplicates.
Sources
Related posts
More in Developers
- Webhook receiver that queues finished SKU videos for human approval
Verify the format.run.terminal signature, dedupe on run_id, and park each finished SKU video as pending review before anything is published. Runnable Python.
- Max text length for Gemini and OpenAI TTS: the docs name none
The OpenAI TTS guide and the Gemini speech page I read state no input limit. Sume publishes 20,000 characters and 1,200 s; here is a splitter.
- What to log from a TTS job result so you can recreate a voiceover
A finished Sume TTS 1.0 job echoes model_id, voice, language, output_format and generation_config. Save them with the audio URL to rebuild the same take.
- What to store from a Sume result: job and run artifact columns
Store artifact id, durable media.sume.com URL, type and content type for jobs, and size, width, height, duration and sha256 for runs. SQLite schema included.
Written by Sume