Developer docs

NovaVerto API v1

Render rooms, match real furniture and order walkthrough videos from your own code. JSON over HTTPS, API keys, signed callbacks — and free test keys to build against before you pay.

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.

curl
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}]}'
Python · requests
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']}")
Node · fetch
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

MethodPathWhat it does
GET/v1/meThe key, the account, its credits, limits and usage
GET/v1/toolsEvery tool with its inputs; /v1/tools/{tool_id} for one
GET/v1/optionsRooms, styles, palettes, budgets, modes, qualities, prices, video options
POST/v1/uploadsA signed URL to PUT a photo to
POST/v1/rendersCreate a render (202)
GET/v1/rendersList the account's API renders, keyset-paged
GET/v1/renders/{id}One render, with its objects and matches
GET/v1/renders/{id}/matchesJust the objects and their product matches
POST/v1/renders/{id}/matchesStart product matching on demand (202)
GET/v1/projectsThe account's projects, keyset-paged; a project_id goes into POST /v1/renders
GET/v1/productsSearch the furniture catalogue; a product_id goes into product_ids
POST/v1/videosCreate a walkthrough video from a render (202)
GET/v1/videos/{id}One video
GET/v1/openapi.jsonThe 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 keyTest key
Prefixnv_live_nv_test_
Who can create oneStandard, Pro and Enterprise accountsEvery account, the free plan included
What a render doesRuns the real models on your photoReturns a sample result on a fixed clock
What it costsThe app's credit prices, from your balanceNothing
What it seesYour live renders and videosOnly what test keys created
LimitsBy 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 bytes

By 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_url or parent_render_id — unless the tool needs no photo, like the floor-plan creator.
  • room_type, style, palette and budget / budget_usd are optional; GET /v1/options lists the ids, and custom_room_type, custom_style and custom_palette take your own words when the id is options.custom_id.
  • mode is quick or pro, quality is 1k, 2k or 4k; only the combinations in options.costs exist, 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 in options.image_models. Credits are charged when the render is accepted and refunded if it fails. model picks an entry of options.image_models by id and fixes mode plus the sizes and prices listed for it.
  • count up to 4 makes sibling renders in one call; render_ids names them all and render_id the first.
  • project_id is optional. Without it, renders go to one project per key and day, named API · <key name> · <date>, which does not count against your plan's project limit. An explicit project must be yours or shared with you.
  • metadata keeps 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 from

Tools

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 page

GET /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

EventWhen
render.completedThe 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.failedThe render failed and its credits are refunded; data.error says why.
render.matching_completedProduct matching finished after the render — a run you requested, or matching that runs lazily.
render.matching_failedProduct matching failed; a charged run is refunded.
video.completedThe walkthrough is ready; data.url and data.download_url are signed.
video.failedThe 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.

Python
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)
Node
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 callback field reads failed with the last status we saw.
  • Deliveries are at least once. X-NovaVerto-Delivery (and delivery_id in 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"}
StatusCodeMeaning
400bad_requestThe request could not be read.
401api_key_requiredNo Authorization: Bearer nv_… header, or a bearer that is not an API key.
401api_key_invalidThe key is unknown or revoked. The two are deliberately not told apart.
401api_key_use_v1A key was sent to a route outside /v1. Keys only work under /v1.
402insufficient_creditsNot enough credits for the action; required, available and scope (user or org) say how far short.
403api_plan_requiredA live key on an account without a paid plan. Test keys keep working.
403forbiddenThe plan does not allow it — the pro model, 2K or 4K output, a Pro-only video resolution.
403project_limit_reachedAn explicit project_id on an account at its project limit; limit, used and plan say where it stands.
404not_foundNot there, not yours, or not in this key's mode — a test key never sees live objects, nor the reverse.
409conflictWrong state for the action, such as iterating on a render that has not finished.
409idempotency_conflictThe Idempotency-Key was used within the last 24 hours with a different body.
409idempotency_in_progressThe first request with this Idempotency-Key is still running; Retry-After: 2.
413payload_too_largeThe body, or the image behind image_url, is over the size limit.
422validation_errorA field is missing or malformed; detail lists which.
422idempotency_key_invalidThe Idempotency-Key is empty or longer than 255 characters.
422callback_url_invalidcallback_url is not a public https URL.
422callback_secret_missingThe key has no callback signing secret yet (it predates callbacks). Rotate the secret on the account page.
422image_fetch_failedimage_url could not be fetched, or is not a JPEG, PNG or WebP; reason says which.
422product_image_unavailableOne of the product_ids no longer has a picture at its store; product_ids and reasons name them.
429rate_limitedOver 120 requests in the current minute; Retry-After says when the window turns.
429daily_quota_exceededThe key's renders or videos for the rolling 24 hours are used up; limit, used, resets_at and Retry-After are included.
502upstream_errorA model or store behind us failed. Safe to retry; a charged render that fails is refunded.
503unavailableThe 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.

PlanRequests a minuteRenders a dayVideos a day
Standard12010020
Pro12030060
Enterprise1201,000200
Test keys (any plan)12020050

"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 turns

Idempotency

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: true

The 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.

RenderVideo
queued from 0 s → generating from 2 s → detecting from 7 s → done from 10 squeued 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 Code
claude mcp add --transport http novaverto https://api.novaverto.com/mcp \
  --header "Authorization: Bearer nv_live_…"
Cursor · ~/.cursor/mcp.json
{
  "mcpServers": {
    "novaverto": {
      "url": "https://api.novaverto.com/mcp",
      "headers": { "Authorization": "Bearer nv_live_…" }
    }
  }
}
VS Code · .vscode/mcp.json
{
  "servers": {
    "novaverto": {
      "type": "http",
      "url": "https://api.novaverto.com/mcp",
      "headers": { "Authorization": "Bearer nv_live_…" }
    }
  }
}
Claude Desktop and other stdio-only clients
{
  "mcpServers": {
    "novaverto": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.novaverto.com/mcp", "--header", "Authorization:${NOVAVERTO_AUTH}"],
      "env": { "NOVAVERTO_AUTH": "Bearer nv_live_…" }
    }
  }
}
ToolWhat it doesCalls
get_accountPlan, credits, market and what is left of today's quotasGET /v1/me
list_design_options, list_design_tools, get_design_toolValid room, style and palette ids, the one-click tools and their inputs, the cost tableGET /v1/options, /v1/tools
create_upload_urlA signed URL to upload a photo the client holds as a filePOST /v1/uploads
create_designRender from a photo URL, a data URL, an upload or an earlier design; waits up to 90 secondsPOST /v1/renders
get_design, list_designsStatus, render and detected furniture with store matches — optionally the picture itself; earlier API renders, newest firstGET /v1/renders
list_projectsThe account's projects — the app's, a team's and the API's — with a link into the appGET /v1/projects
search_catalogueReal products by text, store, category, room, colour and price, to place with create_designGET /v1/products
find_productsSearch stores for the furniture in a finished designPOST /v1/renders/{id}/matches
create_video, get_videoA camera-move walkthrough clip of a designPOST, 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.

Build on the engine behind every render

A test key takes a minute and costs nothing. Live keys come with Standard, Pro and Enterprise, at the same credit prices as the app.