Store the Idempotency-Key before you submit: a SQLite intent table
Write key and body to SQLite, submit with that key, then store the job id. A crash between steps replays the same key and returns the original Sume job.

To make a Sume submit crash-safe, write the intent and its Idempotency-Key to durable storage before the HTTP call, send the request with that key, and store the job id from the response. If the process dies between any two steps, the next run finds the row and reuses the key, so the retry returns the original job instead of creating and billing a second one.
The Sume docs say to send Idempotency-Key on submit requests when retrying after client-side timeouts or network failures, and to reuse it only for the same operation and payload. A table keyed by that value is the smallest piece of state that makes both rules automatic.
The three crash points
There are three places a process can die. Each one needs a different recovery, and the intent table covers them all (the endpoint and fields are from the Sume jobs docs, read 2026-10-03).
| Crash point | Row state on restart | Recovery |
|---|---|---|
| Before the row is written | No row | Nothing was sent; start normally |
| After the row, before the response | key set, job_id empty | Resend with the same key; Sume returns the original job if it was accepted |
| After the response, before the update | key set, job_id empty | Same as above; the replay returns the job id again |
| After the update | job_id set | Skip the submit; poll the job |
The code
It uses only the standard library. The body is serialized with sorted keys so the same intent always produces the same text, which doubles as the lookup column. The response field request_id is the job id, as documented for the generate endpoints. Set SUME_API_KEY before running.
import json, os, sqlite3, urllib.request, uuid
db = sqlite3.connect("intents.db")
db.execute("create table if not exists intent (key text primary key, body text unique, job_id text)")
def submit(body: dict) -> str:
payload = json.dumps(body, sort_keys=True)
row = db.execute("select key, job_id from intent where body = ?", (payload,)).fetchone()
if row and row[1]:
return row[1]
key = row[0] if row else str(uuid.uuid4())
if not row:
db.execute("insert into intent (key, body) values (?, ?)", (key, payload))
db.commit()
req = urllib.request.Request(
"https://api.sume.com/v1/image-1.0/generate",
data=payload.encode(),
headers={"Authorization": "Bearer " + os.environ["SUME_API_KEY"],
"Content-Type": "application/json", "Idempotency-Key": key},
)
with urllib.request.urlopen(req) as resp:
job_id = json.load(resp)["request_id"]
db.execute("update intent set job_id = ? where key = ?", (job_id, key))
db.commit()
return job_id
print(submit({"prompt": "Product hero shot of a matte black bottle", "mode": "async"}))Limits of this pattern
Two identical bodies are treated as one intent, so a second request for the same image needs a different body, for example a seed or a prompt change. If you want deliberate duplicates, add your own business key to the table instead of keying on the body.
The key never changes for an intent, which also means you must not edit the stored body after the first send, because the same key with a different payload is a 409 idempotency_conflict. Once job_id is set, switch to polling, and use list and recover for rows that were stored without an id. Keys longer than your own business key, up to 255 characters, are covered in hashing long business keys.
Sources
Related posts
More in Developers
- SSML in text to speech: Sume takes a plain transcript, no ssml field
Does Sume's text to speech accept SSML? The tts_create body has a plain transcript and rejects unknown keys. What to use for speed, volume, emotion and pauses.
- Stippled AI graphics turn to gray mush when resized: a downscale test
A dotted AI graphic can lose most of its contrast when downscaled. A Pillow test of nearest, bilinear and Lanczos on a stipple, and what to request instead.
- Streaming transcripts: burn only final text into clip captions
MAI-Transcribe-2-Streaming returns partials in about 100 ms, then settled text. Burned-in captions need the final text. Filter it and pass it to Sume as cues.
- Stripe branding icon: JPG or PNG under 512 KB, 128 px up
Stripe wants a JPG or PNG icon under 512kb and at least 128 x 128 px. Make a 1:1 mark on Sume, shrink it in Pillow, and know where the icon shows up.
Written by Sume