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:

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.

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 and one to four photos. Free.
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.
  • 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; 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

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 /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": 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:

json
{
  "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 }
}
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.
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. 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.
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.
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), 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:

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.
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": 45,
  "count": 1,
  "total": 45,
  "balance": 1200
}

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": 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}

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": [],
  "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.

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.
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) 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) 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"
  ]
}
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
      }
    ],
    "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 and element1 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.

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:

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": 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
}
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": 60,
  "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 as quoted; 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-2Nano Banana 2generate30 credits / generation
nano-banana-proNano Banana Progenerate56 credits / generation
seedream-v5-proSeedream v5 Progenerate40 credits / generation
gpt-image-2-5GPT Image 2.5generate20 credits / generation
gpt-image-2GPT Image 2generate60 credits / generation
flux-kontextFLUX.1 Kontextgenerate12 credits / generation
recraft-v4Recraft V4generate20 credits / generation
seedream-4Seedream 4generate25 credits / generation
qwen-image-3Qwen Image 3generate12 credits / generation
ideogram-v4Ideogram v4generate6 credits / generation
grok-imagine-2Grok Imagine 2generate18 credits / generation
virtual-try-onVirtual Try-Ongenerate, try-on25 credits / generation
image-upscalerImage Upscalergenerate, upscale12 credits / generation

Video

Model idNameModesBase price
kling-v3-proKling v3 Progenerate20 credits / second
veo-3Google Veo 3generate600 credits / generation
kling-v3-pro-motion-controlKling v3 Pro · Motion Controlmotion-control20 credits / second
video-upscalerVideo Upscaler · RealESRGANupscale20 credits / second
veo-3-1-fastGoogle Veo 3.1 Fastgenerate220 credits / generation
hailuo-02MiniMax Hailuo 02generate120 credits / generation
seedance-v1-proSeedance v1 Progenerate150 credits / generation
minimax-h3-max-turboMiniMax H3-Max Turbogenerate4 credits / second
minimax-h3-maxMiniMax H3-Maxgenerate12 credits / second
wan-3Wan 3.0generate18 credits / second
lipsync-photoLipsync — from a photolipsync24 credits / second
lipsync-redubLipsync — redub a cliplipsync13 credits / second
video-to-audioSound for videosound10 credits / generation
seedance-2-5Seedance 2.5generate85 credits / second

Audio

Model idNameModesBase price
minimax-speech-hdMiniMax Speech 2.8 HDtts18 credits / 1000 characters
minimax-speech-turboMiniMax Speech 2.8 Turbotts11 credits / 1000 characters
gemini-3-8-flash-ttsGemini 3.8 Flash TTStts9 credits / 1000 characters
gemini-3-8-flash-lite-ttsGemini 3.8 Flash Lite TTStts6 credits / 1000 characters
elevenlabs-tts-v3ElevenLabs v3tts18 credits / 1000 characters
elevenlabs-tts-multilingual-v2ElevenLabs Multilingual v2tts18 credits / 1000 characters
elevenlabs-tts-turbo-v2-5ElevenLabs Turbo v2.5tts9 credits / 1000 characters
xai-ttsxAI TTStts3 credits / 1000 characters
inworld-tts-1-5-maxInworld TTS 1.5 Maxtts2 credits / 1000 characters
lyria-3-proLyria 3 Promusic15 credits / generation
stable-audio-3Stable Audio 3music7 credits / generation
minimax-musicMiniMax Music 2.6music27 credits / generation
lyria-3Lyria 3music8 credits / generation
elevenlabs-music-v2-5ElevenLabs Music v2.5music108 credits / generation
elevenlabs-sfxElevenLabs Sound Effectssfx4 credits / generation
sonilo-sfxSonilo Sound Effectssfx4 credits / generation

3D

Model idNameModesBase price
tripo-h3-1Tripo H3.1image54 credits / generation
tripo-p2Tripo P2image198 credits / generation
hunyuan-3d-proHunyuan 3D Proimage68 credits / generation
rodin-2-5Rodin 2.5image72 credits / generation
trellis-2Trellis 2image54 credits / generation
tripo-h3-1-textTripo H3.1text36 credits / generation
rodin-2-5-textRodin 2.5text72 credits / generation
meshy-7-viewsMeshy V7views216 credits / generation
meshy-riggingMeshy Rigginganimate36 credits / generation
meshy-rigging-multiMeshy Rigging Multi-Animationanimate36 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": 45,
    "available": 12
  }
}

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, reference_*An input file cannot be read, or breaks the model’s limits.
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.
400name_invalid, handle_invalid, handle_reserved, photos_required, too_many_photos, photo_foreign, photo_unreadable, photo_too_small, photo_aspectPOST /characters: fix the field. A photo refusal names the photo (index, photo).
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.
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.
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 and POST /effects/{slug}/run, dry runs included30 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
API runs queued or being made at once10 per account, generations and effects 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

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);
  • 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.