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 /generationsanswers202with the run, then you pollGET /generations/{id}or receive a webhook. - Stability: fields under
v1are only ever added, never removed or changed in meaning. Breaking changes go tov2, andv1stays for at least six months after. Ignore fields you do not know. - The
errorcode in an error response is stable;messageis for people and may change. - Machine-readable spec: /openapi.yaml (OpenAPI 3.1).
Authentication
| Method | Header | Notes |
|---|---|---|
| API key | Authorization: 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 tier | x-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
| Method | Path | Identity | Purpose |
|---|---|---|---|
| GET | /models | none | Model catalogue with options, defaults and prices |
| GET | /apps | none | Ready-made apps and their preset looks |
| GET | /me | required | Who you are and the credits left |
| POST | /generations | required | Start a run (202) |
| GET | /generations/{id} | required | One run; reading it also settles it upstream, so polling this is enough |
| GET | /generations?limit=&before= | required | Your runs, newest first, cursor paging |
| POST | /uploads | required | A 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 id | Kind | Credits at defaults | Free tier |
|---|---|---|---|
studio-video-v1 | video | 20 | yes |
minimax-h3 | video | 90 | |
seedance-2-5 | video | 300 | |
seedance-2 | video | 480 | |
seedance-2-fast | video | 445 | |
seedance-2-mini | video | 130 | |
seedance-1-5-pro | video | 115 | |
kling-3-0 | video | 155 | |
kling-2-6 | video | 130 | |
kling-3-turbo | video | 210 | |
kling-avatar | video | 125 | |
veo-3-1 | video | 115 | |
wan-3-0 | video | 185 | |
ltx-2-5 | video | 185 | |
flux-3 | video | 315 | |
studio-image-v1 | image | 5 | yes |
nano-banana-2 | image | 25 | |
nano-banana-2-lite | image | 15 | |
nano-banana-pro | image | 50 | |
nano-banana | image | 15 | |
gpt-image-2 | image | 25 | |
gpt-image-2-5 | image | 10 | |
seedream-5-lite | image | 15 | |
seedream-4 | image | 10 | |
seedream-4-5 | image | 15 | |
seedream-5-pro | image | 15 | |
qwen-image-3 | image | 10 | |
qwen-image-2-1 | image | 10 | |
grok-imagine-2 | image | 20 | |
suno-v6 | music | 60 |
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
| Field | Type | Notes |
|---|---|---|
kind | image | video | music | Required |
model | string | Required. An id from /models |
prompt | string | Required. Up to the model's prompt_max_chars |
options | object | Required. resolution always; aspect, tier, duration, format, audio as the model allows |
imageUrls | string[] | 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 |
endImageUrl | string | End frame, on video models that support it |
audioUrl | string | Driving audio for kling-avatar (upload it first); priced on its real length |
negativePrompt | string | Up to 2,000 characters, on models that use it |
webhookUrl | string | https, up to 500 characters. The finished run is POSTed here (and only here); see Webhooks |
requestKey | string | Up 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.tier | prompt is | Max length | Other options |
|---|---|---|---|
Describe | One sentence about the song; lyrics are written for you | 3,000 | style |
Lyrics | Your lyrics, sung in order; mark sections with [Verse] [Chorus] [Bridge] | 5,000 | style, title (80), vocal (m / f) |
Instrumental | The sound you want: genre, instruments, tempo | 1,000 | title |
{
"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→SUCCEEDEDorFAILED.costis in credits;0means a free run. A failed run is refunded automatically.outputsis filled only on success. The URLs are permanent and can be downloaded directly.createdAtis 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).
| Header | Value |
|---|---|
webhook-id | Stable per delivery, e.g. msg_cmuw…_succeeded. De-duplicate on it |
webhook-timestamp | Unix seconds. Reject anything more than 5 minutes off |
webhook-signature | v1,<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: …"] }| HTTP | error | Meaning | What to do |
|---|---|---|---|
| 400 | invalid | Bad parameters; issues lists each | Fix the request |
| 400 | rejected | Refused by content moderation; not charged | Rewrite the prompt |
| 401 | unauthorized | No identity at all | Send a key or x-anon-id |
| 401 | invalid_api_key | Key invalid or revoked | Check the key |
| 402 | credits | Not enough credits | Top up |
| 402 | allowance | Free wallet used up | Use an API key |
| 403 | needs_api_key | The free tier does not cover this model | Use an API key |
| 403 | trial_closed | Free tier switched off for now | Use an API key |
| 404 | not_found | No such record, or not yours | |
| 409 | busy | Too many runs at once (free 1, paid 3) | Wait and retry |
| 413 | too_large | File too large | Shrink the file |
| 429 | anon_ip_daily | This address opened its free wallets for today | Use an API key |
| 429 | rate_limited | Too many requests; see Retry-After | Wait, then retry |
| 502 | provider | Upstream model failed; credits refunded | Retry once with the same requestKey |
| 503 | moderation_unavailable | Moderation is down; not charged | Retry later |
| 503 | paused | Generation paused by the operator; not charged | Retry later |
| 500 | server_error | Unexpected; not charged | Retry once with the same requestKey |
Limits
| Limit | |
|---|---|
| Concurrent runs | Free accounts and the free tier 1, paid plans 3; beyond that 409 busy |
| Submissions | 20 a minute per identity, 60 a minute per address |
| Reads | 240 a minute per identity, 600 a minute per address |
| Upload slots | 30 a minute per identity |
| Free runs | Signed-in accounts get free daily runs on the house engines; the anonymous free tier is 5 credits per id |
| Moderation | Every prompt and reference image is checked before a model runs; refused requests are never charged |
| Cancel | Not supported: a submitted run cannot be withdrawn |
Rate-limited responses carry Retry-After and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.