Skip to content

GuidesTechnique

Calling the Vellria API from your code

Four calls take you from nothing to a finished file: create a key, start a generation, poll the record, and download the output. This is that loop end to end, with the error each step returns and what a client should do about it.

Cover art for the guide: Calling the Vellria API from your code

Create a key, send it as a Bearer token

Keys are made in the console, under API Keys, and that response is the only place the full key appears: every later read of your key list returns an id, a name and a prefix, never the secret again. Store it at once. The prefix exists so that a log line can name a key without containing it.

Send it in the Authorization header on every request, as the word Bearer followed by the key. There is no query-string form: RFC 6750 lists among its recommendations that "Bearer tokens SHOULD NOT be passed in page URLs (for example, as query string parameters)", because URLs carrying tokens end up in browser history, referrers and server logs.

Send no key, or one that was revoked, and the answer is 401 with authentication_required as the code. Every error shares one body, an error object holding message, type, param and code. Branch on code: the message is written for a person and its wording will change.

Read the catalog instead of hardcoding it

GET /v1/models returns two lists, image and video. An image entry lists the sizes it accepts, what each one costs in credits, and a default size; a video entry carries resolutions with a per-second cost, a duration floor and ceiling, and any ratios it takes. A size list belongs to one model rather than to the catalog, and so does a default, so no single value is safe to apply everywhere. The model catalog and pricing pages read this same endpoint. Each model page also shows the id to send, for example Seedance 2.5 for video and Qwen 3 for images, and the NSFW image API and NSFW video API pages summarise the request shapes.

The kind field says whether a model makes images or video; each entry then lists a text mode and a reference mode. You send the same model id either way: attach reference images and the reference mode's sizes and prices apply. An unknown model, an invalid size or an out-of-range duration comes back as 400 with param naming the field, and a duration outside the range is rejected rather than trimmed to fit.

Start the run, then poll the record

POST to /v1/generate/image or /v1/generate/video with a model id, a prompt and the options that model accepts. The reply is 202, carrying an id, the status processing and how many credits were just deducted: credits come off at the start of a run, not at the end. Then keep reading GET /v1/generations/{id}; once status is no longer queued or processing, the run is over. Once complete, the record's output_url points at our file endpoint; a failed one carries error_code, a value from a fixed set, alongside error, a short summary meant for your logs.

Put a delay between polls and randomness in the delay. Marc Brooker's backoff experiment on the AWS Architecture Blog simulates clients contending for one row and reports that with jitter added, "in the case with 100 contending clients, we've reduced our call count by more than half". Polling is how you learn the outcome, not how it happens: runs are reconciled server-side whether or not anyone is watching, so a dropped connection costs you your handle on the result, not the result.

The whole loop in one file

Save this as an .mjs file, set VELLRIA_API_KEY, and run it on a recent Node. No model id, size or cost is hardcoded.

```js import { writeFile } from "node:fs/promises"; const BASE = "https://vellria.com"; const AUTH = { Authorization: `Bearer ${process.env.VELLRIA_API_KEY}` }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function call(path, init) { const res = await fetch(BASE + path, { ...init, headers: { ...AUTH, ...init?.headers } }); if (res.status === 429) { await sleep(Number(res.headers.get("retry-after") ?? 5) * 1000); return call(path, init); } if (!res.ok) throw new Error((await res.json().catch(() => null))?.error?.code ?? res.status); return res.json(); } const catalog = await call("/v1/models"); const model = catalog.image[0]; let rec = await call("/v1/generate/image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: model.id, prompt: "a lighthouse in fog", size: model.text.default_size }), }); while (rec.status === "queued" || rec.status === "processing") { await sleep(2000 + Math.random() * 2000); rec = await call(`/v1/generations/${rec.id}`); } if (rec.status !== "completed") throw new Error(rec.error_code ?? "generation_failed"); const file = await fetch(rec.output_url, { headers: AUTH }); const ext = file.headers.get("content-type") === "image/png" ? "png" : "jpg"; await writeFile(`${rec.id}.${ext}`, Buffer.from(await file.arrayBuffer())); ```

For a model that takes pictures, POST the file to /v1/uploads first with a Content-Type header matching the bytes, then pass the address you get back in image_urls; from a still to a video has the accepted formats and the rejection list. Whether a model wants pictures is a property of the model, and sending the wrong shape of body is refused rather than silently adjusted, which keeping a character consistent covers. Video bodies carry duration and resolution instead of size, which choosing resolution and length covers. Every video model id with its type and per-second price sits in one table on the NSFW video API page.

Failures, back-pressure and expiry

When a run fails after starting, its credits go back to the balance by themselves once the record turns failed. Treat failed as terminal and settled: branch on error_code for the reason, and never reconcile balances from your side. The refund is written under a condition only one writer can satisfy, so a redelivered provider notification cannot credit you twice or skip you once.

Crossing a rate limit returns 429 with the code rate_limit_exceeded and a Retry-After header. RFC 6585 defines that status: "the 429 status code indicates that the user has sent too many requests in a given amount of time". RFC 9110 section 10.2.3 defines the header as one that "indicates how long the user agent ought to wait before making a follow-up request". Read it rather than guessing, and see storyboards and previz for what that means when you are firing a batch. Too little balance is 402, with no record created. Full detail is in the API reference.

FAQ ( 3 )

Frequently asked questions

Do I have to keep polling for a generation to finish?

No. Polling is how you find out, not how it happens. Runs are reconciled on our side whether or not a client is connected, so a job started by a process that then died still reaches completed or failed; pick it up by id later.

Can I call this from browser code?

Not with your key. A key spends credits and cannot be scoped down, so anything shipped to a browser is a key you have published. Put the calls behind your own server, keep the key in its environment, and let your front end talk to that.

Can I hardcode model ids, sizes and costs?

You can, and it will drift. The catalog endpoint is what the model pages render from and what the server bills from, so a copied value stops matching the day the catalog changes. Fetch it at start-up and cache it for the life of the process.

Related guides

All guides