Sume webhook never arrived: a sweeper that settles pending jobs
Run a small timer job that reads status for jobs still pending after ten minutes and settles them with the same guarded update your webhook route uses.

If a Sume webhook never reaches you, the job still finished; only the notification is missing. Fix it with a sweeper: a timer that selects your local rows still marked pending after a grace period, reads GET /v1/jobs/{id}/status for each, and settles the terminal ones with the same guarded UPDATE ... WHERE state = 'pending' your webhook route uses. The 27-line Python function below is that sweeper, standard library only.
The docs say webhooks are a delivery optimization and not your only recovery path: Sume tries up to 10 times, 30 seconds apart, and ten refused attempts leave a failed delivery and a job that still reached its real terminal state. A sweeper is the polling backup the docs tell you to keep.
Why this is cheap and safe
| Fact | Consequence |
|---|---|
| Webhook: 10 attempts, 30 s apart, 10 s timeout | Outages over about five minutes lose the automatic deliveries |
| Redeliver endpoint works after attempts are used up | A manual option, but needs you to know which jobs |
| Reads have their own budget, 40x the write number | A sweep of pending jobs does not touch your submit budget |
| job_id is the receiver dedupe key | Sweeper and webhook can both run |
The sweeper
It assumes a jobs table with id, state and submitted_at, and sets state to the Sume status. A transient failure (URLError covers connection errors and HTTP errors) skips the job until the next sweep. The AND state = 'pending' guard makes it race-free against the webhook handler: whichever runs second changes zero rows.
import json, os, sqlite3, time, urllib.error, urllib.request
BASE = os.environ.get("SUME_BASE_URL", "https://api.sume.com")
TERMINAL = {"completed", "failed", "canceled"}
def read_status(job_id: str) -> dict:
req = urllib.request.Request(f"{BASE}/v1/jobs/{job_id}/status",
headers={"Authorization": f"Bearer {os.environ['SUME_API_KEY']}"})
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)["data"]
def sweep(db: sqlite3.Connection, grace_s: int = 600, now=time.time) -> int:
"""Settle jobs whose webhook never arrived. Safe to run on a timer and next to the webhook route."""
cutoff = now() - grace_s
rows = db.execute("SELECT id FROM jobs WHERE state = 'pending' AND submitted_at < ?", (cutoff,)).fetchall()
settled = 0
for (job_id,) in rows:
try:
s = read_status(job_id)
except urllib.error.URLError:
continue # 429, 5xx, network: the next sweep tries again
if s["sume_status"] in TERMINAL:
with db: # the same guarded update the webhook handler uses
cur = db.execute("UPDATE jobs SET state = ? WHERE id = ? AND state = 'pending'",
(s["sume_status"], job_id))
settled += cur.rowcount
return settledScheduling it
Given four rows, it settles the two old pending ones, leaves a fresh pending row and an already completed row alone, and a second sweep changes nothing.
- Run it every minute or two from cron, a systemd timer or your framework's scheduler.
- Keep
grace_sabove the webhook retry horizon you can tolerate; 600 seconds is a sensible start because most jobs notify well inside it. - After settling a job, run the same follow-up your webhook route runs (fetch result, notify). Put that in one function both call.
Limits
A 404 from the status read is also swallowed by URLError, so a job id that does not exist for your key will be retried forever; add a counter or check HTTPError.code if that is possible in your data. The sweeper reads one job at a time, which is fine for hundreds of pending rows but wasteful for thousands; at that size list jobs with a status filter and join locally instead. The job's real result still needs fetching after the state flips.
Sources
Related posts
More in Developers
- Test a Sume webhook receiver with node:test and signed fixtures
Three node:test cases that sign their own Sume webhook bodies: fresh, rotation header, and the three rejections. Runs with node --test.
- TikTok upload chunk rules: 5 MB to 64 MB, up to 1,000 chunks
TikTok FILE_UPLOAD chunks must be 5 to 64 MB, the last up to 128 MB, 1,000 chunks max. A short planner computes the Content-Range for each PUT.
- TikTok Direct Post post_info fields: title, is_aigc, duet, stitch
A checked list of TikTok post_info fields as of October 2026, with a validator for title length in UTF-16 units, privacy level and the is_aigc AI label.
- Timeline 1.0 warnings after stitching shots: which need a fix
Timeline 1.0 returns soft warnings, not failures. A list of the codes you will see on a stitched AI film, what each means, and which ones are worth acting on.
Written by Sume