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, and puts your saved characters into a picture or a clip by their @handle.
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:
| Way | For whom | What you need |
|---|---|---|
| Claude | Anyone who uses Claude: you ask in a chat, Claude runs the models. | The address https://salven.ai/mcp and your Salven sign-in. |
| REST | Your own scripts, services and automations. | An API key and any HTTP client. |
| An agent | Claude 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.
Claude on the web, desktop and mobile
- 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.
- Paste the address
https://salven.ai/mcp. - 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
# 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/mcpThe 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:
# 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.
| Tool | What it does |
|---|---|
account | The account the connection acts as, and its credit balance. |
list_models | The models available now, optionally of one kind: id, modes, inputs and base price. |
get_model | One model’s full spec: modes, params, inputs, aspect options, prompt limits and pricing. |
generate | Starts 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_generation | One generation’s status and, when it is done, its files. Can wait up to 40 seconds for it to finish. |
list_generations | The account’s works, newest first, made on the site or through the API. |
list_effects | The ready-made effects: the pictures each takes, its price and its quality options. |
run_effect | Applies an effect to your picture or pictures, or only quotes it with dryRun: true. |
list_characters | Your characters and AI influencers, each with its @handle. |
create_character | Saves a person as a character: a name, an @handle and one to four photos. Free. |
upload_from_url | Copies 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_link | Makes a one-time link for adding files from your device. |
get_upload_link | The 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_linkand 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.
- Open Settings, find Claude & API, type a name for the key and press Create key.
- Copy the key now: it is shown once.
- Put it in the environment variable
SALVEN_API_KEY, for example withexport SALVEN_API_KEY=<your key>in the shell you will use. - Run the calls below one by one.
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.
| Call | What it answers |
|---|---|
GET /me | The account and its balance. |
GET /models?kind=image | The image models, each with its params, inputs and pricing. |
POST /generations with "dryRun": true | The price: cost for one generation, total for the whole request. Nothing starts. |
POST /generations | 202 and generations[].id. The credits are taken now. |
GET /generations/{id}?wait=60 | The 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:
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.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized | The key is missing, is not a Salven key, is unknown or was revoked. |
| 403 | api_not_available | The 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 /uploadstakesmultipart/form-data. - Times are ISO 8601, in UTC.
- Every answer is sent with
Cache-Control: no-store. - Money is counted in credits.
- 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: a413for a request body over 25 MB, and a bare502or504while the site restarts. See Errors.
How a generation goes
- Balance.
GET /meanswersbalance: the most a run may cost. - 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. - Uploads. Only when the model takes files:
POST /uploadsanswers aurlto use as an input. - Quote.
POST /generationswith"dryRun": trueanswerscost(one generation) andtotal(cost×count). - Start. The same body without
dryRun, with anIdempotency-Keyheader. The answer is202withgenerations[].id, and the credits are taken. - Wait.
GET /generations/{id}?wait=60, repeated whilestatusisqueuedorprocessing. - Deliver.
outputs[].urlare direct links to the files.
Statuses
| Status | Meaning |
|---|---|
queued | Accepted and paid for, waiting its turn. |
processing | Being made. |
done | Finished. outputs holds the files. |
failed | Could not be made. The credits were returned (refunded). |
A failed generation has error.code set to generation_failed; the model’s own reason is not shared. Its credits are already back, so it is fine to try again, perhaps with other settings. After two failures in a row, stop and look at the request.
How long it takes
| What | Typical time |
|---|---|
| Image | 10–60 seconds |
| Video | 1–5 minutes, longer for clips of 10–15 seconds |
| Speech | A few seconds to half a minute |
| Music | Up to a few minutes |
| 3D model | 2–8 minutes; some models can take over half an hour |
| 3D rigging | About 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
| Endpoint | What it does |
|---|---|
GET /me | The account behind the key and its balance. |
GET /models | Every model the API can run, optionally of one kind. |
GET /models/{id} | One model’s spec. |
POST /uploads | Stores a file as your own and answers its URL. |
POST /generations | Starts one or more generations, or quotes them. |
GET /generations/{id} | One generation: its status and, when done, its files. |
GET /generations | The account’s generations, newest first. |
GET /characters | The account’s characters and influencers. |
GET /characters/{handle} | One of them, with its photos. |
POST /characters | Makes a character. |
GET /effects | The ready-made effects. |
GET /effects/{slug} | One effect’s spec. |
POST /effects/{slug}/run | Runs 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
{
"id": "cmg1x2y3z0000abcd1234efgh",
"username": "anna",
"name": "Anna",
"credits": 1200,
"dailyAllowance": 0,
"balance": 1200,
"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:
{
"id": "kling-v3-pro",
"name": "Kling v3 Pro",
"kind": "video",
"description": "Top-tier video quality, start from a frame",
"pricing": { "credits": 20, "unit": "second", "secondsFrom": "params.duration" },
"modes": [
{ "slug": "generate", "name": "Generate", "promptRequired": true, "priceMultiplier": 1 }
],
"prompt": { "maxLength": 10000 },
"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 }
}| Field | How to read it |
|---|---|
pricing | credits 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. |
modes | The 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, negativePrompt | The longest text in characters. negativePrompt: null means the model takes none. |
params | Settings, sent by name. number: within min..max on step. 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). boolean: true or false, with priceFactorWhenTrue when the switch costs extra. A param left out takes its default; an unknown name is refused. |
aspect | Not 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. |
inputs | Files, 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. |
maxCount | How many separate generations one request may start with count. |
startImageSetsShape | true: a start frame decides the clip’s shape, and aspect_ratio is ignored once one is sent. |
seed, seedMax | Whether the model takes a seed, and its largest value. See Seeds. |
references | The limits for reference media on the models that take it, else null. |
characters | Whether an @handle in the prompt brings that person’s photos (supported), for how many people in one run (maxCharacters), and whether a start frame must come with them (needsStartFrame). See Characters. |
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:
{
"url": "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847000000-9f2c4e6a8b0d1f35.jpg",
"kind": "image",
"mimeType": "image/jpeg",
"bytes": 184233
}| Kind | Formats | Largest file |
|---|---|---|
image | JPEG, PNG, WebP | 10 MB |
video | MP4, MOV, WebM | 25 MB |
audio | MP3, WAV, M4A, OGG | 20 MB |
model | GLB (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
.gltfis 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
413whose 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[].urlof 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
{
"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
}| Field | Meaning |
|---|---|
model | Required. A model id from GET /models. |
mode | Optional. One of the model’s modes[].slug. |
prompt | Required when the mode’s promptRequired is true. Any language; @handle names one of your characters. See Prompts. |
negativePrompt | Only where the model’s negativePrompt is not null, up to its maxLength. |
aspect | Only where the model’s aspect is not null: one of its options. |
params | Param name → value, as the model’s params list them. |
inputs | Input name → file URL, or an array of URLs for a multiple input. |
seed | Only where the model’s seed is true: a whole number from 0 to seedMax. |
count | 1 to maxCount separate generations, each charged. Default 1. |
enhancePrompt | Default true. false sends the prompt exactly as written. |
dryRun | true: 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:
{
"dryRun": true,
"model": "nano-banana-2",
"mode": "generate",
"cost": 45,
"count": 1,
"total": 45,
"balance": 1200
}A real run answers 202:
{
"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": 45,
"refunded": 0,
"createdAt": "2026-10-01T09:30:00.000Z",
"startedAt": null,
"finishedAt": null,
"outputs": [],
"error": null
}
],
"charged": 45,
"balance": 1155
}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}
{
"id": "cmg7q2w8e0003abcd9012mnop",
"status": "done",
"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": 45,
"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.
| Field | Meaning |
|---|---|
status | queued, processing, done or failed. |
kind | image, video, audio or 3d. |
model, mode, prompt | What ran. For a run of an effect these are null, and effect holds its slug, name and quality. |
characters | Who the prompt’s handles brought in, each with handle, name and kind. |
seed | The seed it ran with, when one was sent. |
source | Where it was started: web, bot or api. |
cost, refunded | The credits taken, and the credits returned if it failed. |
createdAt, startedAt, finishedAt | Times. The last two are null until they happen. |
outputs | The files, once done: url, mimeType, width, height, durationSeconds, thumbnailUrl. Empty until then. |
error | null, or on a failed run an object with code (generation_failed) 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 /charactersanswers{ "characters": [ … ] }: characters, newest first, then influencers. Each hashandle,name,kind(characterorinfluencer),photos(how many a mention sends) andcreatedAt.GET /characters/{handle}answers{ "character": { … } }(the shape shown underPOST /charactersbelow), or 404character_not_found. Herephotosis a list, the main photo first, each withurl,widthandheight; an influencer’swidthandheightarenull.POST /charactersmakes a character. It is free and answers201with the same{ "character": { … } }.
{
"name": "Anna",
"handle": "anna",
"photos": [
"https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847000000-9f2c4e6a8b0d1f35.jpg",
"https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847060000-3b7d9e1c5a2f4860.jpg"
]
}{
"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
}
],
"createdAt": "2026-10-01T09:30:00.000Z"
}
}| Field | Rules |
|---|---|
name | 1–60 characters on one line. |
handle | 2–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 and element1 are reserved. |
photos | 1–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. |
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. 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.
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:
{
"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": 30 },
"quality": {
"default": "1K",
"options": [
{ "value": "1K", "label": "1K", "pricing": { "unit": "image", "credits": 30 } },
{ "value": "2K", "label": "2K", "pricing": { "unit": "image", "credits": 45 } },
{ "value": "4K", "label": "4K", "pricing": { "unit": "image", "credits": 60 } }
]
},
"maxCount": 4
}| Field | Meaning |
|---|---|
inputs | The pictures the effect takes, each sent by its key. required says which must be there; exampleUrl is a sample. |
text | null, or the effect’s text field: label, placeholder, maxLength, required. |
pricing | An image effect: credits per picture. A video effect: unit is run, and durations lists each length offered with its seconds and credits. |
quality | null, 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. |
maxCount | How many pictures one request may make. |
{
"inputs": {
"object": "https://cdn.salven.ai/uploads/cmg1x2y3z0000abcd1234efgh/1790847000000-9f2c4e6a8b0d1f35.jpg"
},
"quality": "4K",
"count": 2,
"language": "en",
"expectedCost": 60,
"dryRun": false
}| Field | Meaning |
|---|---|
inputs | Input key → URL of your own picture (PNG, JPEG or WebP). |
text | Only when the effect has text; required when text.required is true. |
duration | Video effects: one of pricing.durations[].seconds. Default: the first. |
quality | Only when the effect has quality: one of quality.options[].value, exactly as written. Default: quality.default. |
count | Image effects: 1 to maxCount pictures, each charged. A video effect always makes one run. |
language | en or ru: the language of the effect’s name the run is recorded under in the gallery. Default en. |
expectedCost | Optional. One run’s price as quoted; if the price is different now, the answer is 409 price_changed. |
dryRun | true: 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 id | Name | Modes | Base price |
|---|---|---|---|
nano-banana-2 | Nano Banana 2 | generate | 30 credits / generation |
nano-banana-pro | Nano Banana Pro | generate | 56 credits / generation |
seedream-v5-pro | Seedream v5 Pro | generate | 40 credits / generation |
gpt-image-2-5 | GPT Image 2.5 | generate | 20 credits / generation |
gpt-image-2 | GPT Image 2 | generate | 60 credits / generation |
flux-kontext | FLUX.1 Kontext | generate | 12 credits / generation |
recraft-v4 | Recraft V4 | generate | 20 credits / generation |
seedream-4 | Seedream 4 | generate | 25 credits / generation |
qwen-image-3 | Qwen Image 3 | generate | 12 credits / generation |
ideogram-v4 | Ideogram v4 | generate | 6 credits / generation |
grok-imagine-2 | Grok Imagine 2 | generate | 18 credits / generation |
virtual-try-on | Virtual Try-On | generate, try-on | 25 credits / generation |
image-upscaler | Image Upscaler | generate, upscale | 12 credits / generation |
Video
| Model id | Name | Modes | Base price |
|---|---|---|---|
kling-v3-pro | Kling v3 Pro | generate | 20 credits / second |
veo-3 | Google Veo 3 | generate | 600 credits / generation |
kling-v3-pro-motion-control | Kling v3 Pro · Motion Control | motion-control | 20 credits / second |
video-upscaler | Video Upscaler · RealESRGAN | upscale | 20 credits / second |
veo-3-1-fast | Google Veo 3.1 Fast | generate | 220 credits / generation |
hailuo-02 | MiniMax Hailuo 02 | generate | 120 credits / generation |
seedance-v1-pro | Seedance v1 Pro | generate | 150 credits / generation |
minimax-h3-max-turbo | MiniMax H3-Max Turbo | generate | 4 credits / second |
minimax-h3-max | MiniMax H3-Max | generate | 12 credits / second |
wan-3 | Wan 3.0 | generate | 18 credits / second |
lipsync-photo | Lipsync — from a photo | lipsync | 24 credits / second |
lipsync-redub | Lipsync — redub a clip | lipsync | 13 credits / second |
video-to-audio | Sound for video | sound | 10 credits / generation |
seedance-2-5 | Seedance 2.5 | generate | 85 credits / second |
Audio
| Model id | Name | Modes | Base price |
|---|---|---|---|
minimax-speech-hd | MiniMax Speech 2.8 HD | tts | 18 credits / 1000 characters |
minimax-speech-turbo | MiniMax Speech 2.8 Turbo | tts | 11 credits / 1000 characters |
gemini-3-8-flash-tts | Gemini 3.8 Flash TTS | tts | 9 credits / 1000 characters |
gemini-3-8-flash-lite-tts | Gemini 3.8 Flash Lite TTS | tts | 6 credits / 1000 characters |
elevenlabs-tts-v3 | ElevenLabs v3 | tts | 18 credits / 1000 characters |
elevenlabs-tts-multilingual-v2 | ElevenLabs Multilingual v2 | tts | 18 credits / 1000 characters |
elevenlabs-tts-turbo-v2-5 | ElevenLabs Turbo v2.5 | tts | 9 credits / 1000 characters |
xai-tts | xAI TTS | tts | 3 credits / 1000 characters |
inworld-tts-1-5-max | Inworld TTS 1.5 Max | tts | 2 credits / 1000 characters |
lyria-3-pro | Lyria 3 Pro | music | 15 credits / generation |
stable-audio-3 | Stable Audio 3 | music | 7 credits / generation |
minimax-music | MiniMax Music 2.6 | music | 27 credits / generation |
lyria-3 | Lyria 3 | music | 8 credits / generation |
elevenlabs-music-v2-5 | ElevenLabs Music v2.5 | music | 108 credits / generation |
elevenlabs-sfx | ElevenLabs Sound Effects | sfx | 4 credits / generation |
sonilo-sfx | Sonilo Sound Effects | sfx | 4 credits / generation |
3D
| Model id | Name | Modes | Base price |
|---|---|---|---|
tripo-h3-1 | Tripo H3.1 | image | 54 credits / generation |
tripo-p2 | Tripo P2 | image | 198 credits / generation |
hunyuan-3d-pro | Hunyuan 3D Pro | image | 68 credits / generation |
rodin-2-5 | Rodin 2.5 | image | 72 credits / generation |
trellis-2 | Trellis 2 | image | 54 credits / generation |
tripo-h3-1-text | Tripo H3.1 | text | 36 credits / generation |
rodin-2-5-text | Rodin 2.5 | text | 72 credits / generation |
meshy-7-views | Meshy V7 | views | 216 credits / generation |
meshy-rigging | Meshy Rigging | animate | 36 credits / generation |
meshy-rigging-multi | Meshy Rigging Multi-Animation | animate | 36 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 withprompt_too_long(withmaxandlength). - Characters. An
@handleof one of your characters survives translation and rewriting. Any other@wordis 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": truetakes one, from 0 toseedMax. On any other model it is refused withseed_not_supported, and outside the range withinvalid_seed. - A seed never changes the price.
countwith a seed runs seed, seed + 1, seed + 2 and so on.- For an exact repeat, keep
enhancePromptthe same as in the first run;falseis the surest. - A finished generation’s
seedsays 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 /generationsandPOST /effects/{slug}/run. - The key is 1–128 characters of
A-Z,a-z,0-9,.,_,:and-; anything else is refused with 400invalid_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 a400,402 insufficient_creditsor429 too_many_runningis not: once its cause is gone, the same request with the same key runs. - A
202is 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.
| HTTP | Code | Meaning |
|---|---|---|
| 422 | idempotency_key_reused | The key was already used with a different body. Use a new key for a different request. |
| 409 | idempotency_in_progress | The 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.
{
"error": {
"code": "insufficient_credits",
"message": "Not enough credits for this generation",
"required": 45,
"available": 12
}
}A refused request charges nothing. A generation that fails later, with status failed, returns its credits.
| HTTP | Code | What to do |
|---|---|---|
| 400 | invalid_json, invalid_request, invalid_prompt, invalid_params, invalid_inputs | Fix the body; the message names the field. |
| 400 | invalid_idempotency_key | Fix the header. See Idempotency. |
| 400 | invalid_kind, invalid_cursor | Fix the query of GET /models or GET /generations. |
| 400 | unknown_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_large | Read the model’s spec again. The details say what is allowed: allowed, expected, max, input, param. |
| 400 | input_not_allowed | The URL is not your own file. Upload it with POST /uploads first. |
| 400 | prompt_too_long, negative_prompt_too_long | Shorten the text to max characters. |
| 400 | seed_not_supported, invalid_seed | Leave seed out for this model, or keep it within 0..seedMax. |
| 400 | media_missing, media_unreadable, media_foreign, reference_* | An input file cannot be read, or breaks the model’s limits. |
| 400 | model_too_dense, model_untextured, model_unreadable, model_foreign, model_missing | The 3D model to animate cannot be rigged: too many faces, no texture, not a readable GLB, or not your file. |
| 400 | character_not_supported, character_too_many, character_needs_start_frame, character_with_frame, character_with_extend, character_no_room, character_no_photos | The 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. |
| 400 | name_invalid, handle_invalid, handle_reserved, photos_required, too_many_photos, photo_foreign, photo_unreadable, photo_too_small, photo_aspect | POST /characters: fix the field. A photo refusal names the photo (index, photo). |
| 400 | text_required, text_too_long, duration_value, quality_value | Effects: read the effect’s spec again. |
| 400, 413, 415 | missing_file, invalid_form_data, file_too_large, unsupported_format, heic_not_supported | Uploads: see the upload rules. |
| 401 | unauthorized | The key is missing, wrong or revoked. |
| 402 | insufficient_credits | Compare required with available. Top up on salven.ai; do not retry. |
| 403 | api_not_available | The API is not open to this account. |
| 404 | model_not_found, mode_not_available, not_found, effect_not_found, character_not_found | Check 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. |
| 409 | handle_taken, limit_reached | POST /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. |
| 409 | price_changed | Effects: the price changed since the quote. cost is one run’s price now. Quote again. |
| 409 | idempotency_in_progress | Wait a few seconds and repeat with the same key. See Idempotency. |
| 422 | idempotency_key_reused | Use a new Idempotency-Key for a different request. |
| 429 | rate_limited | Wait retryAfter seconds; the Retry-After header says the same. |
| 429 | too_many_running | running 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. |
| 5xx | internal_error, storage_error, uploads_disabled, or no code at all | Retry 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
| What | Limit |
|---|---|
| Requests | 120 per minute per key, reads included |
Generation requests: POST /generations and POST /effects/{slug}/run, dry runs included | 30 per minute per account |
| Uploads | 30 per minute per account, POST /uploads and the MCP tool upload_from_url together |
| Character creations | 20 per minute per account |
| API runs queued or being made at once | 10 per account, generations and effects together |
| JSON body | 64 KB |
wait | Up to 60 seconds |
limit of GET /generations | 20 by default, up to 50 |
count | Up to the model’s maxCount |
| Prompt | The 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 link | Images 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_url | Images 10 MB, audio 20 MB, video and GLB models 60 MB |
| Idempotency keys | Remembered for 24 hours |
| API keys | 10 live keys per account |
| Characters | 50 per account, 1–4 photos 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).
- Download it: salven.ai/api/skill
- Read it in the browser: salven.ai/api/skill?view=1
- Make a key in Settings and set it as
SALVEN_API_KEYin the environment the agent runs in. - Give the agent the file. In Claude Code, save it as
.claude/skills/salven-api/SKILL.mdin a project, or as~/.claude/skills/salven-api/SKILL.mdfor every project. For another agent, add the file to its instructions or point it at the address above. - 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.
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);
- 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 of real people. Make a character only from photos of yourself or of people who agreed to it, and never use one to pass a real person off as saying or doing something they did not.
Questions and problems: Support, or write to support@salven.ai.