Everything the app can do, at the same credit prices, from https://api.novaverto.com/v1. Create a key on your account page, send it as a bearer token, and you are rendering. The interactive OpenAPI reference lives at https://api.novaverto.com/v1/docs; this page is the guide.
Quickstart
A room photo in, a photorealistic redesign with shoppable furniture out — four calls: sign an upload, PUT the photo, create the render, poll until finished. Use a test key (nv_test_…) and the same four calls return sample results in about ten seconds without spending a credit.
KEY=nv_live_… # or nv_test_… for sample results
API=https://api.novaverto.com/v1
AUTH="Authorization: Bearer $KEY"
# 1. Sign an upload
SIGN=$(curl -s -X POST $API/uploads -H "$AUTH" -H "Content-Type: application/json" \
-d '{"content_type":"image/jpeg","size_bytes":'$(stat -f%z room.jpg)'}') # Linux: stat -c%s
URL=$(echo "$SIGN" | jq -r .url); UPLOAD_KEY=$(echo "$SIGN" | jq -r .upload_key)
# 2. PUT the photo to the signed URL, with the headers the sign call returned
curl -s -X PUT "$URL" -H "Content-Type: image/jpeg" \
-H "x-goog-content-length-range: 0,10485760" --data-binary @room.jpg
# 3. Create the render (an Idempotency-Key makes a retried POST safe)
RENDER=$(curl -s -X POST $API/renders -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"upload_key":"'$UPLOAD_KEY'","room_type":"living_room","style":"japandi","budget_usd":3000}' \
| jq -r .render_id)
# 4. Poll until finished, then read the result and the matched products
until curl -s $API/renders/$RENDER -H "$AUTH" | jq -e .finished >/dev/null; do sleep 2; done
curl -s $API/renders/$RENDER -H "$AUTH" \
| jq '{status, result_url, products: [.objects[].matches[0] | {title, retailer_name, price, currency}]}'import time, uuid, requests
API = "https://api.novaverto.com/v1"
HEADERS = {"Authorization": "Bearer nv_live_…"} # or nv_test_… for sample results
# 1. Sign an upload
photo = open("room.jpg", "rb").read()
sign = requests.post(f"{API}/uploads", headers=HEADERS,
json={"content_type": "image/jpeg", "size_bytes": len(photo)}).json()
# 2. PUT the photo to the signed URL, with the headers the sign call returned
requests.put(sign["url"], headers=sign["headers"], data=photo).raise_for_status()
# 3. Create the render (an Idempotency-Key makes a retried POST safe)
render = requests.post(f"{API}/renders",
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
json={"upload_key": sign["upload_key"], "room_type": "living_room",
"style": "japandi", "budget_usd": 3000}).json()
# 4. Poll until finished, then read the result and the matched products
while True:
r = requests.get(f"{API}/renders/{render['render_id']}", headers=HEADERS).json()
if r["finished"]:
break
time.sleep(2)
print(r["status"], r["result_url"])
for obj in r["objects"]:
best = obj["matches"][0] if obj["matches"] else None
print(obj["label"], "→", best and f"{best['title']} · {best['price']} {best['currency']}")import { readFile } from "node:fs/promises";
const API = "https://api.novaverto.com/v1";
const HEADERS = { Authorization: "Bearer nv_live_…" }; // or nv_test_… for sample results
const JSON_HEADERS = { ...HEADERS, "Content-Type": "application/json" };
// 1. Sign an upload
const photo = await readFile("room.jpg");
const sign = await (
await fetch(`${API}/uploads`, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ content_type: "image/jpeg", size_bytes: photo.byteLength }),
})
).json();
// 2. PUT the photo to the signed URL, with the headers the sign call returned
await fetch(sign.url, { method: "PUT", headers: sign.headers, body: photo });
// 3. Create the render (an Idempotency-Key makes a retried POST safe)
const { render_id } = await (
await fetch(`${API}/renders`, {
method: "POST",
headers: { ...JSON_HEADERS, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ upload_key: sign.upload_key, room_type: "living_room", style: "japandi", budget_usd: 3000 }),
})
).json();
// 4. Poll until finished, then read the result and the matched products
let render;
do {
await new Promise((resolve) => setTimeout(resolve, 2000));
render = await (await fetch(`${API}/renders/${render_id}`, { headers: HEADERS })).json();
} while (!render.finished);
console.log(render.status, render.result_url);
for (const obj of render.objects) {
const best = obj.matches[0];
console.log(obj.label, "→", best && `${best.title} · ${best.price} ${best.currency}`);
}Everything in v1
| Method | Path | What it does |
|---|---|---|
GET | /v1/me | The key, the account, its credits, limits and usage |
GET | /v1/tools | Every tool with its inputs; /v1/tools/{tool_id} for one |
GET | /v1/options | Rooms, styles, palettes, budgets, modes, qualities, prices, video options |
POST | /v1/uploads | A signed URL to PUT a photo to |
POST | /v1/renders | Create a render (202) |
GET | /v1/renders | List the account's API renders, keyset-paged |
GET | /v1/renders/{id} | One render, with its objects and matches |
GET | /v1/renders/{id}/matches | Just the objects and their product matches |
POST | /v1/renders/{id}/matches | Start product matching on demand (202) |
GET | /v1/projects | The account's projects, keyset-paged; a project_id goes into POST /v1/renders |
GET | /v1/products | Search the furniture catalogue; a product_id goes into product_ids |
POST | /v1/videos | Create a walkthrough video from a render (202) |
GET | /v1/videos/{id} | One video |
GET | /v1/openapi.json | The OpenAPI document; /v1/docs renders it (no key needed) |
Authentication
Every /v1 request carries the key as a bearer token. Keys are created, rotated and revoked on the account page only — a key cannot manage keys — and they only work under /v1: a key sent anywhere else answers 401 api_key_use_v1.
curl https://api.novaverto.com/v1/me -H "Authorization: Bearer nv_live_…"There are two kinds of key, and the difference is the whole point:
| Live key | Test key | |
|---|---|---|
| Prefix | nv_live_ | nv_test_ |
| Who can create one | Standard, Pro and Enterprise accounts | Every account, the free plan included |
| What a render does | Runs the real models on your photo | Returns a sample result on a fixed clock |
| What it costs | The app's credit prices, from your balance | Nothing |
| What it sees | Your live renders and videos | Only what test keys created |
| Limits | By plan (see Limits) | 200 renders and 50 videos a day |
A live key acts as you: your credits, your projects, your market. On Enterprise, a member's key spends the organisation's pool. Live keys come with Standard, Pro and Enterprise; on a free account every live call answers 403 api_plan_required until you subscribe — test keys keep working.
A test key is for building. It never calls a model and never spends a credit; its output is synthetic and must not be shown as a real design. The sandbox runs on wall clock, so the code you write to poll or to receive callbacks is the code you ship.
GET /v1/me says who the key is and where the account stands:
{
"key": {"key_id": "…", "name": "Listing pipeline", "mode": "live", "created_at": "2026-09-14T09:12:00Z"},
"account": {"user_id": "…", "email": "you@example.com", "plan": "standard", "market": "us"},
"credits": {"available": 6400, "base": 400, "subscription": 6000, "scope": "user", "org": null},
// on Enterprise: "scope": "org" and "org": {"org_id": "…", "name": "…", "pool_credits": 41200}
"limits": {
"requests_per_minute": 120,
"renders_per_day": 100, "renders_used_24h": 3,
"videos_per_day": 20, "videos_used_24h": 0
},
"usage_30d": {"renders": 41, "videos": 2, "credits": 4300}
}Treat a key like a password: keep it server-side, never in a browser or a shipped app. If it leaks, revoke it on the account page — from the next request it answers 401 api_key_invalid, which is also what an unknown key gets. Creating a key binds you to the API terms in section 9 of our Terms.
Uploads
There are two ways to get a photo in.
Signed upload. POST /v1/uploads returns a URL to PUT the bytes to, with the headers the PUT must carry; then pass the upload_key to POST /v1/renders. JPEG, PNG or WebP, up to 10 MB; the URL is good for expires_in seconds.
POST /v1/uploads
{"content_type": "image/jpeg", "size_bytes": 812345}
→ 201 {"upload_key": "uploads/….jpg", "url": "https://storage.googleapis.com/…",
"headers": {"Content-Type": "image/jpeg", "x-goog-content-length-range": "0,10485760"},
"expires_in": 900}
PUT <url> # exactly the returned headers, body = the file bytesBy URL. Pass image_url instead: a public https address of a JPEG, PNG or WebP. We fetch it while handling your request — a 15-second budget, no redirects, no private or internal addresses — and answer 422 image_fetch_failed with a reason before anything is charged.
Either way the image is normalised before it is stored: metadata, location included, is stripped, and the long side is capped at 2048 px. Uploads are private to your account and go when the render does. The same upload flow feeds mask_key (a PNG whose white pixels bound an edit) and reference_keys (up to max_reference_images from GET /v1/options).
Renders
POST /v1/renders answers 202 with the render's id straight away and does the work in the background.
POST /v1/renders
Idempotency-Key: 6f1c2e9a-… # optional; any string ≤ 255 chars
{
"upload_key": "uploads/….jpg", # or "image_url": "https://…/room.jpg"
"room_type": "living_room", # optional — GET /v1/options lists rooms, styles, palettes
"style": "japandi", # optional; with none the model picks one that suits the room
"budget_usd": 3000, # or "budget": "mid" — see options.budgets
"palette": "warm", # optional
"mode": "quick", # quick | pro — see options.costs
"quality": "1k", # 1k | 2k | 4k (2K/4K: Pro and Enterprise)
"model": "flux-2-pro", # optional: an id from options.image_models; fixes mode and the sizes it lists
"count": 1, # up to 4 sibling renders
"project_id": null, # optional; without it, the key's daily API project
"callback_url": "https://example.com/hooks/novaverto", # optional, https, public
"metadata": {"listing": "MLS-48213"} # optional, up to 10 string pairs, kept with the render
}
→ 202 {"render_id": "…", "render_ids": ["…"], "project_id": "…", "status": "queued",
"credits_charged": 100, "sandbox": false}- Exactly one of
upload_key,image_urlorparent_render_id— unless the tool needs no photo, like the floor-plan creator. room_type,style,paletteandbudget/budget_usdare optional;GET /v1/optionslists the ids, andcustom_room_type,custom_styleandcustom_palettetake your own words when the id isoptions.custom_id.modeisquickorpro,qualityis1k,2kor4k; only the combinations inoptions.costsexist, and 2K/4K need Pro or Enterprise. A quick render is 65 credits, a pro render 176–283 by quality, and every other model lists its own price inoptions.image_models. Credits are charged when the render is accepted and refunded if it fails.modelpicks an entry ofoptions.image_modelsby id and fixesmodeplus the sizes and prices listed for it.countup to 4 makes sibling renders in one call;render_idsnames them all andrender_idthe first.project_idis optional. Without it, renders go to one project per key and day, namedAPI · <key name> · <date>, which does not count against your plan's project limit. An explicit project must be yours or shared with you.metadatakeeps up to 10 string pairs of your own with the render.
status moves through queued → generating → detecting → matching → done, or failed. finished is true once the render and any product matching have settled — poll on that, every 2 seconds, or take a callback. A render takes about 30 seconds.
GET /v1/renders/{render_id}
→ 200 {
"render_id": "…", "project_id": "…", "parent_render_id": null,
"status": "done", "finished": true, "matching": "done",
"tool_id": null, "room_type": "living_room", "style": "japandi", "budget": null,
"instruction": null, "mode": "quick", "quality": "1k", "image_model": "flux-2-pro",
"credits_charged": 100, "sandbox": false, "watermarked": false,
"source_url": "https://…", "result_url": "https://…", "download_url": "https://…",
"urls_expire_in": 1800,
"error": null, "model": "…", "duration_ms": 28400,
"objects": [
{"id": "…", "label": "sofa",
"bbox": {"x": 0.12, "y": 0.41, "w": 0.46, "h": 0.33}, # fractions of the image, top-left origin
"crop_url": "https://…",
"matches": [{"title": "…", "brand": "…", "retailer": "ikea.com", "retailer_name": "IKEA",
"price": 899, "currency": "USD", "product_url": "https://…",
"thumbnail_url": "https://…", "image_url": "https://…", "in_stock": true}]}
],
"callback": null, # or {"url": "…", "status": "pending" | "delivered" | "failed", "attempts": 1, "last_status": 200}
"metadata": {"listing": "MLS-48213"},
"created_at": "2026-09-14T12:00:00Z", "completed_at": "2026-09-14T12:00:29Z"
}source_url, result_url, download_url and each object's crop_url are signed and expire after urls_expire_in seconds. Store the id, not the URLs; GET the render again for fresh ones.
Iterate on a render
Pass parent_render_id and an instruction to make a new version; mask_key and reference_keys narrow and guide the edit. The parent must be finished, or the call answers 409 conflict.
POST /v1/renders
{"parent_render_id": "…", "instruction": "add a floor lamp by the sofa",
"mask_key": "uploads/….png", # optional: white pixels bound the edit
"reference_keys": ["uploads/….jpg"]} # optional: pieces or looks to take cues fromTools
GET /v1/tools lists every tool — virtual staging, declutter, wall paint, floor plans and the rest — with the inputs each takes. Pass tool_id and tool_inputs to POST /v1/renders.
GET /v1/tools/virtual_staging
→ {"id": "virtual_staging", "title": "Virtual staging", "inputs": [{"id": "room_type", …}, …], …}
POST /v1/renders
{"upload_key": "uploads/….jpg", "tool_id": "virtual_staging",
"tool_inputs": {"room_type": "living_room", "style": "scandinavian"}}List renders
GET /v1/renders lists the account's API renders in the key's mode, newest first, up to 100 a page; narrow with project_id and status, and follow next_cursor until it is null.
GET /v1/renders?limit=50&status=done&project_id=…
→ 200 {"items": [ …RenderOut… ], "next_cursor": "…"} # null on the last pageGET /v1/projects lists the account's projects, most recently changed first: the ones made in the app, the ones a team shares with the key's owner, and the per-day projects API renders land in (kind says which). Pass a project_id to POST /v1/renders to add a render to that project, and send people to web_url to open it in the app. A test key sees only its own sandbox project.
GET /v1/projects?limit=20
→ 200 {"items": [
{"project_id": "…", "title": "Living room", "kind": "app", "shared": false, "owner_name": null,
"render_count": 7, "cover_url": "https://storage.googleapis.com/…",
"web_url": "https://novaverto.com/workspace/…",
"created_at": "2026-09-02T10:04:00Z", "updated_at": "2026-09-18T16:40:00Z"},
{"project_id": "…", "title": "API · Listing pipeline · 2026-09-18", "kind": "api", …}
],
"next_cursor": null}Products
Every render detects the furniture it placed. objects[] carries each piece with its bbox (fractions of the image, top-left origin), a crop_url, and its matches: real products from stores in the account's market, priced in its currency, best match first. The render's matching field says where matching stands.
GET /v1/renders/{id}/matches returns just the objects and their matches. POST /v1/renders/{id}/matches starts a matching run on demand: 202 when it starts, 200 when one is already queued, running or done. A run costs matching_credits (from GET /v1/options; 0 means it is free and runs with every render by itself).
POST /v1/renders/{render_id}/matches
→ 202 {"render_id": "…", "matching": "queued", "credits_charged": 15}
→ 200 {"render_id": "…", "matching": "done"} # already queued, running or done: nothing charged
GET /v1/renders/{render_id}/matches
→ 200 {"render_id": "…", "matching": "done", "objects": [ … ]}The catalogue
GET /v1/products searches the furniture catalogue — free, and the real catalogue for test keys too. Filter with q, retailer, category, room, color (one id or several, comma-separated), price_min (inclusive) and price_max (exclusive), and series together with its retailer; order with sort (best, price_asc, price_desc, rating) and page with page and page_size (up to 50). The answer carries the ids to filter with: stores, categories, category_groups (the rooms), colors, price_buckets and series.
GET /v1/products?q=oak+coffee+table&room=living_room&price_max=400&sort=price_asc&page_size=12
→ 200 {"items": [
{"product_id": "…", "title": "Oak coffee table", "retailer_id": "ikea", "retailer_name": "IKEA",
"price": 129.0, "currency": "USD", "url": "https://www.ikea.com/…", "image_url": "https://…",
"category": "coffee_table", "color": "Oak", "in_stock": true, "rating": 4.6, "rating_count": 212,
"dimensions": {"w": 46.5, "d": 23.6, "h": 17.7, "unit": "in"}, …}
],
"total": 38, "page": 1, "page_size": 12, "locked_total": 0, "sort": "price_asc",
"stores": [{"id": "ikea", "label": "IKEA"}, …], "categories": ["coffee_table", …],
"category_groups": [{"id": "living_room", "label": "Living room", "categories": […]}, …],
"colors": [{"id": "wood", "label": "Wood", "hex": "#b08d5b", "count": 21}, …],
"price_buckets": [{"id": "0-100", "min": 0, "max": 100, "count": 4}, …], "series": []}
POST /v1/renders
{"upload_key": "uploads/….jpg", "product_ids": ["…"], "reference_role": "product",
"instruction": "Place the coffee table in front of the sofa."}To place exact catalogue items, pass their product_ids as product_ids to POST /v1/renders with reference_role: "product"; a piece whose store no longer has a picture answers 422 product_image_unavailable naming it. The catalogue follows the account's plan, as in the app: on a free account the rows past the plan's depth come back as {"locked": true} with a title and a picture but no id, price or link, and locked_total says how many pieces the plan holds back.
Videos
POST /v1/videos turns a finished render into a cinematic walkthrough and answers 202; poll GET /v1/videos/{id} until status is done or failed — a clip takes a minute or two — or take a video.completed callback.
POST /v1/videos
Idempotency-Key: 9b0e4d7c-…
{"render_id": "…", "camera": "orbit", "aspect_ratio": "16:9", "resolution": "720p",
"duration_seconds": 4, "callback_url": "https://example.com/hooks/novaverto"}
→ 202 {"video_id": "…", "status": "queued", "credits_charged": 1000}GET /v1/options → video lists the cameras, aspect_ratios (16:9, or 9:16 for Reels and Shorts), the durations (4 or 8 seconds) and the resolutions, each with its costs per duration and a pro_only flag. From 1,000 credits for 4 seconds and 1,500 for 8 at 720p; higher resolutions cost more, and the ones marked pro_only need Pro or Enterprise. Credits are refunded if the clip fails.
GET /v1/videos/{video_id}
→ 200 {"video_id": "…", "render_id": "…", "project_id": "…",
"status": "done", # queued → generating → done | failed
"camera": "orbit", "aspect_ratio": "16:9", "resolution": "720p", "duration_seconds": 4,
"credits": 1000, "model": "…",
"url": "https://…", "download_url": "https://…", "poster_url": "https://…", # signed
"error": null, "sandbox": false,
"created_at": "2026-09-14T12:05:00Z", "completed_at": "2026-09-14T12:06:40Z"}Test keys get a sample clip on the sandbox clock; a sandbox render cannot be turned into a live video, nor the reverse.
Callbacks
Instead of polling, pass a callback_url on POST /v1/renders or POST /v1/videos. When the object settles we POST its full representation — the same JSON a GET returns — to that URL, signed with the key's callback secret (shown once when the key is created; rotate it on the account page).
POST https://example.com/hooks/novaverto
Content-Type: application/json
User-Agent: NovaVerto-Callbacks/1.0
X-NovaVerto-Event: render.completed
X-NovaVerto-Delivery: … # stable across retries — deduplicate on it
X-NovaVerto-Signature: t=1757851200,v1=5f1c9e… # HMAC-SHA256 of "<t>." + the raw body
{"event": "render.completed", "delivery_id": "…", "created_at": "2026-09-14T12:00:30Z",
"data": { …the same JSON GET /v1/renders/{id} returns… }}Events
| Event | When |
|---|---|
render.completed | The render is done. Where matching runs with the render, data.objects already carries the products; where it runs afterwards, they follow in render.matching_completed. |
render.failed | The render failed and its credits are refunded; data.error says why. |
render.matching_completed | Product matching finished after the render — a run you requested, or matching that runs lazily. |
render.matching_failed | Product matching failed; a charged run is refunded. |
video.completed | The walkthrough is ready; data.url and data.download_url are signed. |
video.failed | The video failed and its credits are refunded. |
Verify the signature
Every delivery carries X-NovaVerto-Signature: t=<unix>,v1=<hex>. The signed message is the timestamp, a full stop and the raw request body: `${t}.` + body. Compute HMAC-SHA256 of it with your callback secret, hex-encoded, compare in constant time against v1, and reject anything whose t is more than 300 seconds from your clock. Verify the raw bytes before you parse them.
import hashlib, hmac, time
def verify(signature_header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False # replayed, or a clock too far out
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Verify the raw bytes, then parse — e.g. in Flask:
# ok = verify(request.headers["X-NovaVerto-Signature"], request.get_data(), CALLBACK_SECRET)import { createHmac, timingSafeEqual } from "node:crypto";
function verify(signatureHeader, rawBody, secret, tolerance = 300) {
const { t, v1 } = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false; // replayed, or a clock too far out
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Verify the raw bytes, then parse — e.g. in Express, mount the route with
// express.raw({ type: "application/json" }) so req.body is a Buffer.Delivery
- Answer 2xx within 10 seconds and do the work afterwards. Anything else — a timeout, a 5xx, a 4xx — is retried: 5 attempts over about ten minutes, after which we stop and the object's
callbackfield readsfailedwith the last status we saw. - Deliveries are at least once.
X-NovaVerto-Delivery(anddelivery_idin the body) is the same on every retry of one event, so deduplicate on it. - Signed URLs in a payload are good for at least 30 minutes from when it was sent; GET the render or video for fresh ones after that.
- The URL must be public https; we do not follow redirects. A revoked key's pending callbacks are dropped.
Errors
Every error body has detail, a sentence for a person, and code, a stable word for your code — plus the extras named below where there are any. Validation errors (422 validation_error) list the failing fields in detail instead of a sentence.
HTTP/1.1 429 Too Many Requests
Retry-After: 3120
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1757851260
{"detail": "daily render quota exceeded", "code": "daily_quota_exceeded",
"limit": 100, "used": 100, "resets_at": "2026-09-14T12:52:00Z"}| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | The request could not be read. |
| 401 | api_key_required | No Authorization: Bearer nv_… header, or a bearer that is not an API key. |
| 401 | api_key_invalid | The key is unknown or revoked. The two are deliberately not told apart. |
| 401 | api_key_use_v1 | A key was sent to a route outside /v1. Keys only work under /v1. |
| 402 | insufficient_credits | Not enough credits for the action; required, available and scope (user or org) say how far short. |
| 403 | api_plan_required | A live key on an account without a paid plan. Test keys keep working. |
| 403 | forbidden | The plan does not allow it — the pro model, 2K or 4K output, a Pro-only video resolution. |
| 403 | project_limit_reached | An explicit project_id on an account at its project limit; limit, used and plan say where it stands. |
| 404 | not_found | Not there, not yours, or not in this key's mode — a test key never sees live objects, nor the reverse. |
| 409 | conflict | Wrong state for the action, such as iterating on a render that has not finished. |
| 409 | idempotency_conflict | The Idempotency-Key was used within the last 24 hours with a different body. |
| 409 | idempotency_in_progress | The first request with this Idempotency-Key is still running; Retry-After: 2. |
| 413 | payload_too_large | The body, or the image behind image_url, is over the size limit. |
| 422 | validation_error | A field is missing or malformed; detail lists which. |
| 422 | idempotency_key_invalid | The Idempotency-Key is empty or longer than 255 characters. |
| 422 | callback_url_invalid | callback_url is not a public https URL. |
| 422 | callback_secret_missing | The key has no callback signing secret yet (it predates callbacks). Rotate the secret on the account page. |
| 422 | image_fetch_failed | image_url could not be fetched, or is not a JPEG, PNG or WebP; reason says which. |
| 422 | product_image_unavailable | One of the product_ids no longer has a picture at its store; product_ids and reasons name them. |
| 429 | rate_limited | Over 120 requests in the current minute; Retry-After says when the window turns. |
| 429 | daily_quota_exceeded | The key's renders or videos for the rolling 24 hours are used up; limit, used, resets_at and Retry-After are included. |
| 502 | upstream_error | A model or store behind us failed. Safe to retry; a charged render that fails is refunded. |
| 503 | unavailable | The API is switched off for maintenance. Retry later. |
Limits
Limits are per key, and separate from the app's own limits: what you do in the app never counts against a key, and a key's traffic never counts against the app.
| Plan | Requests a minute | Renders a day | Videos a day |
|---|---|---|---|
| Standard | 120 | 100 | 20 |
| Pro | 120 | 300 | 60 |
| Enterprise | 120 | 1,000 | 200 |
| Test keys (any plan) | 120 | 200 | 50 |
"A day" is a rolling 24 hours. Every 2xx and 429 answer carries the minute window's state; a 429 adds Retry-After and says which limit it was: rate_limited for the minute, daily_quota_exceeded for the day, with limit, used and resets_at.
X-RateLimit-Limit: 120 # requests allowed in the current minute
X-RateLimit-Remaining: 117 # left in it
X-RateLimit-Reset: 1757851260 # unix time the window turnsIdempotency
Send an Idempotency-Key — any string up to 255 characters; a UUID is a good one — with POST /v1/renders and POST /v1/videos, and a retried request can never charge you twice. The same key with the same body within 24 hours replays the original 202 with Idempotent-Replayed: true; the same key with a different body is 409 idempotency_conflict; while the first request is still running you get 409 idempotency_in_progress with Retry-After: 2.
POST /v1/renders
Idempotency-Key: 6f1c2e9a-…
{ …same body as before… }
→ 202 (replayed: the original answer, nothing charged again)
Idempotent-Replayed: trueThe sandbox clock
A test key's objects move on wall clock from the moment they are created, so your polling and callback code runs against the same shape and timing as live traffic, only faster.
| Render | Video |
|---|---|
queued from 0 s → generating from 2 s → detecting from 7 s → done from 10 s | queued from 0 s → generating from 3 s → done from 20 s |
A finished sandbox render has two detected objects with two priced matches each, in the account's market currency, and model: "sandbox"; a callback, if you asked for one, arrives a second after done.
MCP server for AI assistants
Clients that speak the Model Context Protocol — Claude Code, Cursor, VS Code, Windsurf, Codex, Claude Desktop — can use NovaVerto directly: render a room from a photo, refine it in conversation, find the furniture in stores and order a walkthrough video. The server speaks Streamable HTTP at https://api.novaverto.com/mcp and takes the same API key as a bearer token; a test key returns the sandbox's sample results. What an assistant can do with it.
claude mcp add --transport http novaverto https://api.novaverto.com/mcp \
--header "Authorization: Bearer nv_live_…"{
"mcpServers": {
"novaverto": {
"url": "https://api.novaverto.com/mcp",
"headers": { "Authorization": "Bearer nv_live_…" }
}
}
}{
"servers": {
"novaverto": {
"type": "http",
"url": "https://api.novaverto.com/mcp",
"headers": { "Authorization": "Bearer nv_live_…" }
}
}
}{
"mcpServers": {
"novaverto": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.novaverto.com/mcp", "--header", "Authorization:${NOVAVERTO_AUTH}"],
"env": { "NOVAVERTO_AUTH": "Bearer nv_live_…" }
}
}
}| Tool | What it does | Calls |
|---|---|---|
get_account | Plan, credits, market and what is left of today's quotas | GET /v1/me |
list_design_options, list_design_tools, get_design_tool | Valid room, style and palette ids, the one-click tools and their inputs, the cost table | GET /v1/options, /v1/tools |
create_upload_url | A signed URL to upload a photo the client holds as a file | POST /v1/uploads |
create_design | Render from a photo URL, a data URL, an upload or an earlier design; waits up to 90 seconds | POST /v1/renders |
get_design, list_designs | Status, render and detected furniture with store matches — optionally the picture itself; earlier API renders, newest first | GET /v1/renders |
list_projects | The account's projects — the app's, a team's and the API's — with a link into the app | GET /v1/projects |
search_catalogue | Real products by text, store, category, room, colour and price, to place with create_design | GET /v1/products |
find_products | Search stores for the furniture in a finished design | POST /v1/renders/{id}/matches |
create_video, get_video | A camera-move walkthrough clip of a design | POST, GET /v1/videos |
Every tool is a wrapper over the route beside it, so prices, plan gates, limits and test-key behaviour are exactly this API's; a tool's polling counts against the key's requests a minute like your own would. Photos come in as a public https URL, a data: URL of up to about 3 MB, or an upload through create_upload_url. Renders wait up to 90 seconds inside the call and report progress; a longer job comes back unfinished with a hint to call get_design again. Errors arrive as plain sentences the assistant can relay. Chat apps need no key at all: Claude, ChatGPT and Gemini connect by signing in to your NovaVerto account and spend the credits it already has — how to connect one.
Changelog
- 2026-09-19 — Added GET /v1/projects (the account's projects) and GET /v1/products (catalogue search, for product_ids).
- 2026-09-14 — v1 launched: uploads, renders, product matches, videos, signed callbacks and test keys.
Breaking changes to v1 are announced here at least 30 days before they land, unless security forces our hand. Additions — new fields, new endpoints, new error codes — can arrive at any time, so ignore what you do not know.