Safe retries without idempotency keys
There is no Idempotency-Key header. Mint your own batch ID, persist the request ID as soon as you have it, and make timeouts safe without charging twice.
There is no Idempotency-Key header. A timeout, a dropped connection, or a helpful HTTP client that retries POST for you will, by default, start a second generation and charge for it. Duplicate submissions are yours to prevent. The good news is you do not need a header to do it: you need a client-minted id, a place to store the request_id the moment you see it, and a rule that says "never POST the same job twice unless the server has promised that twice is once".
This is the difference between a retry and a second order. A 429 wait-and-resend of a call that was not accepted is a retry. A second POST /generate/video after the first one might have been accepted is a second order. Credits apply to the second order. There is no free API allowance to soak up the mistake.
No header, so the client owns uniqueness
Stripe-style Idempotency-Key semantics (same key, same body, server returns the original result for some TTL) are not implemented on Versely's REST surface. grep for the header will not find a handler. Your HTTP library's "retry POST on socket timeout" is therefore unsafe until you add your own layer.
What "owning uniqueness" means in practice:
- Mint a
client_job_idbefore the first byte goes on the wire. A UUID v4 is enough. This is your primary key, not Versely's. - Write it down first. Insert
{ client_job_id, state: "submitting" }in your database, then send the HTTP request. If you send first and crash, you have a generation with no owner. - Treat a
request_idas the server's acknowledgement. The instant a response body containsrequest_id/taskId/task_id, persist it on that row and flip state togenerating. From this moment the only legal next call for this job isGET /api/v1/status/:requestId. - Never POST again for a row that has a
request_id. Not on timeout of the status poll, not on worker restart, not because a queue library delivered the message twice.
The developer page will give you routes and auth. It will not give you this table. The table is the integration.
A worker that uses the CLI for the key and then retries at the HTTP layer is the failure mode I see most: versely auth login stores a durable credential, fetch retries the POST, two clips render, one of them is thrown away, both are billed. The CLI is not a client library and will not deduplicate for you.
batch_id on image and video
Image and video generate have an opt-in body field that does absorb a duplicate POST, for five minutes, without an HTTP header.
Send batch_id as a fresh UUID on POST /api/v1/generate/image and POST /api/v1/generate/video. Reuse that UUID if, and only if, you are retrying the same tap: same user, same model, same intent, because the original HTTP call might have died after the server accepted it.
What the server does:
- First POST with that
(user, batch_id, model)tuple takes a Redis lock for 300 seconds and dispatches. - A second POST with the same tuple inside that TTL does not dispatch and does not charge again. It returns HTTP 200:
{
"success": true,
"duplicate": true,
"message": "Duplicate dispatch ignored — this generation was already submitted",
"data": [{ "model": "…", "taskId": "the-original-request-id" }]
}
- If the first POST failed with a 4xx/5xx, the lock is released so a corrected retry can go through.
- If Redis is unavailable, the lock is skipped and you are back to "POST means charge". Do not rely on the lock as your only record; still persist
request_idyourself.
batch_id is per model as well as per batch. A multi-model fan-out that shares one batch_id across models is the intended pattern (one tap, several models). Reusing a batch_id for a different prompt or a different job inside five minutes is how you swallow a generate you actually wanted. Mint a new UUID per user-visible action.
This lock exists on image and video generate only. Audio, lipsync, story, social posts, agent chat, editor renders: no claimDispatch, no duplicate body. For those routes, a retried POST is a new job. Your client_job_id table is the only safety net.
Recovery if the POST timed out and you did send batch_id: send the same POST again (image/video), or list the user's images/videos filtered by that batch_id and read request_id off the rows. Either path is better than minting a new UUID and hoping.
Batch generation in the product sense is several outputs from one action. batch_id is the wire handle that makes that action retry-safe. They rhyme because they are the same tap.
Persist the request ID before you consider the POST done
A client that returns success to its caller before it has written request_id is lying. The next timeout will look like "we never heard back" and will POST again.
Minimum state machine on your row:
| Your state | Versely | Legal operations |
|---|---|---|
submitting |
Unknown | POST once (with batch_id on image/video). On timeout, POST the same body again only if batch_id was sent and the route is image/video; otherwise look up, do not mint. |
generating |
Has request_id |
GET /status/:requestId only |
completed |
Terminal | Fetch result_url. Do not POST. |
failed |
Terminal | New POST only if you have decided this is a new attempt, with a new client_job_id (and a new batch_id) |
dead |
Give up | No calls |
Worker restarts: load rows in submitting and generating. generating rows resume at status. submitting rows without a request_id are the danger zone. For image/video with a stored batch_id, replay the POST. For everything else, check the library by time window and prompt, and if you cannot find a row, then decide whether a new charge is acceptable. That decision should be a human or a flag, not the default.
HTTP libraries: turn off POST retries at the transport layer. Implement them in the state machine above. A generic retry wrapper cannot see duplicate: true and cannot see your table.
API-first content generation fails in production on this row, not on the prompt. The teams that get it right have one function named submitOnce.
Timeouts, 429s, and the one safe retry
Classify the failure before you touch POST.
429. The call was not accepted. No ID, no charge. Sleep retryAfter seconds, send the same HTTP request (same batch_id if any). This is safe on every route.
401 / 403 / 400. The call was not accepted. Fix the key, the scope, the body, or the balance. Sending it again unchanged is not a retry policy.
Timeout or 5xx on POST, no body. Unknown. Image/video with batch_id: resend. Anything else: do not resend until you have looked.
Timeout on GET status. The generation is fine. Your poller is not. Leave the row generating and poll later. Do not POST a replacement because your timer fired.
status: failed. Terminal for that ID. A new attempt is a new client_job_id, a new batch_id, a new POST, a new charge. Use transient / retry_suggested to decide whether that attempt is even allowed. That classifier is a different post; the uniqueness rule here is "new ids, or you will collide with the five-minute lock and think you retried when you only read the old failure".
Your queue delivered the message twice. This is the case client_job_id exists for. The second delivery finds the row already generating or completed and no-ops. If your queue id is not stable, you do not have idempotency, you have a vibes-based worker.
Keys and surfaces: the same job on MCP, CLI-provisioned scripts and REST shares the credit balance and the async ID model. Picking the surface does not pick an idempotency mode. Unattended work belongs on REST with the table above, not in a chat client that will happily click generate twice. API access still draws on the same paid credits as the app; see API pricing.
Reroll rates are for takes you meant to buy twice. Timeouts should not show up in that number. If they do, your submit path is charging on accidents.
FAQ
Can I send Idempotency-Key anyway?
You can send any header you like. Nothing reads it. The duplicate will dispatch. Use batch_id on image and video, and your own table everywhere.
Is batch_id an idempotency key?
It is a five-minute, per-user, per-model lock on image and video generate, opt-in, body field, not a header. It absorbs network retries of the same tap. It is not a general-purpose idempotency API: wrong route, wrong TTL, wrong reuse, and you either double-charge or swallow a job. Treat it as the lock it is.
The POST returned 200 duplicate: true but data is empty. What now?
The lock was held and the original rows were not visible yet (or Redis had the lock and the insert had not landed). Wait a second and list by batch_id, or GET status if you already had a taskId from the first attempt. Do not mint a new batch_id. Empty data on a duplicate acknowledgement is a race, not permission to start over.
Do credits get charged on the duplicate POST?
The duplicate path is specifically "acknowledged without re-charging or re-dispatching". The first accepted generate is the one that costs credits. Your job is to make sure there is only one accepted generate per client_job_id.