Sume webhook retries for 4.5 minutes: dedupe on job_id in Python

Sume retries a webhook up to 10 times, 30 s apart. Make the effect happen once with a claim row keyed on job_id, shown in runnable Python with SQLite.

4 min readSume
All posts

How do I make a Sume webhook handler run its effect only once?

Insert the job_id into a table with a primary key before you do any work, and do the work only when the insert succeeded. Sume delivers each terminal event at least once, so a slow or failing endpoint sees the same event again. The claim row turns "at least once" into "once" for your side effects.

Sume's webhook docs say to use job_id as the idempotency key. A video that finishes after a 30 second generation is a good example: the event is job.completed, and you may get it twice if your first response was late.

What does the retry schedule look like?

A delivery is accepted by any 2xx response. Everything else, including a timeout, counts as a failed attempt. With ten attempts and a fixed gap between them, the automatic retry stretches over nine gaps. At the default 30 seconds that is 270 seconds of waiting, about four and a half minutes, plus up to 10 seconds for each attempt that times out.

After the last refused attempt you have a failed delivery and a job that is still finished. The job result stays on the status route, so a missed webhook never loses the video.

Sume job webhook delivery limits (docs.sume.com, read 2026-10-06)
SettingValueWhat it means for you
AttemptsUp to 10Expect duplicates until you return 2xx
SpacingFixed, 30 s by defaultNot exponential backoff
Timeout per attempt10 sAcknowledge first, work later
Accepted responseAny 2xxReturn 200 or 204 for a duplicate
Dedupe keyjob_idUse it as the primary key

Claim row in Python with SQLite

The insert is the lock. A second delivery hits the primary key, changes zero rows, and is acknowledged without repeating the work. If your work fails, delete the claim and return a 5xx so the next attempt can run.

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("create table seen (job_id text primary key)")

def claim(job_id: str) -> bool:
    cur = db.execute("insert or ignore into seen values (?)", (job_id,))
    db.commit()
    return cur.rowcount == 1

def handle(event: dict) -> int:
    if not claim(event["job_id"]):
        return 200  # duplicate delivery: accept, do nothing
    try:
        print("effect once for", event["job_id"])
    except Exception:
        db.execute("delete from seen where job_id = ?", (event["job_id"],))
        db.commit()
        return 500  # let Sume retry in 30 s
    return 200

evt = {"event": "job.completed", "job_id": "job_demo_1"}
print(handle(evt), handle(evt))

Keep the poll as a safety net

Verify the signature before the claim, and return fast. If the endpoint was down for the whole retry window, call GET /v1/jobs/{id}/status for jobs you still consider open, or ask Sume to resend with POST /v1/jobs/{job_id}/webhook/redeliver. A redelivery carries a fresh signature, does not use up one of the ten automatic attempts, and hits your claim row the same way.

Two details keep the claim honest. First, claim after you verify the signature, not before, so a forged request cannot burn a real job id. Second, store the claim in the same database transaction as the effect when you can. If the effect and the claim live in different stores, a crash between them leaves you choosing between a lost effect and a repeated one, and the rule above (delete the claim, return 5xx) picks the repeat only when your effect is itself safe to run twice.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume