How a generation works
Every feature on this site is the same call underneath. Learn it once here and a feature page is just one more args object.
A generation is asynchronous
Rendering a video takes minutes, and a request that waits for one is a request that times out somewhere between you and us. So nothing is rendered while you wait. POST /v3/generations charges your balance, queues the work and returns an id immediately.
POST /v3/generations
{ "feature": "video/logo-reveal", "args": { … } }
→ 200, in milliseconds
{ "generation": { "id": "9f2c1d84…", "status": "pending", "balanceUsed": 0.64 } }That id is a durable record, not a handle on an open connection. You can lose it and list it back, poll it from a different machine than the one that submitted it, or ignore it and take a webhook instead. We call the record a generation: one submitted request that produces one asset, and the only word these docs use for it.
Cloning a voice is the one exception
/v3/generations produces an asset: an image, a video, an audio file. Cloning a voice produces something you keep and reuse instead, so it posts to /v3/voices and polls there. The statuses and everything else on this page are identical; only the noun changes.The statuses
pending- Accepted and queued. Every generation starts here.
running- A worker picked it up. Nothing for you to do differently.
done- Finished. output is populated and responseDate is set.
error- The render failed.
errorcarries the reason, and the charge is refunded automatically. timeout- The generation did not finish inside its window (fifteen minutes for most features, thirty for the longer video ones, ninety for Voiceover, two hours for Avatar Video). Also refunded automatically.
pending and running are the two non-terminal states, and the only sound way to write a wait loop is to keep going while the status is one of those two rather than to check for done. A loop that only looks for done spins forever on a failure.
Collecting the result
Polling
GET /v3/generations/{id} returns the current record. Every few seconds is a sensible interval, and 5 to 10 seconds is plenty for the minute-scale renders (video, voice cloning); the call is cheap and is not rate-limited separately from the rest of the API.
# repeat every 5s or so until status is done, error or timeout
curl "https://api.neurall.io/v3/generations/9f2c1d84-6a3b-4f21-9c77-2f0a1b8e5d43" \
-H "Authorization: nl-YOUR_API_KEY"A finished generation
{
"generation": {
"id": "9f2c1d84…",
"status": "done",
"output": {
"video": "https://cdn.neurall.io/…/video.mp4",
"mime": "video/mp4"
},
"balanceUsed": 0.64,
"requestDate": "2026-08-03T09:12:44.000Z",
"responseDate": "2026-08-03T09:15:02.000Z",
"expiresAt": "2026-08-04T09:12:44.000Z"
}
}The asset comes back under the generation’s kind, mirroring the args side: output.image, output.video or output.audio. Exactly one of them is set, and the URL is a signed CDN link.
Webhooks
Pass webhookUrl on the submit and we POST the finished generation to it once, when it reaches a terminal status. The body is what GET /v3/generations/{id} would have returned, plus an event discriminator, so one parser handles both paths.
POST to your webhookUrl
{
"event": "generation.done",
"generation": { "id": "9f2c1d84…", "status": "done", "output": { … } }
}- The event is
generation.done,generation.errororgeneration.timeout. - The URL must be https and must not resolve to a private or internal host.
- Delivery is best effort: one retry, then it stops. Any 2xx from your endpoint counts as delivered. Polling remains the source of truth, so a generation whose webhook never arrived is still readable by id.
- Every delivery is signed. There is no unsigned mode, and no setting to turn signing off.
The signature is sha256= followed by HMAC-SHA256 over <timestamp>.<raw body>, keyed with your project’s webhook secret from the API keys page. It rides X-Neurall-Signature, with the unix timestamp on X-Neurall-Timestamp.
Verifying a delivery
import crypto from "node:crypto"
// mount this route with the RAW body: a re-serialized JSON object will not
// produce the same bytes, and the signature is over the bytes we sent.
export function verify(rawBody, headers, secret) {
const ts = String(headers["x-neurall-timestamp"] ?? "")
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false // stale replay
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex")
// for 24 hours after a rotation the header carries two space-delimited
// signatures, the new key and the old one. Accept if either matches.
return String(headers["x-neurall-signature"] ?? "")
.split(" ")
.some((sig) => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
}What it costs, and what a failure gives back
The balance is charged when the generation is accepted, not when it finishes, and the amount comes back on the submit response as balanceUsed. Prices are flat per call, or per second for the features billed that way, and GET /v3/pricing returns your exact rates before you spend anything.
A generation that ends in error or timeout is refunded automatically. You do not ask, and there is nothing to reconcile later: you are charged for renders that land.
Outputs expire
A generation created through the API carries an expiresAt, 24 hours after the request. This is not storage. When it passes, the asset is reaped and the generation itself stops being readable: polling an expired id returns 404, exactly as if it had never existed.
Uploaded reference files are on the same clock, counted from the moment they land. Download what you generate and put it somewhere you own, and treat a file id as good for the render you are about to submit rather than as a permanent handle. Work made in the app, by a person, persists in their library instead; the 24 hour clock is on API traffic.
Limits and errors
Generations are capped by how many run at once rather than by how many you submit per minute. Unless your plan raises it, 30 generations and 5 voice clones can be in flight at a time. Over the ceiling, submits answer 429 with a Retry-After header. Wait that many seconds instead of retrying immediately, and keep the retry on the submit: polling is never what is limited.
400- The feature name or the args are wrong. The body names the field.
401- Missing, malformed or deleted API key.
402- Not enough balance for this call. Top up, or turn on auto-recharge.
404- No such generation in this key’s project, or it has expired.
429- Too many generations in flight. Retry after the Retry-After header.
503- No worker available for that feature right now. Retry with backoff.
Switch on the status code, not the body
error field. They are written for a human reading a log. Branch on the status code, and log the body rather than parsing it.Where to go next
- Quickstart if you have not made a first call yet.
- The feature pages for the
argsof whatever you are building, with the real limits of each field. - The generations collection in the reference, for listing, deleting and the full response schema.