Technique
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 before it expires. This is that loop end to end, with the error each step returns and what a client should do about it.
Create a key, send it as a Bearer token
Keys are made in the console, on the API Keys page, 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.
A missing or revoked key answers 401 with the code authentication_required. 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 carries its own valid sizes with the credit cost of each 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.
The kind field tells you the shape of body a model wants: text to image, image to image, text to video, image to video, reference to video. Use it rather than working the same thing out from whether a reference-image object is present, which is right today and quietly wrong for whatever is added next. 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. You get 202 back with an id, a status of processing, and the credit cost just taken: credits come off at the start of a run, not at the end. Then poll GET /v1/generations/{id} until status leaves queued and processing. A completed record carries output_url pointing at our file endpoint; a failed one carries error.
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.find((m) => m.kind === "text-to-image"); 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.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 ?? "generation_failed"); const file = await fetch(rec.output_url, { headers: AUTH }); await writeFile(`${rec.id}.png`, Buffer.from(await file.arrayBuffer())); ```
For an editing or reference model, 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.
Failures, back-pressure and expiry
When a run fails after starting, the credits taken at the start return automatically, at the moment the record is marked failed, and the failed run and the refund both stay in your history. Treat failed as terminal and settled: read error 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. A file removed at the end of the retention window answers 410 with content_expired rather than 404, because a deleted file and a file that never existed are different things to a client. Full detail is in the API reference.
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.
