Guides
How to Prevent Duplicate AI Audio API Jobs Without Idempotency Keys
- Written by
- Sonilo Team
- Published

Claim a client-generated request key in your database before calling the audio provider. Replay a saved result for the same key and payload, reject changed payloads, and freeze ambiguous timeouts for reconciliation instead of automatically sending another POST.
Claim a client-generated request key in your database before calling the audio provider. Replay a saved result for the same key and payload, reject the same key with changed input, and freeze an ambiguous timeout for reconciliation instead of automatically sending another POST. This prevents your application from creating obvious duplicates, but it cannot guarantee exactly-once provider execution when the provider has accepted a request and returned no recoverable identifier.
By Sonilo Team*
Facts verified August 11, 2026.* Sonilo publishes this guide and operates the API used as the concrete example. The pattern was exercised with a six-test local state machine; it was not a test of Sonilo's production systems or a guarantee about another provider.
The duplicate window happens before polling
A user can double-click Generate. A mobile client can resend after losing its connection. A queue worker can restart after sending the request but before saving the response. In each case, two identical-looking application requests can become two separate upstream generation jobs.
The risk is not solved by retry backoff alone. RFC 9110's idempotency rules define POST as non-idempotent by default and say clients should not automatically retry a non-idempotent request unless they know its semantics are idempotent or know the first request was not applied.
This matters for generative audio because a POST can start paid work. The current Sonilo API reference lists generation POST endpoints and bills generations by output duration. Its public OpenAPI 1.0.0 and machine-readable reference did not document an idempotency key or client token when checked on August 11, 2026. That is a dated interface observation, not a promise that the API will never add one.
Polling begins only after your app has a task ID. If the submit response is lost, use the guard described here before the separate async API polling workflow.
Give each user intent one stable request key
Create a random request key when the user begins one generation attempt. Keep that key stable across button debouncing, client reconnects, queue redelivery, and safe application retries. Create a new key only when the user deliberately starts new work.
Scope the key to the authenticated owner or workspace. Store a fingerprint of the normalized input beside it. A practical fingerprint can cover the provider operation, source-media asset ID, prompt, output format, duration, and other fields that change the intended result. Do not hash expiring signed-URL query strings when the underlying media object is the same; fingerprint the stable asset identity instead.
AWS's idempotent API design guidance explains why a caller-provided request identifier is preferable to guessing duplicates from request parameters alone: the identifier expresses the caller's intent. It also treats reuse of one identifier for different intent as a distinct failure case.
Claim the key atomically before the provider call
The claim and uniqueness check must happen in shared durable storage before your service sends the upstream POST. A local in-memory map, frontend disabled state, or check-then-insert sequence can race when two servers handle requests together.
Use a unique constraint on `(owner_id, request_key)`. PostgreSQL's unique-constraint documentation confirms that uniqueness can cover a group of columns. Insert the first `pending` record in one atomic operation; when the insert conflicts, read and evaluate the existing record instead of calling the provider again.
Store at least these fields:
| Field | Purpose |
|---|---|
| `owner_id` and `request_key` | Unique scope for one user intent |
| `payload_fingerprint` | Detects accidental key reuse with changed input |
| `state` | `pending`, `succeeded`, `rejected`, or `uncertain` |
| `provider_task_id` | Connects an accepted async job to later polling when supplied |
| `result_pointer` | Replays the application result without new generation |
| `created_at` and `updated_at` | Supports monitoring and a documented retention policy |
| `attempt_count` and `correlation_id` | Makes operator investigation possible without logging secrets |
Do not put the provider API key, raw bearer header, or sensitive prompt data into the idempotency record. Keep the provider credential behind the server boundary described in the Next.js music API security guide.
Use four states, not success versus failure
The important state is `uncertain`. A timeout after the request body was sent does not prove the provider rejected the operation. Automatically changing it back to `pending` and sending the POST again can create a duplicate.
| Existing state | Same key and same fingerprint | Same key and different fingerprint |
|---|---|---|
| `pending` | Return accepted/in progress; do not call upstream | Return `409 Conflict` |
| `succeeded` | Replay the stored task or result | Return `409 Conflict` |
| `rejected` | Replay the definitive validation failure | Return `409 Conflict` |
| `uncertain` | Return needs reconciliation; do not call upstream | Return `409 Conflict` |
A provider-side idempotency contract can do more. Stripe's official idempotent-request documentation shows one mature example: it saves a result for a key, returns the saved result on reuse, and compares parameters to prevent a key from representing different requests. Stripe's behavior is not Sonilo's behavior; it illustrates why native provider support closes an ambiguity that an application wrapper cannot close alone.
Implement the gate as a narrow server function
The tested sample for this guide uses this sequence. Apply it inside the trusted backend that already owns the provider credential:
- `fingerprint = sha256(canonicalJson(payload))`
- `claim = insert(ownerId, requestKey, fingerprint, "pending") on conflict read existing`
- `if claim.fingerprint != fingerprint: return 409`
- `if claim.state != "pending" or claim.wasCreated == false: return replay(claim)`
- `try: providerResult = await callProviderOnce(payload)`
- `catch definitiveValidationError: save("rejected"); return 422`
- `catch timeoutOrUnknownTransportError: save("uncertain"); return 202`
- `save("succeeded", providerResult); return 201`
Only the process that created the claim may make the provider call. A second request that observes `pending` must not become another worker unless you have a separate lease-and-recovery protocol that proves the first worker never crossed the provider boundary.
The local evidence asset ran six cases with Node.js: completed-result replay, changed-payload conflict, ambiguous timeout, deliberate new intent, owner scoping, and definitive validation rejection. All six passed, and the duplicate and uncertain cases each made one provider call. The sample uses an in-memory store to make the state transitions easy to inspect; deploy the same contract with atomic durable storage.
Treat timeouts as an operations queue
An `uncertain` record needs a resolution path, not an infinite spinner. Use the best signal the provider makes available:
- If a task ID was received, store it and poll the provider's task endpoint.
- If the provider offers a search, request log, webhook, or support correlation ID, reconcile against it.
- If the provider later adds idempotency keys, forward the same application key and follow its retention and parameter rules.
- If no recovery interface exists, surface the record to an operator or let the user explicitly choose whether to create a new request key after seeing the duplication risk.
Do not silently expire an uncertain key and replay it. Retention is a product decision: it should be at least as long as clients, queues, and operators may legitimately retry that user intent. Archive or redact old payload details according to your privacy policy, while keeping enough non-sensitive evidence to investigate billing disputes.
Test the failure paths that create duplicates
Your release test should count provider calls, not only HTTP responses:
- Send two concurrent requests with the same owner, key, and payload; assert one upstream POST.
- Retry the key after success; assert the stored result is replayed.
- Reuse the key with a changed prompt or media asset; assert `409` before the provider call.
- Drop the upstream response after request transmission; assert `uncertain` and no automatic replay.
- Restart the worker after claiming the key; assert the recovery process does not send blindly.
- Use the same key under two owners; assert they remain isolated.
- Send a new key with the same payload; assert it represents deliberate new work.
- Redact credentials and sensitive inputs from logs while preserving correlation IDs.
After generation succeeds, store the returned media using the temporary API URL durability workflow. Idempotent submission prevents duplicate work; durable storage prevents a successful output from disappearing later.
When this pattern is not enough
Use native provider idempotency when you require safe automatic replay across an ambiguous network failure. An application gate can guarantee that your own service does not intentionally send the same claimed request twice, but it cannot prove whether an unacknowledged upstream POST ran once, failed before execution, or completed without returning a result.
Do not use payload hashes alone as request keys. Two deliberate generations can have identical inputs and should remain separate user intents. Do not use a frontend-only UUID without an owner scope or payload fingerprint. Do not automatically release a pending claim because a process heartbeat stopped; the provider call may already be running.
For low-value, non-billable, easily reversible operations, the storage and operations burden may be excessive. A debounced button and explicit retry confirmation can be a reasonable simpler choice when duplicate side effects are acceptable.
Ship the duplicate boundary before adding retries
Start with the unique database claim, four-state record, one-call test, and operator path for uncertain outcomes. Then add retries only where the contract proves them safe.
Review the current Sonilo API reference to choose the endpoint and response mode for your integration. If your stack uses Next.js, put this gate behind the same server-only boundary that protects the Sonilo credential, and carry the request key through your queue and status UI.


