Transactional outbox for paid API calls in Python (Sume)
Write the Sume request and its Idempotency-Key in the order's transaction, drain later: a tested Python outbox that survives crashes, 429s and 409s.

To call a paid API from an order flow without losing or doubling the call, write the request into an outbox table in the same database transaction as the order, then let a separate drain loop send it. For Sume, store the Idempotency-Key in that row, so a crash or a 5xx between send and save is repaired by sending the same key again, which returns the original job instead of creating a second one.
The pattern is old, but it fits a job API well because Sume documents the other half: a replay of the same key and the same body returns the original job with idempotency_hit: true, and a 5xx never proves the work was refused.
Why does a plain POST after the order insert fail?
Two writes to two systems cannot share a transaction. If you insert the order and then call the API, a crash in between leaves an order with no image. If you call first and insert second, a crash leaves a paid job nobody tracks. Retrying blindly is only safe when the retry cannot create a second job, and Sume's OpenAPI says the key is what makes it so: send one on every paid create you might retry, because with a key the retry adopts whatever the first call created.
The outbox turns the problem around. The order and the intent to call commit together, so the only open question left is delivery, and delivery is repeatable.
| Failure | Plain POST | Outbox with a stored key |
|---|---|---|
| Crash after order insert, before the call | Order without a job | Row waits, next drain sends it |
| Request times out after Sume accepted it | Retry may create a second job without a key | Same key returns the original job |
5xx response | Docs: never proves the work was refused | Retry with the same key adopts the first create |
429 rate_limited or queue_full | Caller must remember to retry | Row stays, retried next drain |
| Same key, different body | Not applicable | 409 idempotency_conflict naming the existing job |
What does the outbox look like in Python?
The sample uses SQLite so it runs anywhere, but the shape is the same in Postgres. enqueue belongs inside the transaction that writes your order, and drain can run from a timer, a worker or a cron. The body is serialized once with sorted keys and stored as text, so every retry sends byte-identical JSON.
import json, os, sqlite3
import requests
API = "https://api.sume.com"
H = {"Authorization": f"Bearer {os.environ['SUME_API_KEY']}", "Content-Type": "application/json"}
db = sqlite3.connect("outbox.db")
db.execute("create table if not exists outbox (key text primary key, body text, job_id text)")
def enqueue(order_id: str, prompt: str) -> None:
body = json.dumps({"prompt": prompt, "mode": "async"}, sort_keys=True)
with db: # run inside the same transaction as your order insert
db.execute("insert or ignore into outbox (key, body) values (?, ?)",
(f"order-{order_id}-hero-v1", body))
def drain() -> None:
for key, body in db.execute("select key, body from outbox where job_id is null").fetchall():
r = requests.post(f"{API}/v1/image-1.0/generate", data=body, timeout=30,
headers={**H, "Idempotency-Key": key})
if r.status_code in (429, 500, 502, 503):
return # same key next pass: no second job, no second charge
if r.status_code == 409: # the key already holds a different payload
job_id = r.json()["error"]["details"]["job_id"]
else:
r.raise_for_status()
job_id = r.json()["data"]["request_id"]
with db:
db.execute("update outbox set job_id = ? where key = ?", (job_id, key))
if __name__ == "__main__":
enqueue("8823", "Matte black bottle on marble, soft daylight")
drain()How does the drain decide what to do with each status?
Rows are only marked sent when you hold a job id. A 429, 500, 502 or 503 leaves the row alone and stops the pass, because the next pass reuses the same key. A 409 is the interesting case: the key already holds a job, and the docs say the conflict body names it in error.details (job_id, status_url, result_url), so you adopt that job instead of minting a new key. Anything else that is not a success raises, so a validation or balance error surfaces instead of looping forever.
The success path stores data.request_id, which is the job id you then poll or receive on a webhook. Reading the result is a separate concern, covered in Sume's jobs and results page.
- Derive the key from the order and a version you bump on purpose, never from the time of the attempt.
- Never rebuild the body from live data on retry; store it, so the payload cannot drift under the key.
- Stop the pass on a rate limit rather than hammering every pending row.
- Treat a
4xxother than409and429as a bug to read, not a transient to retry.
What can still go wrong?
The outbox gives at-least-once delivery with a duplicate-proof receiver, not exactly-once magic. One drain at a time per row is still wise: the docs for Format runs describe 409 idempotency_key_in_use when two requests with one key land at the same instant, and while that exact code is documented for runs, the safe habit is the same here, which is a single drainer or a row lock such as select ... for update skip locked in Postgres.
A second trap is the key outliving its meaning. If a customer wants a different image for the same order, bump the version in the key and write a new row. Reusing the old key with a new prompt gives you a 409 and the old job, which is correct but surprising.
Last, record the key next to the job id. Sume returns it as idempotency_key on every job object, so you can rebuild which job belonged to which order from the API alone if your table is ever lost.
When is an outbox overkill?
For a one-off script or a user pressing a button once, a keyed retry loop is enough. The outbox earns its place when the call must follow a database write you cannot lose, when the process can die mid-flight, or when many workers share a budget. In those cases a few lines of SQL buy a property no retry library can: the intent survives the process. Pair it with a webhook or a status sweep for the other half, finding out the job finished.
Sources
Related posts
More in Developers
- Transcribe a three-hour recording when the API caps at ten minutes
Streaming sessions end at an hour; Sume STT jobs take up to 600 seconds. Split with Timeline audio, transcribe 18 chunks, and stitch word times back together.
- Translate an SRT and burn it in: Sume caption cues, limits, Python
Sume takes no SRT upload, but caption cues take the same text and times. A Python converter, the 200-cue and 60-second limits, and which fonts apply.
- Validate video duration and resolution in Python before you submit
Fetch GET /v1/videos/models and check duration, resolution and aspect_ratio per model in about 25 lines of Python, before a Sume video job fails.
- AI video API fallback: retry on another model when a job fails
Chain seedance-2.5, seedance-2 and kling-3 on Sume: poll status_url, read the job error category, and resubmit the brief to the next model.
Written by Sume