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.

5 min readSume
All posts

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

Facts behind the design, read 2026-10-02 from docs.sume.com
FactConsequence
Webhook: 10 attempts, 30 s apart, 10 s timeoutOutages over about five minutes lose the automatic deliveries
Redeliver endpoint works after attempts are used upA manual option, but needs you to know which jobs
Reads have their own budget, 40x the write numberA sweep of pending jobs does not touch your submit budget
job_id is the receiver dedupe keySweeper 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 settled

Scheduling 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_s above 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

All Developers posts

Written by Sume