Developers

Salven API

The studio’s models at the studio’s prices, from Claude, from your own code or from a coding agent. Every run is paid from your credits and lands in your gallery.

Overview

The Salven API does what the Salven studio does: the same models, the same prices, the same checks. It makes images, video, audio (speech, music, sound effects) and 3D models, applies the ready-made effects, puts your saved characters into a picture or a clip by their @handle, and reads a text or makes a clip speak in your saved voices.

A run is charged in credits to the account the key belongs to, and its result lands in that account’s gallery on salven.ai. The price is known before anything is spent (a dry run), it is taken when the run starts, and a run that fails gives its credits back.

The API is open to every Salven account. There are three ways in:

WayFor whomWhat you need
ClaudeAnyone who uses Claude: you ask in a chat, Claude runs the models.The address https://salven.ai/mcp and your Salven sign-in.
RESTYour own scripts, services and automations.An API key and any HTTP client.
An agentClaude Code, Cursor and other coding agents.An API key and the file SKILL.md.

All three reach the same server code, so a run costs the same and is checked the same whichever way it starts.

Connect Claude

Salven is a remote MCP server at https://salven.ai/mcp. Add it to Claude once, and Claude can choose a model, quote a price, run it and hand you the result, all on your Salven account.

The quickest way to connect Claude is the button on the Salven MCP page: it opens Claude's new connector window with the name and address already filled in.

Claude on the web, desktop and mobile

  1. In Claude, open Customize → Connectors → Add custom connector. The mobile apps cannot add a connector: do this step on claude.ai, and the connector then works in the mobile apps too.
  2. Paste the address https://salven.ai/mcp.
  3. Claude sends you to Salven. Sign in if you are not signed in, read what the app will be able to do, and press Allow.

Who can add a connector depends on your Claude plan: the Free plan allows one custom connector, and on Team and Enterprise plans a connector is added for the whole organization by an owner (or a member whose role allows it), not by each member for themselves.

The same address, with a copy button, is in Settings under Claude & API.

Claude Code

bash
# Add Salven to Claude Code as an MCP server. Then, inside Claude Code, run /mcp and pick salven to sign in.
claude mcp add --transport http salven https://salven.ai/mcp

The command only saves the server. To sign in, start Claude Code, run /mcp, pick salven and follow the sign-in: your browser opens on Salven, where you sign in and press Allow, as above.

Other MCP clients

Any client that speaks MCP over Streamable HTTP can use the same address. A client that supports OAuth signs you in the way Claude does. A client that sends a fixed header can use an API key instead:

Server URL:  https://salven.ai/mcp
Transport:   Streamable HTTP
Header:      Authorization: Bearer <your Salven API key>

In Claude Code the same header is set when the server is added, instead of the sign-in above; the shell takes the key from the SALVEN_API_KEY environment variable:

bash
# Add Salven to Claude Code with an API key instead of the OAuth sign-in.
# The shell reads the key from SALVEN_API_KEY, so it is never typed into the command.
# Claude Code saves the header, with the key in it, together with the server.
claude mcp add --transport http salven https://salven.ai/mcp \
  --header "Authorization: Bearer $SALVEN_API_KEY"

What Claude can do

The connection gives Claude the tools below. They call the same code as the REST API, and runs made through them count toward the same limits.

ToolWhat it does
accountThe account the connection acts as, and its credit balance.
list_modelsThe models available now, optionally of one kind: id, modes, inputs and base price.
get_modelOne model’s full spec: modes, params, inputs, aspect options, prompt limits and pricing.
generateStarts a generation with a model, or only quotes it with dryRun: true. Waits up to about 45 seconds and answers the finished files, or the ids of runs still going.
get_generationOne generation’s status and, when it is done, its files. Can wait up to 40 seconds for it to finish.
list_generationsThe account’s works, newest first, made on the site or through the API.
list_effectsThe ready-made effects: the pictures each takes, its price and its quality options.
run_effectApplies an effect to your picture or pictures, or only quotes it with dryRun: true.
list_charactersYour characters and AI influencers, each with its @handle.
create_characterSaves a person as a character: a name, an @handle, one to four photos and, if you like, one of your saved voices. Free.
list_saved_voicesYour saved voices: recordings of a voice, each with its id, name, language and length.
create_voiceSaves a voice from a recording of 10–60 seconds of one person speaking. Free.
design_voiceMakes up a voice from a description and records a text in it. Paid; create_voice then saves the recording as a voice.
upload_from_urlCopies a file from a public web address into your Salven uploads. Salven downloads the file itself: images up to 10 MB, audio up to 20 MB, video and GLB models up to 60 MB.
create_upload_linkMakes a one-time link for adding files from your device.
get_upload_linkThe files that arrived through an upload link.

Giving Claude a file

A model’s inputs must be your own files on Salven, and a chat cannot pass a tool the picture you attached to a message. Claude has three ways to get a file:

  • An output of one of your earlier works, which Claude finds with list_generations.
  • A public web address, which Claude copies in with upload_from_url. Salven downloads the file itself, so the 25 MB limit of a direct upload does not apply: an image may be up to 10 MB, an audio file up to 20 MB, a video or a GLB model up to 60 MB. This is the way to bring in a video or a 3D model larger than 25 MB. The download has to finish while Claude waits for the tool’s answer, so a large file needs an address that serves it quickly.
  • A file on your device: Claude makes an upload link with create_upload_link and gives you a page to open. You pick the files there, with no sign-in, and tell Claude they are in. A link works for 30 minutes and takes up to 10 files, in the formats and sizes of the upload rules: a file sent this way passes through the web server, so a video or a GLB model must be under 25 MB. An agent with a terminal, such as Claude Code, can send a file from the disk to the link itself.

Credits and disconnecting

Everything Claude makes is paid from your credits, at the studio’s prices. generate and run_effect can quote a run without starting it, and Salven’s instructions tell Claude to name the cost and wait for your yes before an expensive run (video, 3D, several pictures) or a spend you did not ask for. When exactly to ask is still Claude’s decision, so say so if you want a quote first.

To end a connection, open Settings, find the app under Claude & API and press Disconnect twice (the second press confirms). The app loses access at once.

Quick start

One image over REST, start to finish. You need a Salven account with some credits and a terminal with curl.

  1. Open Settings, find Claude & API, type a name for the key and press Create key.
  2. Copy the key now: it is shown once.
  3. Put it in the environment variable SALVEN_API_KEY, for example with export SALVEN_API_KEY=<your key> in the shell you will use.
  4. Run the calls below one by one.
bash
API=https://salven.ai/api/v1
AUTH="Authorization: Bearer $SALVEN_API_KEY"

# Who the key acts as, and the balance
curl -s "$API/me" -H "$AUTH"

# The image models, each with its params, inputs and pricing
curl -s "$API/models?kind=image" -H "$AUTH"

# Price first: dryRun answers the cost and starts nothing
curl -s "$API/generations" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"model":"nano-banana-2","prompt":"a red fox in the snow, film photo","params":{"aspect_ratio":"16:9","resolution":"2K"},"dryRun":true}'

# Then run it. The Idempotency-Key names THIS generation: to retry it (a timeout, a 5xx, a 409),
# rerun the command with the same key; pick a new key only for a new generation.
curl -s "$API/generations" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: fox-001" \
  -d '{"model":"nano-banana-2","prompt":"a red fox in the snow, film photo","params":{"aspect_ratio":"16:9","resolution":"2K"}}'

# Wait for the result: put generations[0].id from the answer above in place of GENERATION_ID,
# and repeat while "status" is "queued" or "processing"
curl -s "$API/generations/GENERATION_ID?wait=60" -H "$AUTH"

The examples are written for bash: macOS, Linux, WSL, or Git Bash on Windows. In Windows PowerShell curl is an alias of another command, so use curl.exe and PowerShell’s own syntax for variables and quoting, or run the examples in Git Bash or WSL.

CallWhat it answers
GET /meThe account and its balance.
GET /models?kind=imageThe image models, each with its params, inputs and pricing.
POST /generations with "dryRun": trueThe price: cost for one generation, total for the whole request. Nothing starts.
POST /generations202 and generations[].id. The credits are taken now.
GET /generations/{id}?wait=60The generation. Repeat while status is queued or processing; once it is done, outputs[].url are the files.

The Idempotency-Key header names this one generation. If the answer is lost, send the same request with the same key: it returns the first answer instead of charging again. See Idempotency.

With an input file

A model that works from a picture, a video or an audio file needs the file on Salven first. This uploads a photo and turns it into a clip:

bash
API=https://salven.ai/api/v1
AUTH="Authorization: Bearer $SALVEN_API_KEY"

# Upload the picture. The answer's "url" is the file's address on Salven:
# {"url":"https://cdn.salven.ai/uploads/...","kind":"image","mimeType":"image/jpeg","bytes":184233}
curl -s "$API/uploads" -H "$AUTH" -F "file=@photo.jpg"

# Put that url here
PHOTO_URL="PASTE_THE_URL_FROM_THE_ANSWER_ABOVE"

# Price first: the same body with dryRun
curl -s "$API/generations" -H "$AUTH" -H "Content-Type: application/json" -d @- <<EOF
{
  "model": "kling-v3-pro",
  "prompt": "she turns and smiles, slow dolly in",
  "params": { "duration": 5 },
  "inputs": { "startImage": "$PHOTO_URL" },
  "dryRun": true
}
EOF

# Then run it, with a key that names this generation (reuse it only to retry this very request)
curl -s "$API/generations" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: anim-001" -d @- <<EOF
{
  "model": "kling-v3-pro",
  "prompt": "she turns and smiles, slow dolly in",
  "params": { "duration": 5 },
  "inputs": { "startImage": "$PHOTO_URL" }
}
EOF

# Wait for the clip: put generations[0].id from the answer above in place of GENERATION_ID,
# and repeat while "status" is "queued" or "processing" (video takes minutes)
curl -s "$API/generations/GENERATION_ID?wait=60" -H "$AUTH"

Inputs must be your own files: uploaded with POST /uploads, or outputs[].url of your own generations. A link from anywhere else is refused with input_not_allowed: download the file and upload it.

Authentication

Every request carries an API key: Authorization: Bearer <key>. The header X-API-Key: <key> is accepted as well.

  • A key is slv_ followed by 40 letters and digits.
  • It is shown once, when it is made. Salven stores only a hash of it, so a lost key cannot be shown again: revoke it and make a new one.
  • An account may have up to 10 live keys. Make and revoke them in Settings under Claude & API. A revoked key stops working at once.
  • A key acts as its account: the account’s credits pay and its gallery gets the results. Treat it like a password: keep it in an environment variable or a secret store, and never put it in code, a repository or a log.
  • Nothing but the key is read. A browser signed in to salven.ai cannot call the API with its cookie.
HTTPCodeMeaning
401unauthorizedThe key is missing, is not a Salven key, is unknown or was revoked.
403api_not_availableThe API is not open to this account.

Conventions

  • Base URL: https://salven.ai/api/v1. Every path on this page is relative to it.
  • Bodies are JSON in both directions. Only POST /uploads takes multipart/form-data.
  • Times are ISO 8601, in UTC.
  • Every answer is sent with Cache-Control: no-store.
  • Money is counted in credits. A credit costs 5 ₽, and every credit figure is a decimal number (1.4, 240.5): prices are rounded up to a tenth of a credit.
  • An error from the API is { "error": { "code": …, "message": … } } with a matching HTTP status. The web server in front of the API can answer without a JSON body: a 413 for a request body over 25 MB, and a bare 502 or 504 while the site restarts. See Errors.

How a generation goes

  1. Balance. GET /me answers balance: the most a run may cost.
  2. Model. GET /models, or one kind with ?kind=. Read the chosen model’s spec: params, inputs, aspect, modes, pricing. Send only the names and values the spec lists.
  3. Uploads. Only when the model takes files: POST /uploads answers a url to use as an input.
  4. Quote. POST /generations with "dryRun": true answers cost (one generation) and total (cost × count).
  5. Start. The same body without dryRun, with an Idempotency-Key header. The answer is 202 with generations[].id, and the credits are taken.
  6. Wait. GET /generations/{id}?wait=60, repeated while status is queued or processing.
  7. Deliver. outputs[].url are direct links to the files.

Statuses

StatusMeaning
queuedAccepted and paid for, waiting its turn.
processingBeing made.
doneFinished. outputs holds the files.
failedCould not be made. The credits were returned (refunded).

A failed generation has error.code set to generation_failed, and error.reason says why in one word: content_prompt (the model’s content filter refused the prompt; it most often trips on names of brands, films, characters and real people), content_input (it refused an attached file), real_person (the model does not take photos of real people), no_output, input_too_small, tryon_no_fit, rig_pose, busy (the provider was overloaded: the same request may be sent again) or unknown. error.message says the same in a sentence. The provider’s own text is not shared. The credits are already back, so it is fine to try again, perhaps with other wording or another model. After two failures in a row, stop and look at the request.

How long it takes

WhatTypical time
Image10–60 seconds
Video1–5 minutes, longer for clips of 10–15 seconds
SpeechA few seconds to half a minute
MusicUp to a few minutes
3D model2–8 minutes; some models can take over half an hour
3D riggingAbout 2 minutes

Nothing on Salven’s side ends a run that hangs. A sensible client stops waiting after about 5 minutes for an image, 10 for audio, 20 for video and 60 for 3D, and does not start the same run again on its own: the run stays in the account’s gallery.

Endpoints

EndpointWhat it does
GET /meThe account behind the key and its balance.
GET /modelsEvery model the API can run, optionally of one kind.
GET /models/{id}One model’s spec.
POST /uploadsStores a file as your own and answers its URL.
POST /generationsStarts one or more generations, or quotes them.
GET /generations/{id}One generation: its status and, when done, its files.
GET /generationsThe account’s generations, newest first.
GET /charactersThe account’s characters and influencers.
GET /characters/{handle}One of them, with its photos.
POST /charactersMakes a character.
GET /voicesThe account’s saved voices.
POST /voicesSaves a voice from a recording.
POST /voices/designMakes a trial recording of a voice from a description (paid).
GET /effectsThe ready-made effects.
GET /effects/{slug}One effect’s spec.
POST /effects/{slug}/runRuns an effect, or quotes it.

The figures in the examples on this page are examples. Live prices are in GET /models and in the table under Models; the exact price of a request is what its dry run answers.

GET /me

json
{
  "id": "cmg1x2y3z0000abcd1234efgh",
  "username": "anna",
  "name": "Anna",
  "credits": 240.5,
  "dailyAllowance": 0,
  "balance": 240.5,
  "key": { "id": "cmg4k5m6n0001abcd5678ijkl", "name": "Claude Code", "prefix": "slv_Ab12Cd" }
}

credits never expire. dailyAllowance is a partner’s free daily credits left for today, and they are spent first. balance is the two together: the most a run may cost. key names the key the request came with.

GET /models and GET /models/{id}

GET /models answers { "models": [ … ] } in the studio’s order. ?kind= narrows it to image, video, audio or 3d; any other value is refused with 400 invalid_kind. GET /models/{id} answers one model, or 404 model_not_found. A model looks like this:

json
{
  "id": "kling-v3-pro",
  "name": "Kling v3 Pro",
  "kind": "video",
  "description": "Top-tier video quality, start from a frame",
  "pricing": { "credits": 2.6, "unit": "second", "secondsFrom": "params.duration" },
  "modes": [
    { "slug": "generate", "name": "Generate", "promptRequired": true, "priceMultiplier": 1 }
  ],
  "prompt": { "maxLength": 2500 },
  "negativePrompt": { "maxLength": 2500 },
  "aspect": null,
  "params": [
    {
      "name": "duration",
      "type": "number",
      "min": 3,
      "max": 15,
      "step": 1,
      "default": 5,
      "priced": true
    },
    {
      "name": "aspect_ratio",
      "type": "enum",
      "options": ["16:9", "9:16", "1:1"],
      "default": "16:9"
    },
    { "name": "audio", "type": "boolean", "default": false, "priceFactorWhenTrue": 1.5 }
  ],
  "inputs": [
    { "name": "startImage", "kind": "image", "required": false, "multiple": false, "max": 1 },
    {
      "name": "endImage",
      "kind": "image",
      "required": false,
      "multiple": false,
      "max": 1,
      "requires": "startImage"
    }
  ],
  "maxCount": 1,
  "startImageSetsShape": true,
  "seed": false,
  "references": null,
  "characters": { "supported": true, "maxCharacters": 3, "needsStartFrame": true, "onlyWith": null },
  "voices": null,
  "speechTags": null
}
FieldHow to read it
pricingcredits per unit. generation is a flat price. second multiplies by seconds, and secondsFrom says which: a duration param, or the length of an input file. 1000_characters is for speech: the prompt’s length. The mode’s priceMultiplier and the factors of the chosen options multiply the result; an input with pricePerFile adds that share of credits for each file sent. The total is rounded up to a tenth of a credit.
modesThe studio modes the model runs in. Send mode only when there is more than one; the default is generate, else the first. promptRequired: false means the inputs say everything and no prompt is needed.
prompt, negativePromptThe longest text in characters. negativePrompt: null means the model takes none.
paramsSettings, sent by name. number: within min..max on step; maxWith lowers max while another param holds a value (Kling 4.0’s duration stops at 20 with variant flash; a longer one is refused). enum: exactly one of options; priceFactors gives the price factor of each option, optionNotes says what an option is (a voice’s gender and tone, for example), onlyWith names options offered only beside another param’s values (Seedance 2.0’s 1080p only with variant pro; another pair is refused). boolean: true or false, with priceFactorWhenTrue when the switch costs extra. text: a string of at most maxLength characters, empty for none; billed: true means its characters are billed with the prompt’s. A param left out takes its default; an unknown name is refused.
aspectNot null: the shape of the result is the top-level aspect field, one of options. Null: the model has its own aspect_ratio param, or its input decides the shape.
inputsFiles, sent as inputs: { "<name>": "<url>" }, or as an array of URLs for a multiple input, at most max. kind is what the file must be. A required input must be there; one with requires is used only together with that other input. pricePerFile: each file of the list adds that share of the price.
maxCountHow many separate generations one request may start with count.
startImageSetsShapetrue: a start frame decides the clip’s shape, and aspect_ratio is ignored once one is sent.
seed, seedMaxWhether the model takes a seed, and its largest value. See Seeds.
referencesThe limits for reference media on the models that take it, else null.
charactersWhether an @handle in the prompt brings that person’s photos (supported), for how many people in one run (maxCharacters), whether a start frame must come with them (needsStartFrame), and the only values of another param they go with (onlyWith, e.g. Kling 4.0’s regular version, not Flash). See Characters.
voicesWhether the model takes your saved voices, sent by id in voices: min 1 is a speech model that speaks only in one of them; promptTags: true is a video model that takes up to max, named @Voice1, @Voice2… in the prompt; onlyWith as for characters (Kling 4.0’s regular version). null: none. See Saved voices.
speechTagsOn a speech model, the marks it reads inside the text, written its own way: square (any instruction in square brackets, "any", or only the listed words), paren (round brackets), angle (<laugh>), wrap (around words, <whisper>…</whisper>), pause (<#0.5#>). readsBrackets: true: a [word] that is not one of its marks may be spoken aloud. null: none known. SKILL.md lists which model reads which.

Two things are left to SKILL.md: the motions param type used by 3D rigging, and the rules of the references block.

POST /uploads

A multipart form with the field file and, optionally, kind (image, video, audio or model). Without kind it is told from the file’s first bytes. The answer is 201:

json
{
  "url": "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847000000-9f2c4e6a8b0d1f35.jpg",
  "kind": "image",
  "mimeType": "image/jpeg",
  "bytes": 184233
}
KindFormatsLargest file
imageJPEG, PNG, WebP10 MB
videoMP4, MOV, WebM25 MB
audioMP3, WAV, M4A, OGG20 MB
modelGLB (binary glTF 2.0)25 MB
  • The format is read from the bytes, not from the file name.
  • HEIC photos are refused with heic_not_supported: convert them to JPEG first.
  • A 3D model must be one GLB file with its textures inside; a .gltf is not taken.
  • The 25 MB of a video or a 3D model is the web server’s limit on the whole request body, so the file itself must be a little smaller. A larger body is stopped before the API sees it: that answer is a 413 whose body is not JSON.
  • A video or a GLB model over that, up to 60 MB, can come in only from a public web address, through the MCP tool upload_from_url: see Giving Claude a file.
  • The file becomes your own upload. Only your own files can be inputs: uploads, and outputs[].url of your own generations.

Refusals: 400 missing_file, invalid_form_data, invalid_kind; 413 file_too_large (an image or an audio file over its limit, with max in bytes); 415 unsupported_format, heic_not_supported; 502 storage_error (retry once); 503 uploads_disabled.

POST /generations

Request body
{
  "model": "nano-banana-2",
  "mode": "generate",
  "prompt": "a red fox in the snow, film photo",
  "params": { "aspect_ratio": "16:9", "resolution": "2K" },
  "count": 1,
  "enhancePrompt": true,
  "dryRun": false
}
FieldMeaning
modelRequired. A model id from GET /models.
modeOptional. One of the model’s modes[].slug.
promptRequired when the mode’s promptRequired is true. Any language; @handle names one of your characters. See Prompts.
negativePromptOnly where the model’s negativePrompt is not null, up to its maxLength.
aspectOnly where the model’s aspect is not null: one of its options.
paramsParam name → value, as the model’s params list them.
inputsInput name → file URL, or an array of URLs for a multiple input.
seedOnly where the model’s seed is true: a whole number from 0 to seedMax.
voicesOnly where the model’s voices is not null: ids of your saved voices. Exactly one on a speech model with voices.min 1; on a video model up to voices.max, named @Voice1, @Voice2… in the prompt in this order. See Saved voices.
count1 to maxCount separate generations, each charged. Default 1.
enhancePromptDefault true. false sends the prompt exactly as written.
dryRuntrue: answer the price, start nothing, charge nothing.

A field the list does not name is refused with invalid_request. Send an Idempotency-Key header with every real run; see Idempotency.

A dry run answers 200. cost is one generation, total is what the whole request will take:

Dry run answer
{
  "dryRun": true,
  "model": "nano-banana-2",
  "mode": "generate",
  "cost": 1.9,
  "count": 1,
  "total": 1.9,
  "balance": 240.5
}

A real run answers 202:

Answer
{
  "generations": [
    {
      "id": "cmg7q2w8e0003abcd9012mnop",
      "status": "queued",
      "kind": "image",
      "model": "nano-banana-2",
      "mode": "generate",
      "effect": null,
      "prompt": "a red fox in the snow, film photo",
      "characters": [],
      "seed": null,
      "source": "api",
      "cost": 1.9,
      "refunded": 0,
      "createdAt": "2026-10-01T09:30:00.000Z",
      "startedAt": null,
      "finishedAt": null,
      "outputs": [],
      "error": null
    }
  ],
  "charged": 1.9,
  "balance": 238.6
}

If something stops the request part-way through count (usually the balance), the generations already started stand, and the answer also has "partial": true and "stopped" with the code of what stopped it. To run the rest, send a new request with the smaller count under a new Idempotency-Key.

balance is null when it could not be read after the charge; GET /me has it. If reading the new generations back fails, generations holds only each id and status, and charged is null too: poll the ids as usual.

GET /generations/{id}

json
{
  "id": "cmg7q2w8e0003abcd9012mnop",
  "status": "done",
  "kind": "image",
  "model": "nano-banana-2",
  "mode": "generate",
  "effect": null,
  "prompt": "a red fox in the snow, film photo",
  "characters": [],
  "voices": [],
  "seed": null,
  "source": "api",
  "cost": 1.9,
  "refunded": 0,
  "createdAt": "2026-10-01T09:30:00.000Z",
  "startedAt": "2026-10-01T09:30:01.000Z",
  "finishedAt": "2026-10-01T09:30:24.000Z",
  "outputs": [
    {
      "url": "https://cdn.salven.ai/generations/cmg7q2w8e0003abcd9012mnop/0.png",
      "mimeType": "image/png",
      "width": 2048,
      "height": 1152,
      "durationSeconds": null,
      "thumbnailUrl": null
    }
  ],
  "error": null
}

With ?wait=<seconds>, from 0 to 60, the answer is held until the generation is done or failed, or until the seconds pass; then it answers as things stand. An id that is not one of the account’s generations answers 404 not_found.

FieldMeaning
statusqueued, processing, done or failed.
kindimage, video, audio or 3d.
model, mode, promptWhat ran. For a run of an effect these are null, and effect holds its slug, name and quality.
charactersWho the prompt’s handles brought in, each with handle, name and kind.
voicesThe saved voices the request named, each with id and name, in @Voice order.
seedThe seed it ran with, when one was sent.
sourceWhere it was started: web, bot or api.
cost, refundedThe credits taken, and the credits returned if it failed.
createdAt, startedAt, finishedAtTimes. The last two are null until they happen.
outputsThe files, once done: url, mimeType, width, height, durationSeconds, thumbnailUrl. Empty until then.
errornull, or on a failed run an object with code (generation_failed), reason and message.

A 3D result is a .glb file. Its output may also carry faces, and a rigged model fbxUrl and clips; SKILL.md describes them.

GET /generations

The account’s generations, newest first: all of them, not only the ones started through the API. Query: limit (20 by default, up to 50), kind (image, video, audio or 3d) and cursor. The answer is { "generations": [ … ], "nextCursor": … }: pass nextCursor as cursor for the next page; it is null on the last one. A bad query answers 400 invalid_kind or invalid_cursor.

Characters

A character is a person saved on the account: a name, an @handle and one to four photos of that one person, the first being the main photo. Write @anna in a prompt, and a model that takes characters gets anna’s photos with the request, so the result keeps her look. A mention costs nothing extra. AI influencers made on salven.ai have handles too and work the same way.

  • GET /characters answers { "characters": [ … ] }: characters, newest first, then influencers. Each has handle, name, kind (character or influencer), photos (how many a mention sends), voice (its saved voice, or null) and createdAt.
  • GET /characters/{handle} answers { "character": { … } } (the shape shown under POST /characters below), or 404 character_not_found. Here photos is a list, the main photo first, each with url, width and height; an influencer’s width and height are null.
  • POST /characters makes a character. It is free and answers 201 with the same { "character": { … } }.
POST /characters
{
  "name": "Anna",
  "handle": "anna",
  "photos": [
    "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847000000-9f2c4e6a8b0d1f35.jpg",
    "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847060000-3b7d9e1c5a2f4860.jpg"
  ],
  "voice": "vce_8c1e5a3f9b7d2046e1c3a5b7"
}
Answer
{
  "character": {
    "handle": "anna",
    "name": "Anna",
    "kind": "character",
    "photos": [
      {
        "url": "https://cdn.salven.ai/characters/chr_5d1f0a9c3e7b2468ace01357/4a6c8e0b2d4f6a81.jpg",
        "width": 1536,
        "height": 2048
      },
      {
        "url": "https://cdn.salven.ai/characters/chr_5d1f0a9c3e7b2468ace01357/b3d5f7a9c1e30264.jpg",
        "width": 1536,
        "height": 2048
      }
    ],
    "voice": { "id": "vce_8c1e5a3f9b7d2046e1c3a5b7", "name": "Anna" },
    "createdAt": "2026-10-01T09:30:00.000Z"
  }
}
FieldRules
name1–60 characters on one line.
handle2–30 characters of a-z, 0-9 and _, starting with a letter. Unique among the account’s characters and influencers. Names such as image1, video2, audio3, element1 and voice1 are reserved.
photos1–4 URLs of your own pictures (uploads, or image outputs of your generations): PNG, JPEG or WebP, at least 300 px on each side and no more elongated than 2.5:1, all of the same person. Salven keeps its own copies.
voiceOptional. One of your saved voices (GET /voices): the character then speaks in it on the models that take voices. An id that is not yours is refused with voice_unknown.

There is no Idempotency-Key here: the handle is the key. A create retried after a lost answer is refused with 409 handle_taken if the first one went through, and GET /characters/{handle} then shows it. An account may have up to 50 characters (409 limit_reached). Editing and deleting are done on the characters page.

Using one: only a model whose spec says characters.supported: true takes characters, at most characters.maxCharacters in one run, and with characters.needsStartFrame: true a start frame must come too; characters.onlyWith names the only values of another param they go with (Kling 4.0 takes them on its regular version, without a start frame). On any other model a mention of your own handle is refused with character_not_supported, and nothing is charged. A handle the account does not have is not an error: it stays plain text and brings no photos, so take handles from GET /characters and check the finished generation’s characters. A character with a saved voice (voice when it was made, or one set on salven.ai) speaks in it on Kling 4.0’s regular version and on Seedance 2.5, with nothing to send; one run carries at most three voices, each character’s counting (voice_too_many).

Saved voices

A saved voice is a recording of one person speaking, 10 to 60 seconds long, saved on the account under a name. Speech models made for it read any text in that voice, and some video models make a person in the clip speak in it. Saving one is free, and a voice changes no price.

  • GET /voices answers { "voices": [ … ] }, newest first. Each has id, name, language, seconds, transcript (or null), recordingUrl (Salven’s MP3 copy of the recording) and createdAt.
  • POST /voices saves a voice. It is free and answers 201 with { "voice": { … } }.
POST /voices
{
  "name": "Anna",
  "language": "en",
  "recording": "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847120000-5e8a1c3d7f9b2460.mp3",
  "transcript": "Hi, it's Anna. I'm recording this so my voice can read things for me."
}
Answer
{
  "voice": {
    "id": "vce_8c1e5a3f9b7d2046e1c3a5b7",
    "name": "Anna",
    "language": "en",
    "seconds": 18.4,
    "transcript": "Hi, it's Anna. I'm recording this so my voice can read things for me.",
    "recordingUrl": "https://cdn.salven.ai/voices/vce_8c1e5a3f9b7d2046e1c3a5b7/sample.mp3",
    "createdAt": "2026-10-30T09:30:00.000Z"
  }
}
FieldRules
name1–40 characters on one line.
languageThe language spoken in the recording: ru, en, es, fr, de, it, pt, zh, ja or ko.
recordingThe URL of your own audio file (an upload, or an audio output of your generations): MP3, WAV, M4A or OGG, 10 to 60 seconds of one person speaking clearly, without music or other voices. Salven keeps its own copy.
transcriptOptional, up to 1000 characters: what the recording says, word for word. Some models read it.

There is no Idempotency-Key here: a save retried after a lost answer may save the voice twice, so look at GET /voices first. An account may have up to 20 voices (409 limit_reached). Renaming and deleting are done on salven.ai, in the voice menu of a speech model.

Speech in a saved voice: a model whose spec says voices.min: 1 (Qwen3-TTS, Fish Audio Drama 3, HeyGen Voice) speaks the prompt in exactly one of your voices, sent in voices; without one it answers voice_required. The prompt is the line to say, word for word, as on every speech model.

Speech in a saved voice
{
  "model": "my-voice-qwen3",
  "prompt": "Good morning! Here is what's new on the site this week.",
  "voices": ["vce_8c1e5a3f9b7d2046e1c3a5b7"],
  "dryRun": true
}

Your recording in a saved voice: the voice changer (voice-changer, mode voice-change, once GET /models lists it) takes your recording in inputs.sourceAudio, from 1 second to 5 minutes, and exactly one voice in voices, with no prompt. It returns the same words, pauses and intonation in that voice. It is billed per second of the recording; params.hd: true uses ChatterboxHD and 48 kHz audio at the same price.

A clip that speaks in it: a model whose spec says voices.promptTags: true (Seedance 2.5, and Kling 4.0’s regular version) takes up to voices.max voices. Send their ids in voices and name each in the prompt as @Voice1, @Voice2… in the order sent; a tag with no voice behind it is refused with reference_tag. A character’s own voice counts toward the same voices.max. Whether the clip really sounds like the recording is up to the model, so listen to it before you promise it. The finished generation’s voices lists the voices the request named.

A voice from a description

Without a recording, a voice can be made up from a description: POST /voices/design makes a trial recording of a text read in a voice that Qwen3-TTS Voice Design makes up from the description. It is a paid run like a generation: dryRun: true quotes it, an Idempotency-Key makes a retry safe, and the answer is 202 with { "generations": [ … ], "charged", "balance" }, as from POST /generations. Poll GET /generations/{id} until it is done, usually 20–30 seconds; its outputs hold the recording.

POST /voices/design
{
  "description": "A low, slightly husky male voice, calm and unhurried, like a late-night radio host",
  "text": "Good evening, and welcome back. The city is quiet now, the rain has stopped, and the last train has gone. Stay with me for an hour: there is music, there are a few stories, and there is no hurry at all.",
  "language": "en"
}
FieldRules
description1–500 characters in any language: what the voice sounds like — gender, age, timbre, manner, mood.
text150–500 characters in language: what the voice reads, about 10–30 seconds of speech. It becomes the saved voice’s transcript.
languageThe text’s language: ru, en, es, fr, de, it, pt, zh, ja or ko.
dryRunOptional. true answers the price and the balance and starts nothing.

Listen to the recording before you keep it. To keep it, POST /voices with its output URL as recording, the same language and the text as transcript; from then on it is a saved voice like any other. If it does not sound right, make another one with another description: each recording is paid, saving a voice is not.

Effects

Effects are the ready-made looks from the effects page: Salven fixed the model, the prompt and the settings, and you give the picture or pictures and, for some, a line of text. A run of an effect is an ordinary generation.

GET /effects answers { "effects": [ … ] } in the page’s order. GET /effects/{slug} answers one effect, or 404 effect_not_found:

json
{
  "slug": "wide-angle",
  "name": "Wide Angle",
  "description": "…",
  "kind": "image",
  "category": { "slug": "objects", "name": "Objects" },
  "preview": {
    "videoUrl": null,
    "posterUrl": "https://cdn.salven.ai/effects/cmg2a3b4c0002abcd3456qrst/preview.jpg",
    "width": 1024,
    "height": 1365
  },
  "inputs": [
    {
      "key": "object",
      "label": "Object",
      "hint": "…",
      "required": true,
      "exampleUrl": "https://cdn.salven.ai/effects/cmg2a3b4c0002abcd3456qrst/object.jpg"
    }
  ],
  "text": null,
  "pricing": { "unit": "image", "credits": 1.4 },
  "quality": {
    "default": "1K",
    "options": [
      { "value": "1K", "label": "1K", "pricing": { "unit": "image", "credits": 1.4 } },
      { "value": "2K", "label": "2K", "pricing": { "unit": "image", "credits": 1.9 } },
      { "value": "4K", "label": "4K", "pricing": { "unit": "image", "credits": 2.8 } }
    ]
  },
  "maxCount": 4
}
FieldMeaning
inputsThe pictures the effect takes, each sent by its key. required says which must be there; exampleUrl is a sample.
textnull, or the effect’s text field: label, placeholder, maxLength, required.
pricingAn image effect: credits per picture. A video effect: unit is run, and durations lists each length offered with its seconds and credits.
qualitynull, or the choice the effect offers: default, and options, each with its value, label and own pricing. The top-level pricing is the default’s.
maxCountHow many pictures one request may make.
POST /effects/{slug}/run
{
  "inputs": {
    "object": "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847000000-9f2c4e6a8b0d1f35.jpg"
  },
  "quality": "4K",
  "count": 2,
  "language": "en",
  "expectedCost": 2.8,
  "dryRun": false
}
FieldMeaning
inputsInput key → URL of your own picture (PNG, JPEG or WebP).
textOnly when the effect has text; required when text.required is true.
durationVideo effects: one of pricing.durations[].seconds. Default: the first.
qualityOnly when the effect has quality: one of quality.options[].value, exactly as written. Default: quality.default.
countImage effects: 1 to maxCount pictures, each charged. A video effect always makes one run.
languageen or ru: the language of the effect’s name the run is recorded under in the gallery. Default en.
expectedCostOptional. One run’s price in credits as quoted (the dry run’s cost, e.g. 1.4); if the price is different now, the answer is 409 price_changed.
dryRuntrue: answer the price, start nothing.

A dry run takes the same body, pictures included, and answers cost (one run), count, total, quality and balance. A run answers 202 like POST /generations, with the same Idempotency-Key rules; poll each generation with GET /generations/{id}. The usual order is to quote with dryRun, then send the same body with expectedCost set to the quoted cost.

Models

The API offers exactly the models the studio offers now, in the studio’s order. This table is read from the live catalogue. The price is the base price, before a mode’s multiplier and any priced option.

Image

Model idNameModesBase price
nano-banana-2-1Nano Banana 2.1generate1 credits / generation
nano-banana-2Nano Banana 2generate1.4 credits / generation
nano-banana-proNano Banana Progenerate2.4 credits / generation
seedream-v5-proSeedream v5 Progenerate1.6 credits / generation
gpt-image-2-5GPT Image 2.5generate0.9 credits / generation
gpt-image-2GPT Image 2generate4.8 credits / generation
flux-kontextFLUX.1 Kontextgenerate1 credits / generation
recraft-v4Recraft V4generate1 credits / generation
seedream-4Seedream 4generate0.7 credits / generation
qwen-image-3Qwen Image 3generate1 credits / generation
ideogram-v4Ideogram v4generate0.9 credits / generation
grok-imagine-2Grok Imagine 2generate1.4 credits / generation
virtual-try-onVirtual Try-Ontry-on1.8 credits / generation
image-upscalerImage Upscalerupscale1 credits / generation

Video

Model idNameModesBase price
wan-3Wan 3.0generate2.3 credits / second
kling-v4Kling 4.0generate3.6 credits / second
seedance-2-5Seedance 2.5generate8.8 credits / second
kling-v3-proKling v3 Progenerate2.6 credits / second
veo-3Google Veo 3generate22 credits / generation
kling-v3-pro-motion-controlKling v3 Pro · Motion Controlmotion-control3.9 credits / second
video-upscalerVideo Upscaler · RealESRGANupscale1.7 credits / second
veo-3-1-fastGoogle Veo 3.1 Fastgenerate17 credits / generation
hailuo-02MiniMax Hailuo 02generate6.2 credits / generation
seedance-v1-proSeedance v1 Progenerate15 credits / generation
minimax-h3-max-turboMiniMax H3-Max Turbogenerate1 credits / second
minimax-h3-maxMiniMax H3-Maxgenerate1.9 credits / second
lipsync-photoLipsync — from a photolipsync3.2 credits / second
lipsync-redubLipsync — redub a cliplipsync1.6 credits / second
video-to-audioSound for videosound0.8 credits / generation
seedance-2-0Seedance 2.0generate5 credits / second
seedance-2-0-4kSeedance 2.0 4Kgenerate17.7 credits / second

Audio

Model idNameModesBase price
elevenlabs-tts-v4ElevenLabs v4tts1.9 credits / 1000 characters
minimax-speech-hdMiniMax Speech 2.8 HDtts2.3 credits / 1000 characters
minimax-speech-turboMiniMax Speech 2.8 Turbotts1.4 credits / 1000 characters
my-voice-qwen3Qwen3-TTStts2.1 credits / generation
my-voice-fishFish Audio Drama 3tts0.7 credits / 1000 characters
my-voice-heygenHeyGen Voicetts0.7 credits / 1000 characters
voice-changerChatterboxvoice-change0.01 credits / second
gemini-3-8-flash-ttsGemini 3.8 Flash TTStts1.1 credits / 1000 characters
gemini-3-8-flash-lite-ttsGemini 3.8 Flash Lite TTStts0.7 credits / 1000 characters
elevenlabs-tts-v3ElevenLabs v3tts2.3 credits / 1000 characters
elevenlabs-tts-multilingual-v2ElevenLabs Multilingual v2tts2.3 credits / 1000 characters
elevenlabs-tts-turbo-v2-5ElevenLabs Turbo v2.5tts1.2 credits / 1000 characters
xai-ttsxAI TTStts0.4 credits / 1000 characters
inworld-tts-1-5-maxInworld TTS 1.5 Maxtts0.3 credits / 1000 characters
lyria-3-proLyria 3 Promusic1.9 credits / generation
stable-audio-3Stable Audio 3music0.9 credits / generation
minimax-musicMiniMax Music 2.6music3.5 credits / generation
lyria-3Lyria 3music1 credits / generation
elevenlabs-music-v2-5ElevenLabs Music v2.5music14 credits / generation
elevenlabs-sfxElevenLabs Sound Effectssfx0.3 credits / generation
sonilo-sfxSonilo Sound Effectssfx0.5 credits / generation

3D

Model idNameModesBase price
tripo-h3-1Tripo H3.1image6.9 credits / generation
tripo-p2Tripo P2image25 credits / generation
hunyuan-3d-proHunyuan 3D Proimage8.6 credits / generation
rodin-2-5Rodin 2.5image9.1 credits / generation
trellis-2Trellis 2image2.3 credits / generation
tripo-h3-1-textTripo H3.1text4.6 credits / generation
rodin-2-5-textRodin 2.5text9.1 credits / generation
meshy-7-viewsMeshy V7views28 credits / generation
meshy-riggingMeshy Rigginganimate4.6 credits / generation
meshy-rigging-multiMeshy Rigging Multi-Animationanimate4.6 credits / generation

The list changes: models are added and hidden. A program should read GET /models instead of keeping its own copy, and take the price of a request from its dry run.

Prompts and seeds

Write prompts in any language. By default (enhancePrompt: true) Salven translates the prompt to English and rewrites it for the model. An image or video prompt comes back as at most about 80 English words; a long, detailed English prompt is kept nearly as it is.

enhancePrompt: false sends the prompt exactly as written, and the negative prompt too. Use it for a carefully written English prompt, for long lyrics, or for a precise edit order such as “make the jacket red, change nothing else”.

  • Text in the picture. Put the words to be written in the picture in quotes, for example a cake with the inscription «Happy birthday».
  • Speech. A speech model (mode tts) reads the prompt verbatim, in its own language; it is never translated. The whole prompt is spoken and billed, so send only the words to say.
  • Music and sound. The prompt comes back as a short English description. Words to be sung or spoken stay as written, in quotes.
  • 3D models and pure editors. The prompt is only translated, never embellished. An image model that takes reference pictures is not a pure editor: its prompt is rewritten like any other, so send a precise edit order in English with enhancePrompt: false.
  • Negative prompts. Only on models whose spec has negativePrompt. By default it is translated to English and never embellished.
  • Length. Stay within the model’s prompt.maxLength, or the request is refused with prompt_too_long (with max and length).
  • Characters. An @handle of one of your characters survives translation and rewriting. Any other @word is ordinary text. See Characters.

Translation is best-effort: when it cannot run, the prompt goes as typed. For image, video and 3D models English is the safe choice.

Seeds

A seed is the number the model starts its randomness from. The same seed with the same prompt, inputs and params gives the same result again, or very nearly, so a result you liked can be repeated with one thing changed.

  • Only a model whose spec says "seed": true takes one, from 0 to seedMax. On any other model it is refused with seed_not_supported, and outside the range with invalid_seed.
  • A seed never changes the price.
  • count with a seed runs seed, seed + 1, seed + 2 and so on.
  • For an exact repeat, keep enhancePrompt the same as in the first run; false is the surest.
  • A finished generation’s seed says what it ran with.

Idempotency

Starting a generation charges, so it must not happen twice by accident. When an answer is lost (a timeout, a dropped connection, a 5xx), you cannot tell whether the run started. The Idempotency-Key header makes the retry safe: the same request with the same key returns the first answer instead of starting again.

  • Send it with POST /generations and POST /effects/{slug}/run.
  • The key is 1–128 characters of A-Z, a-z, 0-9, ., _, : and -; anything else is refused with 400 invalid_idempotency_key. A UUID works.
  • Use a new key for every new generation, and the same key for every retry of it.
  • A replayed answer carries the header Idempotent-Replayed: true.
  • Only an answer after which generations started (a 202) is remembered. A refusal such as a 400, 402 insufficient_credits or 429 too_many_running is not: once its cause is gone, the same request with the same key runs.
  • A 202 is final for its key, a partial one included: repeating it replays that answer and starts nothing.
  • Keys are remembered for 24 hours.
  • A dry run is never remembered or replayed: it only quotes.
HTTPCodeMeaning
422idempotency_key_reusedThe key was already used with a different body. Use a new key for a different request.
409idempotency_in_progressThe first request with this key is still being handled. Wait a few seconds and repeat it with the same key.

If the 409 keeps coming for more than a few minutes, the first attempt may have died after starting. Look at GET /generations for runs made since, and send the request under a new key only if nothing started.

Errors

Every error the API itself answers has one shape. Branch on code: it is stable. message is English and written for people. The other fields are details of that code. An answer that did not come from the API’s own code has no such body: the web server’s 413 for a request body over 25 MB, a bare 502 or 504 while the server restarts, or a 5xx with no code. Check the Content-Type before parsing an error body, and handle a 5xx without a code as the last row of the table says.

json
{
  "error": {
    "code": "insufficient_credits",
    "message": "Not enough credits for this generation",
    "required": 1.9,
    "available": 0.6
  }
}

A refused request charges nothing. A generation that fails later, with status failed, returns its credits.

HTTPCodeWhat to do
400invalid_json, invalid_request, invalid_prompt, invalid_params, invalid_inputsFix the body; the message names the field.
400invalid_idempotency_keyFix the header. See Idempotency.
400invalid_kind, invalid_cursorFix the query of GET /models or GET /generations.
400unknown_param, invalid_param_value, unknown_input, input_kind, input_missing, input_requires, input_takes_one_file, invalid_input_url, too_many_files, aspect_not_supported, invalid_aspect, prompt_required, negative_prompt_not_supported, count_too_largeRead the model’s spec again. The details say what is allowed: allowed, expected, max, input, param.
400input_not_allowedThe URL is not your own file. Upload it with POST /uploads first.
400prompt_too_long, negative_prompt_too_longShorten the text to max characters.
400seed_not_supported, invalid_seedLeave seed out for this model, or keep it within 0..seedMax.
400media_missing, media_unreadable, media_foreign, media_length, media_resolution, reference_*An input file cannot be read, or breaks the model’s limits: its length, its size (media_resolution: max is the largest short side in pixels), its format.
400model_too_dense, model_untextured, model_unreadable, model_foreign, model_missingThe 3D model to animate cannot be rigged: too many faces, no texture, not a readable GLB, or not your file.
400character_not_supported, character_too_many, character_needs_start_frame, character_with_frame, character_with_extend, character_no_room, character_no_photosThe prompt names your characters and the request cannot run with them. Choose a model whose spec takes characters, name fewer handles, or follow the message. See Characters.
400voice_required, voice_too_many, voice_unknown, voice_not_supported, invalid_voicesSaved voices: send one id on a model whose voices.min is 1, name fewer voices or characters (each character’s voice counts), take ids from GET /voices (a character’s voice may have been deleted meanwhile), or choose a model, or a version of one (requires), that takes voices. Nothing is charged. See Saved voices.
400name_invalid, handle_invalid, handle_reserved, photos_required, too_many_photos, photo_foreign, photo_unreadable, photo_too_small, photo_aspect, voice_unknownPOST /characters: fix the field. A photo refusal names the photo (index, photo); voice_unknown: voice is not one of your saved voices.
400language_invalid, transcript_too_long, sample_foreign, sample_unreadable, sample_too_short, sample_too_longPOST /voices: fix the field. The recording must be your own audio file on Salven, 10 to 60 seconds long; name_invalid here is a name that is empty or over 40 characters.
400design_description, design_text, language_invalidPOST /voices/design: fix the field: description 1–500 characters, text 150–500, language one of the voice languages. Nothing is charged.
400text_required, text_too_long, duration_value, quality_valueEffects: read the effect’s spec again.
400, 413, 415missing_file, invalid_form_data, file_too_large, unsupported_format, heic_not_supportedUploads: see the upload rules.
401unauthorizedThe key is missing, wrong or revoked.
402insufficient_creditsCompare required with available. Top up on salven.ai; do not retry.
403api_not_availableThe API is not open to this account.
404model_not_found, mode_not_available, not_found, effect_not_found, character_not_foundCheck the id against the matching list: GET /models, GET /generations, GET /effects, GET /characters. The details of mode_not_available list the model’s modes.
404design_unavailablePOST /voices/design is switched off for now.
409handle_taken, limit_reachedPOST /characters: the handle is in use (or this was a retry and the first create went through), or the account has as many characters as it may (50). POST /voices: the account has as many saved voices as it may (20).
409price_changedEffects: the price changed since the quote. cost is one run’s price now. Quote again.
409idempotency_in_progressWait a few seconds and repeat with the same key. See Idempotency.
422idempotency_key_reusedUse a new Idempotency-Key for a different request.
429rate_limitedWait retryAfter seconds; the Retry-After header says the same.
429too_many_runningrunning of your API runs are queued or being made, and this request would pass the ceiling. Wait for some to finish, then repeat it; the same Idempotency-Key is fine.
5xxinternal_error, storage_error, uploads_disabled, or no code at allRetry once after a short pause, with the same Idempotency-Key. Before retrying an expensive request, look at GET /generations for a run made in the last minute. A 502 or 504 whose body is not JSON comes from the web server during a restart and may have cut the request off part-way: the retry with the same key then answers the first result, or a 409.

The complete list, with every detail field, is in SKILL.md.

Limits

WhatLimit
Requests120 per minute per key, reads included
Generation requests: POST /generations, POST /effects/{slug}/run and POST /voices/design, dry runs included30 per minute per account
Recordings of a voice from a description: POST /voices/design and the MCP tool design_voice, the site’s voice window included10 per minute per account
Uploads30 per minute per account, POST /uploads and the MCP tool upload_from_url together
Character creations20 per minute per account
Voice saves20 per minute per account
API runs queued or being made at once10 per account, generations, effects and voice recordings together
JSON body64 KB
waitUp to 60 seconds
limit of GET /generations20 by default, up to 50
countUp to the model’s maxCount
PromptThe model’s prompt.maxLength, never more than 10 000 characters; a negative prompt never more than 4000
Size of a file you send: POST /uploads, an upload linkImages 10 MB, audio 20 MB, video and GLB models 25 MB
Size of a file copied from a web address: the MCP tool upload_from_urlImages 10 MB, audio 20 MB, video and GLB models 60 MB
Idempotency keysRemembered for 24 hours
API keys10 live keys per account
Characters50 per account, 1–4 photos each
Saved voices20 per account, 10–60 seconds each

Over REST, passing a rate limit answers 429 rate_limited with retryAfter in seconds and a Retry-After header. A request whose count would take the open runs past 10 answers 429 too_many_running with running, the number open now.

For agents: SKILL.md

SKILL.md is the instruction file for coding agents such as Claude Code and Cursor. It is the complete reference of this API, written for an agent to read: every endpoint and refusal code, the rules for reference media, speech and 3D rigging, and how to behave with someone else’s credits (quote first, never loop on failures, never print the key).

  1. Make a key in Settings and set it as SALVEN_API_KEY in the environment the agent runs in.
  2. Give the agent the file. In Claude Code, save it as .claude/skills/salven-api/SKILL.md in a project, or as ~/.claude/skills/salven-api/SKILL.md for every project. For another agent, add the file to its instructions or point it at the address above.
  3. Ask for what you want. The agent reads the catalogue, quotes the price and runs the request.

An agent that speaks MCP does not need the file or a key: add the server from Connect Claude instead.

A Python helper

SKILL.md ends with a small Python client: it quotes, asks for a yes, runs with an Idempotency-Key, waits, and retries through rate limits, busy slots and restarts. It needs the requests package.

python
import os, time, uuid, requests

API = "https://salven.ai/api/v1"
H = {"Authorization": f"Bearer {os.environ['SALVEN_API_KEY']}"}

def upload(path):
    with open(path, "rb") as f:
        r = requests.post(f"{API}/uploads", headers=H, files={"file": f})
    r.raise_for_status()
    return r.json()["url"]

class StillInProgress(RuntimeError):
    """The request may have started: check GET /generations before sending it under a new key."""

def call(method, url, **kw):
    """One request, retried through rate limits, busy slots, restarts and one server error.
    Every retry sends the same arguments, so a POST keeps its Idempotency-Key."""
    busy_since, server_errors = None, 0
    for attempt in range(80):
        try:
            r = requests.request(method, url, timeout=(10, 150), **kw)
        except (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError):
            time.sleep(10)                     # a restart, a dropped link: same request again
            continue
        if r.status_code == 409:               # idempotency_in_progress
            busy_since = busy_since or time.time()
            if time.time() - busy_since > 300:
                raise StillInProgress(url)
            time.sleep(10)
            continue
        if r.status_code == 429:
            code = r.json().get("error", {}).get("code") if "json" in r.headers.get("content-type", "") else None
            time.sleep(60 if code == "too_many_running" else int(r.headers.get("Retry-After", 10)))
            continue
        if r.status_code >= 500 and server_errors == 0:
            server_errors += 1
            time.sleep(10)
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"{method} {url}: gave up")

MAX_WAIT_MIN = {"image": 5, "audio": 10, "video": 20, "3d": 60}   # past this a run counts as stuck

def generate(body, confirm):
    """Quote, ask, run, wait. Returns the output URLs and what did not finish."""
    quote = call("POST", f"{API}/generations", headers=H, json={**body, "dryRun": True})
    if quote["total"] > quote["balance"]:
        raise RuntimeError(f"needs {quote['total']} credits, the balance is {quote['balance']}")
    if not confirm(quote["total"]):            # total = cost × count
        return None
    kind = call("GET", f"{API}/models/{body['model']}", headers=H)["kind"]
    key = str(uuid.uuid4())                    # one key for this request and all its retries
    started = call("POST", f"{API}/generations", headers={**H, "Idempotency-Key": key}, json=body)
    result = {"urls": [], "failed": [], "stuck": [], "stopped": (started.get("stopped") or {}).get("code")}
    deadline = time.time() + MAX_WAIT_MIN[kind] * 60
    for gen in started["generations"]:
        while gen["status"] in ("queued", "processing") and time.time() < deadline:
            gen = call("GET", f"{API}/generations/{gen['id']}", headers=H, params={"wait": 60})
        if gen["status"] == "done":
            result["urls"] += [o["url"] for o in gen["outputs"]]
        elif gen["status"] == "failed":
            result["failed"].append(gen["id"])     # its credits were returned
        else:
            result["stuck"].append(gen["id"])      # tell the user; do not start it again
    return result                                  # "stopped": why fewer than `count` started, if so

start = upload("portrait.jpg")
print(generate({"model": "kling-v3-pro", "prompt": "she laughs and looks at the camera",
                "params": {"duration": 5}, "inputs": {"startImage": start}},
               confirm=lambda credits: input(f"Spend {credits} credits? [y/N] ").strip().lower() == "y"))

Good to know

The API does not do the following. They are done on salven.ai:

  • publishing a work to the community;
  • deleting works;
  • editing or deleting characters (the characters page);
  • renaming or deleting saved voices;
  • making AI influencers (an existing one is used by its @handle, like a character);
  • topping up the balance;
  • Kling’s multi-shot clips.

Output URLs are unguessable but public. Anyone who has the link can open the file, so share it only with the people who should see it.

Characters and voices of real people. Make a character only from photos of yourself or of people who agreed to it, and a voice only from such recordings, and never use either to pass a real person off as saying or doing something they did not.

Questions and problems: Support, or write to support@salven.ai.