Store the Sume job webhook, then answer 204: SQLite insert-or-ignore

Commit the event keyed on job_id before you return 2xx, so a retry or a redeliver is harmless and a crash never loses it. Runnable Python and sqlite3 example.

4 min readSume
All posts

Insert the event into a table keyed on job_id, commit, and only then return a 2xx. If the insert fails, return 500 so Sume retries. If the row already exists, return 204 and do nothing. Process stored rows in a separate worker. This is the order the Sume docs ask for: store the event durably, then return success.

What the docs say to do

The Webhooks page says: after you store the event durably, return any 2xx. Sume retries network errors and non-2xx responses until it uses all attempts, with up to 10 attempts and a 10-second timeout each. It also says to use job_id as the idempotency key on your side, and that Redeliver does not change the destination URL.

Receiver outcomes and what Sume does next (Sume docs, read 2026-10-09)
Your handlerHTTP statusSume's next step
Row committed204Stops retrying this delivery
Row already present (retry or redeliver)204Stops retrying; nothing is processed twice
Database write failed500Retries, up to 10 attempts total
Handler slower than 10 stimeoutCounts as a failed attempt and retries

The handler

INSERT OR IGNORE on a primary key does the dedupe in one statement. The with db: block commits before the function returns. The signature check happens before this function, on the raw body. The example runs as written and uses no network.

import json
import sqlite3

db = sqlite3.connect("webhooks.db")
db.execute(
    "CREATE TABLE IF NOT EXISTS events ("
    "job_id TEXT PRIMARY KEY, body TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)"
)

def on_delivery(raw_body: bytes) -> int:
    """Call after the signature check. Returns the HTTP status to send."""
    event = json.loads(raw_body)
    try:
        with db:  # commit before we answer
            db.execute(
                "INSERT OR IGNORE INTO events (job_id, body) VALUES (?, ?)",
                (event["job_id"], raw_body.decode()),
            )
    except sqlite3.Error:
        return 500  # not stored: let Sume retry
    return 204      # stored (or already stored): stop the retries

def work_loop():
    rows = db.execute("SELECT job_id, body FROM events WHERE done = 0").fetchall()
    for job_id, body in rows:
        print("process", job_id, json.loads(body)["event"])
        with db:
            db.execute("UPDATE events SET done = 1 WHERE job_id = ?", (job_id,))

Why the worker is separate

Doing the slow part inside the handler is how a 12-second handler gets retried and runs twice. With a stored row and a done flag, a crash between store and work leaves done = 0, and the worker picks the row up on restart. A redelivered event finds the row and exits.

Keep the status polls as a backup. Delivery is an optimization, and ten refused attempts leave a failed delivery on a job that has finished.

Why insert-or-ignore

The job id is stable across redeliveries, so a primary key on it turns a duplicate delivery into a no-op. Insert the raw event first, then answer 2xx, and do the slow work from a queue. Sume allows 10 seconds per attempt, so a handler that does real processing inline risks a timeout and another delivery.

A 204 is enough as an acknowledgment. If you return a 4xx or 5xx, Sume treats the attempt as failed and tries again 30 seconds later, up to 10 attempts.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume