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.

5 min readSume
All posts

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 points of order-then-call versus the outbox, with Sume's documented behaviour, read 2026-10-03
FailurePlain POSTOutbox with a stored key
Crash after order insert, before the callOrder without a jobRow waits, next drain sends it
Request times out after Sume accepted itRetry may create a second job without a keySame key returns the original job
5xx responseDocs: never proves the work was refusedRetry with the same key adopts the first create
429 rate_limited or queue_fullCaller must remember to retryRow stays, retried next drain
Same key, different bodyNot applicable409 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 4xx other than 409 and 429 as 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

All Developers posts

Written by Sume