Sume webhook beat your database write: handle an unknown job_id

A fast Sume job can send its webhook before your submit handler commits the job id. Park the event in an inbox table and reconcile it, in runnable Python.

4 min readSume
All posts

What if the webhook arrives before I saved the job id?

Accept it, store it in an inbox keyed by job_id, and let the submit path pick it up once the job row exists. Do not return an error just because the id is unknown: a 4xx or 5xx spends one of the ten automatic attempts, and a 2xx for an event you cannot match yet loses nothing if you saved the body.

The race is real when the submit handler does more after the 202 than write one row. If you create an order, charge a card, and only then commit the transaction that holds request_id, a short generation can finish inside that window. Short clips are the ones most likely to win the race.

Three ways to handle the early event

Pick one and keep it consistent. The inbox is the only option that never depends on timing.

Handling an unknown job_id in a Sume webhook receiver (read 2026-10-06)
OptionWhat Sume seesTrade-off
Inbox table, return 204Delivered on attempt 1You must reconcile after the job row commits
Return 503 and waitFailed attempt, retry after about 30 sBurns one of 10 attempts per miss
Ignore, poll laterDeliveredResult waits for your next poll

Inbox and reconcile in Python

The handler writes the event to the inbox, and attach runs right after you commit the job row. Both are idempotent, so a duplicate delivery does no harm.

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""create table jobs (job_id text primary key, order_id text, state text);
create table inbox (job_id text primary key, body text);""")

def on_webhook(event: dict) -> int:
    db.execute("insert or ignore into inbox values (?, ?)", (event["job_id"], str(event)))
    db.commit()
    return 204

def attach(job_id: str, order_id: str) -> str:
    db.execute("insert or ignore into jobs values (?, ?, 'open')", (job_id, order_id))
    hit = db.execute("select 1 from inbox where job_id = ?", (job_id,)).fetchone()
    if hit:
        db.execute("update jobs set state = 'done' where job_id = ?", (job_id,))
        db.execute("delete from inbox where job_id = ?", (job_id,))
    db.commit()
    return db.execute("select state from jobs where job_id = ?", (job_id,)).fetchone()[0]

on_webhook({"event": "job.completed", "job_id": "job_early"})
print(attach("job_early", "order-7"))
print(attach("job_late", "order-8"))

Sweep the inbox

Rows left in the inbox for longer than your longest submit transaction belong to jobs your service never created, or to a deploy that lost the row. Check them against GET /v1/jobs/{id}/status with the API key that submitted the job: a key reads only the jobs its own member created, so a job from another workspace answers 404. Delete what does not match.

The same reasoning covers the mirror case. If you rely only on webhooks, a job whose event never arrived stays "open" forever in your table. Run a slow sweep over open rows, read each status, and close the ones that are terminal. The poll and the webhook then write the same final state, and whichever arrives first wins without a conflict because both go through the same idempotent update.

Keep the transaction that stores request_id as short as you can, and send the event handler's acknowledgement before any slow work. Both habits shrink the window in which the race can happen, while the inbox makes the remaining window harmless.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume