Clone a voice
curl --request POST \
--url https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"name": "<string>",
"description": "<string>",
"language": "<string>",
"labels": "<string>",
"ref_text": "<string>",
"remove_background_noise": true
}
'import requests
url = "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone"
payload = {
"name": "<string>",
"description": "<string>",
"language": "<string>",
"labels": "<string>",
"ref_text": "<string>",
"remove_background_noise": True
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
name: '<string>',
description: '<string>',
language: '<string>',
labels: '<string>',
ref_text: '<string>',
remove_background_noise: true
})
};
fetch('https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'name' => '<string>',
'description' => '<string>',
'language' => '<string>',
'labels' => '<string>',
'ref_text' => '<string>',
'remove_background_noise' => true
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone"
payload := strings.NewReader("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"language\": \"<string>\",\n \"labels\": \"<string>\",\n \"ref_text\": \"<string>\",\n \"remove_background_noise\": true\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"language\": \"<string>\",\n \"labels\": \"<string>\",\n \"ref_text\": \"<string>\",\n \"remove_background_noise\": true\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"language\": \"<string>\",\n \"labels\": \"<string>\",\n \"ref_text\": \"<string>\",\n \"remove_background_noise\": true\n}"
response = http.request(request)
puts response.read_body{
"data.voice": {},
"data.job_id": "<string>"
}Endpoints
Clone a voice
Create a voice and enroll it from a reference clip in a single call.
POST
/
flam-voice-svc
/
api
/
v1
/
public
/
voices
/
clone
Clone a voice
curl --request POST \
--url https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"name": "<string>",
"description": "<string>",
"language": "<string>",
"labels": "<string>",
"ref_text": "<string>",
"remove_background_noise": true
}
'import requests
url = "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone"
payload = {
"name": "<string>",
"description": "<string>",
"language": "<string>",
"labels": "<string>",
"ref_text": "<string>",
"remove_background_noise": True
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
name: '<string>',
description: '<string>',
language: '<string>',
labels: '<string>',
ref_text: '<string>',
remove_background_noise: true
})
};
fetch('https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'name' => '<string>',
'description' => '<string>',
'language' => '<string>',
'labels' => '<string>',
'ref_text' => '<string>',
'remove_background_noise' => true
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone"
payload := strings.NewReader("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"language\": \"<string>\",\n \"labels\": \"<string>\",\n \"ref_text\": \"<string>\",\n \"remove_background_noise\": true\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"language\": \"<string>\",\n \"labels\": \"<string>\",\n \"ref_text\": \"<string>\",\n \"remove_background_noise\": true\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"language\": \"<string>\",\n \"labels\": \"<string>\",\n \"ref_text\": \"<string>\",\n \"remove_background_noise\": true\n}"
response = http.request(request)
puts response.read_body{
"data.voice": {},
"data.job_id": "<string>"
}A convenience one-shot that creates a voice and then enrolls it from a
reference audio clip in a single
Body (
multipart/form-data request. Enrollment runs asynchronously: the
response is 202 Accepted with the new (processing) voice and a job_id you poll with
GET /jobs/{id}. Once the job reaches succeeded, the voice’s status becomes
ready and it can synthesize.
Cloning is free - it accrues no character usage and is never charged. It’s bounded only by your
plan’s
max_voices limit; over the cap the request returns 403 with
{ "error": "plan limit reached", "feature": "max_voices", "limit": <n> }.Authorization
string
required
Your workspace API key (
flv_…).Body (multipart/form-data)
string
required
Display name for the new voice.
file
required
The reference audio clip to clone from. Maximum 20 MiB; larger uploads return
400.string
Optional long-form description.
string
BCP-47 language code of the reference clip (e.g.
en, hi). Also becomes the voice’s primary
language.string
Optional key/value tags as a JSON-encoded string, e.g.
labels={"accent":"british","gender":"male"}.string
Optional transcript of the reference clip. Improves clone quality when provided.
boolean
default:"false"
Accepts
true/1/on/yes. When set, the voice is tagged with a
background_noise_removal label. This records the denoise preference as voice
metadata; the enrollment pipeline does not currently apply denoising.Response
Voice
The new Voice object, initially with status
processing.string
The clone job id. Poll
GET /jobs/{id} until it reaches a terminal status.curl -X POST "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone" \
-H "X-API-Key: $FLAM_API_KEY" \
-F "name=Narrator" \
-F "description=Warm documentary voice" \
-F "language=en" \
-F 'labels={"accent":"british","gender":"male"}' \
-F "ref_text=The quick brown fox jumps over the lazy dog." \
-F "file=@./sample.wav"
import { readFile } from "node:fs/promises";
const form = new FormData();
form.set("name", "Narrator");
form.set("language", "en");
form.set("labels", JSON.stringify({ accent: "british", gender: "male" }));
form.set("ref_text", "The quick brown fox jumps over the lazy dog.");
form.set("file", new Blob([await readFile("./sample.wav")]), "sample.wav");
const res = await fetch(
`https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone`,
{ method: "POST", headers: { "X-API-Key": process.env.FLAM_API_KEY }, body: form },
);
const { data } = await res.json();
console.log(data.voice.id, data.job_id);
import os, json, requests
with open("sample.wav", "rb") as f:
res = requests.post(
f"https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone",
headers={"X-API-Key": os.environ["FLAM_API_KEY"]},
data={
"name": "Narrator",
"language": "en",
"labels": json.dumps({"accent": "british", "gender": "male"}),
"ref_text": "The quick brown fox jumps over the lazy dog.",
},
files={"file": ("sample.wav", f, "audio/wav")},
)
data = res.json()["data"]
print(data["voice"]["id"], data["job_id"])
Response
{
"status": 202,
"data": {
"voice": {
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"name": "Narrator",
"status": "processing",
"primary_language": "en"
},
"job_id": "665f1a2b3c4d5e6f7a8b9aa01"
},
"error": false
}
Poll the clone job
After a successful202, poll the job until it reaches a terminal status (succeeded, failed,
cancelled, or expired). See Get a job. A clip the cloner can’t use fails
the job rather than the request - the reference needs at least ~6 seconds of continuous speech.
cURL
# 1. Kick off the clone and capture the job id.
JOB_ID=$(curl -s -X POST "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone" \
-H "X-API-Key: $FLAM_API_KEY" \
-F "name=Narrator" \
-F "language=en" \
-F "file=@./sample.wav" | jq -r '.data.job_id')
# 2. Poll until the job finishes.
while :; do
STATUS=$(curl -s "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/jobs/$JOB_ID" \
-H "X-API-Key: $FLAM_API_KEY" | jq -r '.data.status')
echo "job status: $STATUS"
case "$STATUS" in
succeeded) echo "voice is ready"; break ;;
failed|cancelled|expired) echo "clone did not complete"; break ;;
esac
sleep 2
done
Prompt
Copy this into your coding agent (Claude Code, Cursor, Codex…) to implement this endpoint.You are implementing a typed client for one Finesse Voice API endpoint: "Clone a voice".
Everything you need is in this prompt. Do not invent anything that is not specified here.
WHAT TO BUILD
- A function cloneVoice(input) (name it per the project's conventions) that creates a voice AND
enrolls it from a reference audio clip in one multipart request, returning { voice, jobId }.
- A function waitForJob(jobId, options) that polls the clone job until it reaches a terminal
status (reuse an existing one if the project already has it).
- Detect the stack first: look for package.json, tsconfig.json, pyproject.toml,
requirements.txt, go.mod, Gemfile, composer.json and similar. Use the project's language, HTTP
client, module layout, error-handling conventions and test runner. If there is no project yet or
the stack is unclear, use TypeScript on Node 18+ with the built-in fetch and no new dependencies.
- If the project already has a Finesse API client module, add this to it (reuse its auth, base
URL, error type and retry helper) instead of creating a parallel one.
- Keep the base URL (https://fi.production.flamapis.com) in one constant or config value.
ENDPOINT
- Method: POST
- URL: https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone
- Body: multipart/form-data
- Enrollment is asynchronous: the response is 202 Accepted with the new voice (status
"processing") and a job_id to poll.
REQUEST FIELDS (multipart/form-data)
- Send multipart/form-data. Do not set the Content-Type header yourself; let the HTTP library
add it with the multipart boundary.
- name (text, required): display name for the new voice. Validate non-empty locally.
- file (file, required): the reference audio clip to clone from. Maximum 20 MiB (20 * 1024 * 1024
bytes); larger uploads return 400, so check the size locally before uploading. Accept a file
path, Buffer/Blob or stream as fits the stack, and send a filename (e.g. "sample.wav").
- description (text, optional): long-form description.
- language (text, optional): BCP-47 code of the reference clip (e.g. "en", "hi"). Also becomes the
voice's primary language.
- labels (text, optional): key/value tags as a JSON-ENCODED STRING of a flat object, e.g.
labels={"accent":"british","gender":"male"}. Take an object in your input type and
JSON.stringify it yourself.
- ref_text (text, optional): transcript of the reference clip. Improves clone quality.
- remove_background_noise (text, optional, default false): accepts "true", "1", "on" or "yes".
When set, the voice is tagged with a background_noise_removal label. This only records the
preference as metadata; the pipeline does not currently apply denoising, so do not promise it.
- Omit optional fields that the caller did not provide.
Equivalent curl:
curl -X POST "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/clone" \
-H "X-API-Key: $FLAM_API_KEY" \
-F "name=Narrator" \
-F "language=en" \
-F 'labels={"accent":"british","gender":"male"}' \
-F "ref_text=The quick brown fox jumps over the lazy dog." \
-F "file=@./sample.wav"
AUTHENTICATION
- Send the header X-API-Key: <workspace API key> on every request. Keys start with "flv_".
- Read the key from the environment variable FLAM_API_KEY (or the project's existing secrets or
config mechanism). Fail fast with a clear error if it is missing, before making any request.
- Never hard-code, log or commit the key, and never ship it in browser/client-side code: call this
from server-side code only. The key can synthesize with every voice in its workspace.
- A key is bound to one workspace. Referencing a voice or job from another workspace returns 404.
RESPONSE ENVELOPE
JSON responses use one envelope. On success "error" is false and the payload is in "data":
{
"status": 200,
"data": { "...": "endpoint payload" },
"error": false
}
On failure "error" is true, "data" is omitted and "message" carries the detail:
{
"status": 404,
"error": true,
"message": "Voice not found"
}
Billing and plan denials do NOT use the envelope. They return a flat JSON body whose "error"
field is a string, not a boolean:
{ "error": "plan limit reached", "feature": "max_voices", "limit": 10 }
{ "error": "quota_exhausted", "unit": "character", "limit": 100000, "used": 100000 }
The "error" string is the denial reason (for example quota_exhausted, insufficient_credits,
limit_reached, feature_disabled, or one of the messages above). Tell the two shapes apart by
checking whether "error" is a boolean (envelope) or a string (flat denial), and expose
feature / unit / limit / used on your error type when present.
SUCCESS RESPONSE (202, application/json)
{
"status": 202,
"data": {
"voice": {
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"name": "Narrator",
"status": "processing",
"primary_language": "en"
},
"job_id": "665f1a2b3c4d5e6f7a8b9aa0"
},
"error": false
}
- data.voice: the new Voice, initially "processing".
- data.job_id: the clone job id to poll.
VOICE OBJECT (define a type for it; timestamps are Unix epoch MILLISECONDS)
- id: string. Unique voice id, 24-character hex. Used by the synthesize endpoints.
- name: string. Display name.
- description: string, optional.
- status: string. Only "ready" voices can synthesize. A voice being created reports "processing",
"failed" if creation did not succeed, and a newly created voice with no audio yet is "draft".
Type it as a string union of these values but tolerate other strings (treat them as not usable).
- clone_method: "instant" | "professional" | "design" | "remix".
- visibility: "private" | "org" | "public".
- primary_language: string, BCP-47 (e.g. "en"). Used when a synthesis request omits language.
- labels: array of { key: string, value: string }, optional. Common keys: accent, gender, age,
use_case. Example: [{ "key": "accent", "value": "american" }]
- settings: object, optional, present only when configured. Synthesis defaults: stability,
similarity, style, speed, pitch, volume, use_speaker_boost, emotion, engine_params.
- sample_audio_url: string, optional. Public URL of a short preview clip.
- base_voice_id: string, optional. Present when derived from another voice (clone_method "remix").
- created_at, updated_at: integers (epoch ms).
Optional fields may be absent. Responses may contain extra fields: ignore them, never fail on them.
Example:
{
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"name": "Studio Narrator",
"description": "Warm, neutral narration voice.",
"status": "ready",
"clone_method": "instant",
"visibility": "public",
"primary_language": "en",
"labels": [
{ "key": "accent", "value": "american" },
{ "key": "gender", "value": "female" }
],
"sample_audio_url": "https://storage.googleapis.com/.../sample.wav",
"created_at": 1717939200000,
"updated_at": 1717939260000
}
JOB OBJECT (returned by GET https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/jobs/{id} in "data")
- id: string. The job id.
- voice_id: string. The voice this job is enrolling.
- org_id: string. The owning workspace.
- job_type: string, e.g. "instant_clone", "pro_train", "verify", "localize", "design_preview",
"reembed".
- status: "pending" | "running" while in progress; terminal states are "succeeded", "failed",
"cancelled" and "expired". When "succeeded", the voice's status is "ready".
- progress: integer 0-100.
- error: object, present when status is "failed"; carries the provider error detail.
{
"status": 200,
"data": {
"id": "665f1a2b3c4d5e6f7a8b9aa0",
"voice_id": "665f1a2b3c4d5e6f7a8b9c0d",
"org_id": "664a0b1c2d3e4f5a6b7c8d9e",
"job_type": "instant_clone",
"status": "succeeded",
"progress": 100
},
"error": false
}
POLLING THE JOB
- Poll GET https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/jobs/{job_id} (same X-API-Key header) every ~2 seconds until
data.status is terminal: "succeeded", "failed", "cancelled" or "expired". "pending" and "running"
mean keep polling.
- On "succeeded" the voice is "ready" and can synthesize. On "failed" surface data.error (for
example, a clip without at least ~6 seconds of continuous speech fails the job, not the POST). On
"cancelled" or "expired" surface a clear "clone did not complete" error.
- Make the interval and an overall timeout configurable, accept an abort/cancel signal, and retry
individual poll requests on 429, 502 and network errors with backoff (a poll is a safe GET).
- A 404 while polling means the job does not exist in this workspace: stop and surface it.
LIMITS AND BILLING
- Cloning is free: it accrues no character usage and is never charged.
- It is bounded by the plan's max_voices limit. Over the cap: 403 with the flat body
{ "error": "plan limit reached", "feature": "max_voices", "limit": <n> }.
ERRORS
- 400: invalid request, e.g. missing name, missing file, or a file over 20 MiB. Do not retry.
- 401: X-API-Key missing, malformed, invalid or revoked. Do not retry.
- 402: metered allowance exhausted (flat billing body). Do not retry.
- 403: "max_voices" plan limit reached (flat body with "feature" and "limit"). Do not retry.
- 422: the voice provider rejected the input, e.g. a reference clip that is too short
(message such as "audio_too_short"). Do not retry; tell the caller to fix the clip.
- 429: rate limited. 502: provider temporarily unavailable.
- Surface failures as one typed error (for example FinesseApiError) carrying: HTTP status, message
(the envelope "message" or the flat "error" string), optional feature / unit / limit / used, a
retryable flag, and the raw body for debugging.
- Parse error bodies defensively: if a body is not valid JSON, keep the raw text in the error.
RETRIES
- This POST creates a resource, so do NOT retry it automatically after a timeout or 5xx: the first
attempt may have succeeded and a retry could create a duplicate. Surface the error instead.
- A 429 (rate limited) means the request was refused, so retrying it with exponential backoff plus
jitter (about 1s, 2s, 4s; at most 3 retries) is safe.
- Never retry 400, 401, 402, 403, 404 or 422.
ACCEPTANCE CRITERIA
- cloneVoice sends a multipart request with exactly the documented fields, labels as a JSON
string, and a file no larger than 20 MiB (checked before upload).
- On 202 it returns the typed voice and jobId; waitForJob resolves on "succeeded" and rejects with
a descriptive error on "failed", "cancelled", "expired" or its own timeout.
- The 403 max_voices answer is a distinct, non-retried plan-limit error exposing "limit".
- The POST is never auto-retried after a timeout or 5xx. The key is never logged.
TEST PLAN
- Unit tests with mocked HTTP: 403 max_voices maps to a plan-limit error with limit; 202 returns voice + jobId; a 21 MiB file is rejected
locally; labels are serialized as a JSON string; waitForJob walks running -> succeeded, and
running -> failed surfaces data.error; a poll 502 is retried.
- Smoke script: call cloneVoice with a local sample.wav, poll the job and print the final status
and the voice id.
RULES
- Implement only what this spec describes. Do not invent parameters, fields, headers, endpoints or
error codes. If something is ambiguous, missing, or conflicts with the existing codebase, stop
and ask me before guessing.
- Match the project's existing style and conventions; do not refactor unrelated code.

