Idempotency keys for batch TTS: derive them from what you render
A retry loop that mints random keys pays twice. Hash the sentence, voice, model and settings into the key, and re-running a script returns the first job.

How do you avoid paying twice when a batch TTS script crashes halfway? Derive each Idempotency-Key from what the request renders, not from a counter or a random id. Re-running the script then sends the same keys, and the API returns the existing jobs.
Cheap generation makes big batches normal. Mistral's Voxtral TTS (read 2026-10-04) is priced at $0.016 per 1,000 characters, and a book-length script is hundreds of requests. A crash at request 212 should cost nothing on restart.
What the key must include
If two requests would produce different audio, their keys must differ. If they would produce the same audio, their keys should match.
- The exact transcript text for the sentence.
- The voice id or avatar handle, and the language.
- The model id when you use the router.
- Generation settings: speed, volume and emotion.
- A version string you bump deliberately when you want a fresh take.
A key function
Hash a canonical form of those inputs. Sorted keys keep the hash stable across runs. Every Sume submit also reports idempotency_hit, so you can log which sentences were already rendered.
import hashlib, json
def tts_key(text, voice, model, config, version="v1"):
blob = json.dumps({"t": text, "v": voice, "m": model,
"c": config, "ver": version},
sort_keys=True, ensure_ascii=False)
return "tts-" + hashlib.sha256(blob.encode("utf-8")).hexdigest()[:40]
a = tts_key("Hello there.", "voi_demo", "sonic-3.6", {"speed": 1.0})
b = tts_key("Hello there.", "voi_demo", "sonic-3.6", {"speed": 1.0})
c = tts_key("Hello there.", "voi_demo", "sonic-3.6", {"speed": 1.1})
print(a == b, a == c)
Edge cases
Reusing a key with a different body is a conflict, not a new job, which is what you want: a changed sentence must get a changed key. Do not put timestamps or run numbers in the key, since those change on every restart and defeat the point.
A mode of sync or subscribe waits at most 30 seconds, so a slow sentence returns before it is done. Poll the status_url with the same job instead of resubmitting, and the key stays valid for the whole batch.
Sources
Related posts
More in Developers
- image_size presets (landscape_16_9, square_hd): what Sume sends
Named image_size presets map to ratios on Ideogram, Grok, Imagen and Nano Banana, but pass through on GPT and FLUX. What auto means on each, checked in code.
- "image_size must be a named preset" 400 on Sume: how to fix it
Sending image_size as a bare number or an empty object returns a 400 invalid_request. The three accepted shapes, a tested error table, and a safe builder.
- OS credential store vs sume login: where the key lives
Inngest v1.45.0 stores CLI OAuth credentials in the OS credential store. The Sume CLI stores its login key in ~/.sume-com/config.json; use env keys in CI.
- Instagram Reels API: a 100-posts-per-24-hours publish budget
The Instagram content publishing API limits an account to 100 API-published posts per moving 24 hours. A tested Python queue that spreads batch output under it.
Written by Sume