The job table your integration needs to keep
Poll-only delivery means a dropped connection is a lost job unless you persist request ids, statuses, and retries. Here is the minimal schema.
Versely does not POST the finished asset back to you. A generate call returns a request id, the work continues on the server, and the only way to collect the result is to poll GET /api/v1/status/:requestId until it leaves generating. There is no customer webhook to register, no Idempotency-Key header to make a retry safe by itself, and a dropped TCP connection after submit is not a rollback. If that id never lands in a table you own, the job still runs, still spends credits, and you have nothing to poll.
That is the whole reason an integration needs a job table. Not because the status endpoint is hard, but because delivery is poll-only and deduplication is your problem once the HTTP call has returned.
What the status endpoint actually returns
Auth is required (JWT or API key). The lookup is scoped to the authenticated user, so a valid id that belongs to someone else is a 404, same as a made-up one. Missing requestId is a 400.
When the row exists, the body is one of three statuses:
status |
Meaning | What you store |
|---|---|---|
generating |
The row is still in flight (is_generating is true). |
Leave it. Poll again. |
completed |
At least one result URL was written. | Persist result_url (and result_urls if you want the extras). Stop polling. |
failed |
No result URL. | Persist error. Read transient / retry_suggested before you retry. |
Completed and failed responses also carry type (images, videos, audios, or music), model, created_at, and up to five URLs in result_urls. The first of those is result_url. Failed responses add a sanitised error string, plus two booleans: transient and retry_suggested. Those two are true only when the upstream reason looks retryable ("try again", "internal error", "temporary"). A bad prompt, a missing image, or a model that cannot run the request is not transient. Retrying it spends credits again on the same failure.
The status endpoint is the only status view you should use. It is scoped to one request, so it cannot race you the way looking in a library listing can. The developer page is the entry point for the rest of that contract; CLI, MCP, or API is the decision of which door you are standing in.
Why client-side dedupe is not optional
A generate POST is not a read. If your HTTP client times out, retries, and the original call actually landed, you now have two jobs and two charges unless something stops the second dispatch.
Versely absorbs that case for image and video generate calls, and only when you send a batch_id. The server takes a five-minute lock keyed on user + batch + model. A retry of the same tuple returns 200 with duplicate: true and the existing { model, taskId } rows, without charging again. The lock is dropped if the original dispatch failed, so a genuine failure can be retried immediately. If Redis is unavailable, the lock is skipped rather than blocking generation.
That is a retry absorber, not a job store.
- It does not apply to lipsync, audio, story, upscale, or editor-render.
- It does not survive the five-minute TTL.
- It does not give you a webhook.
- It does not record the transition from
generatingtocompleted. - If you omit
batch_id, you get today's behaviour: every POST is a new job.
Your table is what maps request_id to your user, your product SKU, and the prompt you actually sent. batch_id is a UUID you mint per "Generate" tap so a flaky network does not double it. Keep both.
The minimal schema
These columns do the job. Add more later if you want; do not ship without them.
| Column | Type | Why it exists |
|---|---|---|
id |
UUID, yours | Your primary key. Never reuse a Versely request id as this. |
user_id |
your user | The person you will show the asset to. |
request_id |
text, unique, nullable | Null only before the POST succeeds. Unique so a retry cannot insert twice. |
model |
text | The name you sent. Needed when one HTTP call fans out to several models. |
type |
text | The status payload's type: images / videos / audios / music. Lets you route the result URL. |
status |
text | Your copy of the state machine, below. |
result_url |
text, nullable | Set once, on completed. |
error |
text, nullable | Set on terminal failed. |
transient |
boolean, nullable | Copied from the status payload so a later operator can see why you retried. |
batch_id |
UUID, nullable | The id you sent on the POST. |
attempt |
int, default 1 | How many times you have submitted this logical job. |
created_at / updated_at / completed_at |
timestamps | completed_at is the poll that observed a terminal status, not when you inserted the row. |
Optional but worth it on day one: a hash of the prompt plus the input URLs, so you can answer "did we already run this?" without rereading the prompt column. Versely will not answer that for you.
One row per request_id, not one row per HTTP call. A single generate POST can name several models and come back with several ids. Store each.
The transitions you have to record
Treat these as the only legal moves. Anything else is a bug in the worker.
pending→submitted. You have a local row, you POST, you get arequest_id(or aduplicate: truereplay of one). Write the id before you start polling. If the POST returns402, staypendingand do not retry: there is no free allowance, the cheapest model in the catalog still costs credits, and retrying a402is how you hammer an empty balance. Top up, then submit once. Plans and credit costs are on pricing; API access draws on the same balance, which is the point of the API pricing page.submitted→generating. First poll that returnsgenerating. Mostly a no-op; it proves the id is real. If the first poll is404, wait and poll again. The server-side insert is not always visible on the millisecond you returned from POST. A 404 that lasts is a lost id, not a cue to POST again without checkingbatch_id.generating→completed. Persistresult_url, setcompleted_at, stop the loop. Fetch the file to your storage if you need it to outlive the CDN URL. Do not assume you can come back in a week and the URL still resolves if you never copied it.generating→failed. Persisterrorandtransient. Ifretry_suggestedis true andattemptis under your ceiling, mint a new local row (or incrementattempt) and POST again with a newbatch_id. Reusing the oldbatch_idon a successful original will no-op for five minutes. Ifretry_suggestedis false, stop. Showerrorto your user.- Any non-terminal →
abandoned. Your poll ceiling elapsed while status was stillgenerating. This is a state you own. The job may still finish; a later poll can moveabandonedtocompleted, which is whyresult_urlstays nullable andrequest_idstays unique. Do not POST a replacement just because you got bored.
Poll interval: five seconds is the interval the rest of the batch-rendering pattern uses, and it is a sensible default. On 429, do not use five seconds. Read Retry-After or X-RateLimit-Reset and wait that long. Generation endpoints are cost-sensitive; a worker that ignores those headers will spend the rest of the window failing.
A 401 is a dead key, not a flaky job. A 403 is a missing scope. Neither is cured by polling harder.
What this table is not
It is not Versely's library. The library is the user's. Your table is the mapping from your product events (a signup, a SKU, a ticket) onto request ids you must collect.
It is not a queue API. There isn't one. generate_via_queue was removed. Do not build against a queue surface you saw in an old post.
It is not a substitute for costing the run. A hundred rows in pending against an empty balance is a hundred 402s. Cost the batch before you insert, the same way you would for a shell run.
If you are wiring this into an agent rather than your own worker, the generate skill already specifies the same poll-only rule and the same error ladder. The skills page is the procedure; the table is still yours, because the agent will forget the id when the conversation ends.
FAQ
Can I register a webhook and skip the table?
No. Inbound /webhook/* routes exist for providers talking to Versely. There is no webhook_url on generate, and no signature for you to verify on the way out. Poll GET /api/v1/status/:requestId. The table is how you remember what to poll.
If I send batch_id, do I still need request_id uniqueness?
Yes. batch_id stops a second POST from charging. It does not stop a second INSERT on your side if your worker handles the 200 twice. Unique on request_id (and a uniqueness rule on (batch_id, model) for the in-flight window) is what makes the replay harmless.
What if status stays generating for twenty minutes?
Use a ceiling, then mark abandoned and keep the request_id. Video jobs legitimately run minutes; an infinite loop is how a stuck worker looks like load. A later poll of the same id is cheap and can still complete the row. Replacing the job because you are impatient is how you double-charge a job that was about to finish.
Do I poll editor renders the same way?
No. POST /api/v1/features/editor-render is a blocking pipeline: it returns video_url on the same response (or errors). Generation is the async path this table is for. Do not invent a request id for a render that already gave you the file.