Developers

API reference

Every endpoint, field and error. JSON over HTTPS; the same API powers the website, the SDKs and the MCP server.

Basics

  • Base URL https://tanvo.ai/api/v1. Requests and responses are JSON; every endpoint sends CORS headers.
  • A run is asynchronous: POST /generations answers 202 with the run, then you poll GET /generations/{id} or receive a webhook.
  • Stability: fields under v1 are only ever added, never removed or changed in meaning. Breaking changes go to v2, and v1 stays for at least six months after. Ignore fields you do not know.
  • The error code in an error response is stable; message is for people and may change.
  • Machine-readable spec: /openapi.yaml (OpenAPI 3.1).

Authentication

MethodHeaderNotes
API keyAuthorization: Bearer sk_…Create under Settings → API keys. Billed to your account with your plan's limits; runs appear in your history. An invalid or revoked key is 401 invalid_api_key.
Free tierx-anon-id: <your id>Any id you make up, 8–80 characters of letters, digits and . _ -. The first request opens a 5-credit wallet; each address can open 3 a day. Only studio-image-v1, watermarked.

When a request carries both, only the key counts. Keep keys on your server.

Endpoints

MethodPathIdentityPurpose
GET/modelsnoneModel catalogue with options, defaults and prices
GET/appsnoneReady-made apps and their preset looks
GET/merequiredWho you are and the credits left
POST/generationsrequiredStart a run (202)
GET/generations/{id}requiredOne run; reading it also settles it upstream, so polling this is enough
GET/generations?limit=&before=requiredYour runs, newest first, cursor paging
POST/uploadsrequiredA signed upload slot for a local file

GET /models

Every model with the options it accepts. A null option is not accepted by that model. Read this at start-up rather than hard-coding ids; when you leave an option out of a request, use the model's defaults.

{ "models": [ {
  "id": "nano-banana-2", "kind": "image", "name": "Nano Banana 2", "vendor": "Google", "free_tier": false,
  "inputs": ["text", "image"],
  "options": { "aspect": ["1:1","16:9",…], "resolution": ["1K","2K","4K"], "format": ["PNG","JPG"],
               "tier": null, "duration": null, "audio": null },
  "reference_images_max": 14,
  "defaults": { "aspect": "1:1", "resolution": "1K", "format": "PNG" },
  "credits_at_defaults": 25, "prompt_max_chars": 5000
} ] }
Model idKindCredits at defaultsFree tier
studio-video-v1video20yes
minimax-h3video90
seedance-2-5video300
seedance-2video480
seedance-2-fastvideo445
seedance-2-minivideo130
seedance-1-5-provideo115
kling-3-0video155
kling-2-6video130
kling-3-turbovideo210
kling-avatarvideo125
veo-3-1video115
wan-3-0video185
ltx-2-5video185
flux-3video315
studio-image-v1image5yes
nano-banana-2image25
nano-banana-2-liteimage15
nano-banana-proimage50
nano-bananaimage15
gpt-image-2image25
gpt-image-2-5image10
seedream-5-liteimage15
seedream-4image10
seedream-4-5image15
seedream-5-proimage15
qwen-image-3image10
qwen-image-2-1image10
grok-imagine-2image20
suno-v6music60

GET /apps

Every app on the site with its upload slots (inputs) and preset looks. Each look has the tuned prompt, model and options, a real example output and a url that opens the app on that look. To run a look, send its prompt, model and options (filled with the model's defaults) to POST /generations, with the user's photos in imageUrls in the order of inputs. Music apps have kind: "music" and their looks already set options.tier.

{ "apps": [ {
  "slug": "ai-pet-portrait-generator", "kind": "image", "name": "Pet Portrait Generator",
  "description": "A painted portrait of the one who runs the house.", "category": "Pets",
  "model": "seedream-5-lite", "url": "https://tanvo.ai/image/ai-pet-portrait-generator",
  "inputs": [ { "id": "in1", "label": "Pet photo", "hint": "Max 10MB", "accept": "image/*", "min": 1, "max": 1 } ],
  "looks": [ { "id": "royal", "name": "Royal monarch", "prompt": "Keep the pet from the photo exactly…",
               "model": "seedream-5-lite", "options": {}, "example": { "url": "https://media.tanvo.ai/…", "kind": "image" },
               "url": "https://tanvo.ai/image/ai-pet-portrait-generator?look=royal" } ]
} ] }

GET /me

{ "tier": "paid" | "free" | "anonymous", "email": "…", "credits": 930, "watermarked": false, "concurrency": 3 }

Anonymous callers have no email.

POST /generations

FieldTypeNotes
kindimage | video | musicRequired
modelstringRequired. An id from /models
promptstringRequired. Up to the model's prompt_max_chars
optionsobjectRequired. resolution always; aspect, tier, duration, format, audio as the model allows
imageUrlsstring[]Up to 14 photos: /uploads URLs or any public https image (PNG, JPEG or WebP, up to 10 MB; fetched and stored for you). Makes the run image-to-image or image-to-video
endImageUrlstringEnd frame, on video models that support it
audioUrlstringDriving audio for kling-avatar (upload it first); priced on its real length
negativePromptstringUp to 2,000 characters, on models that use it
webhookUrlstringhttps, up to 500 characters. The finished run is POSTed here (and only here); see Webhooks
requestKeystringUp to 120 characters. Idempotency: the same caller and key returns the first run and is not charged again. Always send one when you retry

The price is fixed at submission from the model, resolution, length and audio, and returned in cost. The response is 202 with { generation }.

curl -s https://tanvo.ai/api/v1/generations \
  -H "Authorization: Bearer $TANVO_API_KEY" -H "content-type: application/json" \
  -d '{"kind":"video","model":"kling-3-0","prompt":"slow dolly-in on a lighthouse in a storm, cinematic",
       "options":{"aspect":"16:9","resolution":"720p","duration":5},
       "requestKey":"lighthouse-001"}'

Songs

suno-v6 makes two takes per run, each an MP3 with cover art, a title and the lyrics as sung. Put the mode in options.tier and set options.resolution to "song". 60 credits a run in every mode; usually under a minute.

options.tierprompt isMax lengthOther options
DescribeOne sentence about the song; lyrics are written for you3,000style
LyricsYour lyrics, sung in order; mark sections with [Verse] [Chorus] [Bridge]5,000style, title (80), vocal (m / f)
InstrumentalThe sound you want: genre, instruments, tempo1,000title
{
  "kind": "music", "model": "suno-v6",
  "prompt": "An upbeat pop song for Maya's 25th birthday about her terrible parallel parking",
  "options": { "tier": "Describe", "resolution": "song", "style": "acoustic pop, big singalong chorus" }
}

Each output carries url (MP3), cover, title, lyrics (not for instrumentals) and seconds.

The generation object

{
  "generation": {
    "id": "cmuo…",
    "kind": "image",
    "model": "nano-banana-2",
    "prompt": "…",
    "options": { "aspect": "16:9", "resolution": "2K", "format": "PNG" },
    "cost": 40,
    "status": "SUCCEEDED",
    "error": null,
    "createdAt": 1790000000000,
    "outputs": [ { "url": "https://media.tanvo.ai/outputs/cmuo…/0.png", "mime": "image/png" } ]
  }
}
  • status: PENDING → PROCESSING → SUCCEEDED or FAILED.
  • cost is in credits; 0 means a free run. A failed run is refunded automatically.
  • outputs is filled only on success. The URLs are permanent and can be downloaded directly.
  • createdAt is in milliseconds.

Polling and listing

Poll GET /generations/{id} every 3–5 seconds. Images usually finish within a minute, video in 1–8 minutes, songs in about a minute; suggested client timeouts are 3, 10 and 5 minutes. A run stuck for 30 minutes is failed and refunded by the service. A run keeps going on the server when you stop polling, so you can always read it later.

GET /generations?limit=20&before=… lists your runs newest first (limit 1–100). Pass the previous page's next_before as before; it is null on the last page.

POST /uploads

For local files. Send { "mime": "image/png", "bytes": 123456 }; the answer has an upload_url (a signed PUT, valid 5 minutes) and the url to use in imageUrls. PUT the bytes with the same Content-Type. Images up to 10 MB, audio and video up to 50 MB; PNG, JPEG, WebP, MP4, MOV, MP3, WAV and M4A. If the file is already at a public https address, pass that address instead.

Webhooks

Instead of polling, have the finished run sent to you. With webhookUrl on a request, that URL alone receives it; otherwise runs submitted with an API key go to every endpoint registered under Settings → Webhooks (up to 5). One POST is sent when the run reaches SUCCEEDED or FAILED; the body is the generation object, exactly as GET /generations/{id} returns it (a failed run also has refunded).

HeaderValue
webhook-idStable per delivery, e.g. msg_cmuw…_succeeded. De-duplicate on it
webhook-timestampUnix seconds. Reject anything more than 5 minutes off
webhook-signaturev1,<base64 HMAC-SHA256> of ${webhook-id}.${webhook-timestamp}.${raw body}, keyed with your whsec_ secret (base64-decoded after the prefix)

Any 2xx within 10 seconds counts as delivered. Anything else, a timeout or a refused connection is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, with the same webhook-id. Redirects are not followed. URLs must be https and resolve to public addresses. An endpoint that fails for 3 days is switched off and its owner emailed; settings show the last 50 deliveries and can resend or send a test event. The signature follows the Standard Webhooks spec, so libraries such as standardwebhooks work too.

import { verifyWebhook } from "@tanvoai/sdk";

export async function POST(req: Request) {
  const raw = await req.text(); // the raw body, not re-serialised JSON
  const generation = await verifyWebhook(process.env.TANVO_WEBHOOK_SECRET!, req.headers, raw);
  // de-duplicate on req.headers.get("webhook-id"), then store generation.outputs
  return new Response(null, { status: 204 });
}

Errors

{ "error": "credits", "message": "Not enough credits for this run.", "issues": ["options.resolution: …"] }
HTTPerrorMeaningWhat to do
400invalidBad parameters; issues lists eachFix the request
400rejectedRefused by content moderation; not chargedRewrite the prompt
401unauthorizedNo identity at allSend a key or x-anon-id
401invalid_api_keyKey invalid or revokedCheck the key
402creditsNot enough creditsTop up
402allowanceFree wallet used upUse an API key
403needs_api_keyThe free tier does not cover this modelUse an API key
403trial_closedFree tier switched off for nowUse an API key
404not_foundNo such record, or not yours
409busyToo many runs at once (free 1, paid 3)Wait and retry
413too_largeFile too largeShrink the file
429anon_ip_dailyThis address opened its free wallets for todayUse an API key
429rate_limitedToo many requests; see Retry-AfterWait, then retry
502providerUpstream model failed; credits refundedRetry once with the same requestKey
503moderation_unavailableModeration is down; not chargedRetry later
503pausedGeneration paused by the operator; not chargedRetry later
500server_errorUnexpected; not chargedRetry once with the same requestKey

Limits

Limit
Concurrent runsFree accounts and the free tier 1, paid plans 3; beyond that 409 busy
Submissions20 a minute per identity, 60 a minute per address
Reads240 a minute per identity, 600 a minute per address
Upload slots30 a minute per identity
Free runsSigned-in accounts get free daily runs on the house engines; the anonymous free tier is 5 credits per id
ModerationEvery prompt and reference image is checked before a model runs; refused requests are never charged
CancelNot supported: a submitted run cannot be withdrawn

Rate-limited responses carry Retry-After and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.