Poll-first integrations when there are no webhooks
Versely does not POST results to your server. Build around submit-then-poll on a request ID, and stop waiting for a webhook that is never going to arrive.
If your integration plan starts with "we register a webhook URL and they POST us the mp4", it is a plan for a different API. Versely does not take a customer webhook_url or callback_url. Those keys are plumbing and are stripped before anything reaches a provider. There is no customer notify_url to register either. There is nothing to verify with a webhook-id / webhook-signature pair, because nothing is going to be sent to your server.
Results come back when you ask. You POST a generate route, you keep the request_id, you GET /api/v1/status/:requestId until the state is terminal, you fetch result_url. That is the whole delivery model. Build for it and the system is boring in the way production systems should be. Wait on a callback that never arrives and you will time out jobs that finished ten minutes ago.
There is no callback URL to register
Plenty of generation APIs (fal, Replicate, others) accept a per-job webhook. Versely's /webhook/* routes are inbound only: providers notifying Versely, billing events, social platform callbacks. Signature checks on those routes protect Versely, not you. You cannot subscribe to them, and you should not expose an endpoint "just in case".
A body field named callback_url on a generate request is not a customer feature that happens to be undocumented. It is in the same discard set as user_id and endpoint_id. Sending one does not register anything. Sending one and then blocking your worker until a POST hits your server is how a batch sits in "processing" until an operator kills it.
The product surfaces already assume poll-only. The MCP connector and the agent submit work and then wait on status. The CLI mints a key; it does not poll for you. Your service should submit, persist the ID, and poll, with your own datastore in the middle.
What you do need on your side is an HTTPS endpoint only if you want to fan results out to your users. That is your webhook, paid for by your engineering, pointing at your customers. Versely will not call it.
Submit, store, poll
Three operations, in this order, with a database row between them.
1. Submit. POST https://api.versely.studio/api/v1/generate/video (or /image, /audio, /lipsync, …) with a bearer key. On success, pull a request ID out of the body. The field is request_id on some paths and taskId / task_id on others; take the first non-empty value in that set. If the POST also includes an output URL, you are done and you can skip polling. Most video jobs will not.
2. Store, before you consider the POST finished. Write { client_job_id, request_id, status: "generating", submitted_at } to your database in the same step that handles the HTTP 200. A process crash between "got the ID" and "wrote the ID" is how you lose the only handle the status endpoint will accept. If you cannot write the row, you do not have a job.
3. Poll. GET https://api.versely.studio/api/v1/status/:requestId with the same auth. You will get one of:
status |
Meaning | Your next action |
|---|---|---|
generating |
Row exists, is_generating is still true |
Sleep, poll again |
completed |
At least one Versely CDN URL is on file | Persist result_url, stop |
failed |
Generation settled with no URL | Persist error, read transient, stop |
| HTTP 404 | No row for this user and ID | The ID is wrong, not yours, or not yet visible. Retry a few times, then fail |
A completed payload looks like this (fields omitted if absent):
{
"success": true,
"status": "completed",
"type": "videos",
"model": "the model you asked for",
"result_url": "https://videos.versely.studio/…",
"result_urls": ["https://videos.versely.studio/…"],
"created_at": "2026-08-20T12:00:00.000Z"
}
result_url is a Versely CDN URL, not the upstream provider URL. Use that. Hotlink the provider and you will hit hosts that are not meant to be your CDN.
The poller should not share a tight loop with the submitter. Status reads are not on the generate limiter, but with an API key they do count against that key's per-key RPM. Poll every few seconds, not every few hundred milliseconds, and stop on a terminal state. A generate-then-publish pipeline that polls ten jobs at 10 Hz will 429 the next POST from the same key.
This is the same discipline as running a dub queue, just pointed at /status/:requestId instead of a dub project. Manifest first, IDs in the manifest, sweep later. Do not sit on the HTTP request waiting for pixels.
A loop that stops
A poll loop with no stop condition is an RPM leak. The stop condition is the status enum, not a feeling about how long video "usually" takes.
async function waitFor(requestId, { timeoutMs = 15 * 60 * 1000 } = {}) {
const started = Date.now();
let delay = 2000;
let notFound = 0;
while (Date.now() - started < timeoutMs) {
const res = await fetch(
`https://api.versely.studio/api/v1/status/${requestId}`,
{ headers: { Authorization: `Bearer ${process.env.VERSELY_API_KEY}` } }
);
if (res.status === 404) {
notFound += 1;
if (notFound >= 5) throw new Error(`No generation for ${requestId}`);
await new Promise((r) => setTimeout(r, 2000));
continue;
}
const body = await res.json();
if (body.status === "completed") return body;
if (body.status === "failed") {
const err = new Error(body.error || "Generation failed.");
err.transient = body.transient === true;
throw err;
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 8000);
}
throw new Error(`Timed out polling ${requestId}`);
}
Notes that are easy to get wrong:
- A timeout in your poller is not a failure of the generation. The job may complete a minute later. Leave the row as
generatingon your side, or mark itunknown, and sweep it on the next pass. Do notPOSTa replacement because your timer fired. failedis terminal for that ID. A new attempt is a newPOSTand a new charge. Thetransientflag is advice about whether that newPOSTis worth it, not a request to keep polling.completedcan carry several URLs inresult_urls(up to five are returned, newest first).result_urlis the first of those. If you need every output, store the array.- Auth is required. The lookup is scoped to the calling user, not to the individual key. A status call authenticated as a different user 404s even if the ID is real. Two keys owned by the same user can both poll the same ID.
Long work that you do not want to sit on belongs in a background worker, not in an HTTP request to your own users. Background tasks while you keep chatting is the product version of that split; your integration should mirror it: accept the job, return your own ID, poll Versely out of band.
What not to build
A webhook receiver "for later". There is no later. The inbound /webhook/* routes will not start POSTing to you if you add a field.
A single HTTP request from your user to your server that blocks until Versely completes. Video is async by design. Your user waits on you; you wait on status. Those are two loops.
A busy poll on the generate limiter's remaining slots. Status is a different route. Use it. Then go do something else. The API-first shape is submit, persist, continue; it is not submit-and-stare.
An assumption that MCP or the agent will callback instead. They poll too. MCP is a tool surface for a person or an agent who is present. It is not a delivery network. If the initiator is your product, you want REST and a worker, as the surface comparison lays out.
Treating "no webhook" as "we cannot automate". Poll-first is how most of the catalog already runs internally. It is automation. It just does not look like a Slack-style callback.
Credits still apply the moment a generate is accepted, not the moment you happen to poll. There is no free API allowance and no sandbox mode that returns fake URLs. If you are going to poll, you are going to pay for the jobs you submitted; check the balance before the batch, not after you have fifty IDs in generating.
FAQ
Can I pass webhook_url anyway and hope?
You can pass it. It will be dropped with the rest of the plumbing keys. No route will store it, no worker will call it, and no signature secret will be issued. Hope is not a delivery mechanism.
How long should I poll before giving up?
Give up on your timer, not on the request ID. A 15-minute wall clock is a reasonable sweep interval for a worker; it is not a signal to resubmit. Leave the ID in your table and pick it up on the next pass. Resubmitting because you got bored is how you pay twice for one clip.
Is there a queue API I should be using instead?
No general queue surface. generate_via_queue / check_queue_status were removed. /ltx-batch/queue/stats is specific to LTX batch jobs, not a stand-in for "any generation". Poll /status/:requestId.
Does the app get results some other way?
The app polls the same status model, sometimes behind a short-lived cache, and inbound provider webhooks are what flip is_generating to false on Versely's side. That webhook is Versely's, from the provider, to Versely. Your credentials cannot subscribe to it.