Voice and media
Voices, voice changing, multi-speaker recast, dubbing, video import, captions, trimming, audio tools and social publishing, all as REST endpoints on Nodaro.
The voice and media endpoints expose Nodaro's voice and media tools as plain REST. They cover the voice catalog, voice changing and multi-speaker recasting, dubbing, voice design, video import, captions, trimming, audio tools, transcription and social publishing. Most of them are jobs: the POST returns { jobId } at once, you poll GET /v1/jobs/:id/status until the status is completed, and you read the result from output_data.
The routes work on every edition, with the exceptions marked below, and take a bearer token. See Authentication, and Jobs for polling many jobs at once. To send local files, upload them first; see Uploads.
Voices
| Method | Path | What it does |
|---|---|---|
GET | /v1/voices | The premade voice catalog: name, voice_id, gender, accent and age. |
GET | /v1/voices/library | Search the shared Voice Library with search, gender, age, accent, language, category, use_cases, descriptives, featured, sort, page and page_size. |
GET | /v1/voices/library/facets | How many library voices each group, tone, language, gender, age, accent and category has, for the filters you send. |
GET | /v1/voices/:voiceId/preview | A voice's preview, served from the deployment's own host, on a deployment that serves previews this way. |
GET | /v1/voices/favorites | Your favorite voices, newest first. |
PUT | /v1/voices/favorites/:voiceId | Star a voice. |
DELETE | /v1/voices/favorites/:voiceId | Remove a voice from your favorites. |
GET | /v1/voice-clones | The voice clones you made before cloning was retired. |
PATCH | /v1/voice-clones/:id | Rename or edit one of those clones. |
DELETE | /v1/voice-clones/:id | Delete one of those clones. |
POST | /v1/voice-clones, /v1/voice-clones/from-url | Retired. Both answer 410 voice_cloning_retired. |
Wherever a route accepts a voice, pass a catalog voice's voice_id, a premade voice's name such as Rachel, or a clone's elevenlabsVoiceId. Clones made before the retirement keep working everywhere.
A Voice Library result may carry recommendedProvider: the text-to-speech model the voice is verified on. When your app has no model picker, send it as provider on Text to Speech, so the voice sounds like its preview. verifiedProviders lists every model the voice is verified on; when your app has a picker, override the user's choice only when it is not in that list. hasMore in the response drives "load more".
import { createClient, StaticTokenAuth } from '@nodaro/sdk'
const client = createClient({
baseUrl: 'https://app.nodaro.ai',
auth: new StaticTokenAuth(process.env.NODARO_API_KEY!),
})
const { voices, hasMore } = await client.voices.searchLibrary({ search: 'deep', language: 'en' })
const voice = voices[0]
await client.nodes.run('text-to-speech', {
text: 'Hello!',
voice: voice.voice_id,
voiceType: 'library',
...(voice.recommendedProvider ? { provider: voice.recommendedProvider } : {}),
})Filter the Voice Library
use_casesgroups the library the way a picker shows categories:advertisement,characters_animation,conversational,entertainment_tv,informative_educational,narrative_storyorsocial_media. Send one value per call.descriptivesis one tone word, such ascalm,deeporcute. Send one value per call.- Each voice carries its
descriptive(the library's one-word tone,""when none), its primarylanguageandlocale(""when unstated), andverifiedLanguages: every language code the voice is verified for, the primary first.languagematches againstverifiedLanguages, so a voice in English that is verified for Spanish is listed underlanguage=es.
GET /v1/voices/library/facets takes the same filters without paging and sort, and answers { total, use_cases, languages, descriptives, genders, ages, accents, categories, approximate, updatedAt }. Each facet is a list of { value, count }, sorted by count, with zero counts left out.
totalapplies every filter. Each facet's counts apply every filter except that facet's own, so a picker can show every category's count while one is chosen.languagescounts a voice under every language it is verified for, so those counts add up to more thantotal.- The counts come from the platform's own copy of the library, read on a schedule.
updatedAtsays when, so a count can trail the listing by a few voices. Withsearch, the counts are a lower estimate andapproximateistrue. - The route is public and free, and can be cached for 5 minutes. For a few minutes after a deployment first reads the library, it answers
503 voice_library_warmingwithRetry-After. A deployment without a speech key answers zero counts withkeyMissing: true.
Favorite voices
Your favorite voices are shared by every client you use: a premade voice, a Voice Library voice or one of your clones, starred once and read back everywhere.
GET /v1/voices/favoritesanswers{ favorites: [{ voiceId, voiceType, name, previewUrl, createdAt }] }, newest first.voiceIdandvoiceTypeare what Text to Speech takes asvoiceandvoiceType.nameandpreviewUrlare the snapshot you starred the voice with.PUT /v1/voices/favorites/:voiceIdtakes{ voiceType: "premade" | "library" | "custom", name, previewUrl? }and answers{ favorite }. Starring a voice again refreshes its snapshot. Past 500 favorites it answers409 limit_reached.DELETE /v1/voices/favorites/:voiceIdanswers{ success: true }, also for a voice that was not starred.- Scopes: an OAuth app token needs
assets:readfor the list andassets:writefor the two writes.
Voice previews from the deployment's own host
A deployment can serve voice previews from its own host. There, both GET /v1/voices and GET /v1/voices/library give each preview_url as a signed address of GET /v1/voices/:voiceId/preview, instead of the speech provider's address. Play the address as it is given.
- The route needs no bearer token, because an
<audio>element sends none. The signature is the credential. - It answers the audio, with
206for a byte range. An address the lists did not give answers404, a voice the deployment's voice lock refuses answers400 voice_not_available, and a file the provider cannot serve answers502 preview_unavailable. - On any other deployment the route answers
404.
Design a voice, or speak in a described voice
| Method | Path | Body | Result |
|---|---|---|---|
POST | /v1/voice-design | { text, voiceDescription, model?, loudness?, guidanceScale?, seed?, quality?, shouldEnhance? } | A preview and a reusable voice id. |
POST | /v1/voice-remix | { text, voiceDescription } | Speech in the described voice, without cloning. |
For Voice Design, text is a preview line of 100 to 1,000 characters, loudness runs from -1 to 1 and guidanceScale from 0 to 100. For Voice Remix, text is 1 to 5,000 characters. Both return a job.
Change the voice in a recording
POST /v1/voice-changer replaces the voice in an audio track, or in a whole talking video, with one target voice. Send exactly one of audioUrl or videoUrl; when you send both, the video wins. For a video, Nodaro takes the audio out, changes the voice and puts it back on the original picture, and the job's output_data carries both videoUrl and audioUrl.
curl -X POST https://app.nodaro.ai/v1/voice-changer \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "videoUrl": "https://cdn.nodaro.ai/uploads/talking.mp4", "voiceId": "Aria", "removeBackgroundNoise": false }'const { jobId } = await client.voices.change({
videoUrl: 'https://cdn.nodaro.ai/uploads/talking.mp4',
voiceId: 'Aria',
})nodaro voice changer --voice Aria --video https://cdn.nodaro.ai/uploads/talking.mp4 --watch| Field | What it does |
|---|---|
voiceId | Required. The target voice. |
audioUrl or videoUrl | Required, one of them. The recording to change. |
model | The speech-to-speech model. |
stability, similarityBoost | 0 to 1. How steady the delivery is, and how close to the target voice. |
style | 0 to 1, default 0. Exaggerates the delivery, at some cost in speed and steadiness. |
useSpeakerBoost | Sharpens the likeness to the target voice, a little more slowly. |
seed | An integer that makes the output repeatable. |
removeBackgroundNoise | true returns only the clean voice; false keeps the music and effects under the new voice. |
See Voice Changer for prices and tips.
Recast several speakers
Voice Changer Pro detects each speaker in a recording and gives each one a different voice, keeping the words and the timing. It runs on Nodaro Cloud. A self-hosted install connected to Nodaro Cloud can run the one-shot recast through that connection; the analyze and export steps need Nodaro Cloud itself. See Cloud connect.
| Method | Path | What it does |
|---|---|---|
POST | /v1/voice-changer-pro | Recast every mapped speaker, as a finished video or as separate stems. |
POST | /v1/voice-changer-pro/analyze | Detect the speakers without recasting. Nodaro Cloud only. |
POST | /v1/voice-changer-pro/export | Render a finished video from your own mix of the stems. Nodaro Cloud only. |
POST | /v1/voice-changer-pro/translate | Translate the detected transcript into another language, to re-speak the video in it. Nodaro Cloud only. |
Map voices to speakers
orderedVoices maps speakers by detection order: speaker 0 gets entry 0, speaker 1 gets entry 1, and so on, with 1 to 8 entries. Speakers past the end of the list keep their own voice. Each entry is one of:
- A voice id, such as
"Rachel". null, a keep-slot: that speaker keeps their own voice, and later speakers are still recast. Keep-slots cost nothing, but at least one entry must be a voice.- An object with per-speaker settings:
{ voiceId, engine?, stability?, similarityBoost?, style?, useSpeakerBoost?, seed?, volumeMode?, volume?, languageCode? }.engineissts(the default, speech-to-speech),v3orv4.v3performs the line again from the transcript with ElevenLabs v3 and takes astabilityof 0, 0.5 or 1 only.v4does the same with ElevenLabs v4, takes anystabilityfrom 0 to 1 and appliessimilarityBoost.languageCodeis accepted withv4only, to speak a translated line, as described below.volumeModeismatch(the default),normalizeormanual, withvolumefrom 0 to 200 percent.
Before recasting, Nodaro always separates the voices from the music and effects. The rest of the body controls the result:
| Field | What it does |
|---|---|
audioUrl or videoUrl | Required, one of them. For a video, the recast audio goes back on the original picture. |
preserveBackground | Mix the music and effects back under the new voices. Default true. |
separationQuality | fast (the default, keeps more of the voice) or best (a finer split). |
removeBackgroundNoise | Also denoise the result. |
musicVolumeMode, musicVolume | The level of the kept background: match (the default), normalize, or manual with 0 to 200 percent. |
voiceFx | A reverb or an echo on the voices only: { preset, wetDryMix?, delayMs?, decay? }. Reverb presets such as room, hall or church use wetDryMix; echo and custom use delayMs and decay. |
output | video (the default, the finished result) or stems (dry, unleveled tracks for your own mix). |
analysis | A previous analyze job's output_data. The recast reuses its speakers and stems instead of detecting them again. |
The recast is charged per mapped speaker; analyze and export have flat prices. See Voice Changer Pro for the prices.
Re-speak in another language. POST /v1/voice-changer-pro/translate translates the speakers of an analysis into one target language, segment by segment. Each line gets a length budget, so the re-spoken line fits its slot. The body is { speakers, targetLanguage, sourceLanguage?, tier? }, where speakers comes from the analysis and tier is economy, standard or premium. The job's output_data.speakers keeps every speaker, segment and timing, with the translated text, the original in sourceText, and untranslated, the ids of lines left in the source language. Review the text, then send these speakers as the recast's analysis.speakers, with engine: "v4" and languageCode set to the target language on each re-spoken voice. languageCode with sts or v3, or a language the model does not offer, answers 400. The translation is priced by the length of the text, with a minimum price and a maximum per started 1,000 characters that depend on the tier. Off Nodaro Cloud, the four voice-changer-pro routes answer 404.
The interactive flow: analyze, recast to stems, export
The one-shot recast renders a finished video in one call. The interactive flow lets you check the speakers before you pay for the recast, and mix the result before you render it.
Detect the speakers
POST /v1/voice-changer-pro/analyze with audioUrl or videoUrl, and optionally separationQuality and suggestTitle. The finished job's output_data has the separated vocalsUrl and backgroundUrl, and the detected languageCode and languageProbability. Its speakers list gives each speaker's id, segments, firstStartSec, wordCount and a snippet. It also has a suggestedTitle when you asked for one. Look for "speakers" that are not people, such as applause.
Recast to stems
POST /v1/voice-changer-pro with output: "stems" and the whole analysis output_data as analysis. The recast skips detection, so you can recast again with other voices without paying for detection again.
Mix and export
Set the level of each stem in your own interface; that costs nothing. Then POST /v1/voice-changer-pro/export with the source videoUrl and up to 16 tracks, each { url, gain, muted, kind? }. gain is 0 to 200, kind is voice or background, and at least one track must be unmuted. An optional voiceFx applies to the voice tracks only. The video stream is copied, never encoded again, so the export matches your preview. The finished job's output_data.videoUrl is the result.
AUTH="Authorization: Bearer $NODARO_API_KEY"
VIDEO="https://cdn.nodaro.ai/uploads/panel.mp4"
# 1. Detect the speakers
JOB=$(curl -s -X POST https://app.nodaro.ai/v1/voice-changer-pro/analyze -H "$AUTH" \
-H 'Content-Type: application/json' -d "{\"videoUrl\":\"$VIDEO\",\"suggestTitle\":true}" | jq -r .jobId)
# ...poll until completed, then keep the analysis
ANALYSIS=$(curl -s "https://app.nodaro.ai/v1/jobs/$JOB/status" -H "$AUTH" | jq .data.output_data)
# 2. Recast to stems: speaker 2 keeps their own voice
JOB=$(curl -s -X POST https://app.nodaro.ai/v1/voice-changer-pro -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"videoUrl\":\"$VIDEO\",\"orderedVoices\":[\"Rachel\",null,\"Aria\"],\"output\":\"stems\",\"analysis\":$ANALYSIS}" | jq -r .jobId)
# 3. Export your mix
curl -s -X POST https://app.nodaro.ai/v1/voice-changer-pro/export -H "$AUTH" -H 'Content-Type: application/json' -d "{
\"videoUrl\": \"$VIDEO\",
\"tracks\": [
{ \"url\": \"<voice stem 0>\", \"gain\": 100, \"muted\": false },
{ \"url\": \"<voice stem 1>\", \"gain\": 90, \"muted\": false },
{ \"url\": \"<background stem>\", \"gain\": 70, \"muted\": false, \"kind\": \"background\" }
],
\"voiceFx\": { \"preset\": \"hall\", \"wetDryMix\": 25 }
}"const { jobId: analyzeJob } = await client.voices.analyze({ videoUrl, suggestTitle: true })
const analysis = (await client.jobs.get(analyzeJob)).output_data // once completed
const { jobId: recastJob } = await client.voices.recast({
videoUrl,
orderedVoices: ['Rachel', null, 'Aria'],
output: 'stems',
analysis,
})
const stems = (await client.jobs.get(recastJob)).output_data // once completed
await client.voices.exportMix({
videoUrl,
tracks: [
{ url: stems.tracks[0].url, gain: 100, muted: false },
{ url: stems.tracks[1].url, gain: 90, muted: false },
{ url: stems.backgroundUrl, gain: 70, muted: false, kind: 'background' },
],
voiceFx: { preset: 'hall', wetDryMix: 25 },
})nodaro voice analyze --video https://cdn.nodaro.ai/uploads/panel.mp4 --watch --json > analyze.json
jq .output_data analyze.json > analysis.json
nodaro voice recast --video https://cdn.nodaro.ai/uploads/panel.mp4 --voices Rachel,keep,Aria \
--analysis-file analysis.json --output stems --watch
nodaro voice export --source https://cdn.nodaro.ai/uploads/panel.mp4 --tracks-file mix.json \
--voice-fx hall --voice-fx-mix 25 --watchIn the CLI, the word keep in --voices is a keep-slot.
Dub into another language
POST /v1/dubbing translates and revoices an audio track or a whole video, keeping each speaker's voice. Send exactly one source: audioUrl, videoUrl, or sourceUrl (a public link to where a video is, or a direct media link, that the dubbing model fetches itself). A sourceUrl is an import of that link: send the confirmationId confirmed for it. See Content rights. A video comes back as a dubbed video: output_data.videoUrl, plus the dubbed track alone in output_data.audioUrl.
| Field | What it does |
|---|---|
targetLanguage | Required. An ISO code, for example es or pt-BR. |
sourceLanguage | The spoken language. Detected when you omit it. |
numSpeakers | 0 (the default) detects them; 1 to 20 improves the separation when you know the number. |
startTime, endTime | Dub only this window of the source, in seconds. |
disableVoiceCloning, dropBackgroundAudio | Do not reproduce each speaker's own voice, or drop the music and effects. |
highestResolution | Keep the source resolution on a video. |
useProfanityFilter, targetAccent, watermark | A profanity filter, an accent for the target language, and the dubbing model's watermark on a video. |
A dub is priced per minute of the dubbed span, with a 1-minute minimum. The span can be up to 30 minutes; a longer one answers 413, so dub a window with startTime and endTime. See Dubbing.
Import a video from a link
POST /v1/download-video imports the video behind a link, the video's page or a direct link to a video file, into your storage. It returns { downloadId }, not a job id. The finished file lands in your library.
| Field | What it does |
|---|---|
url | Required. The page or file to import. |
maxHeight | Cap the resolution, for example 720. Omit it for the best available. |
sectionStartSec, sectionEndSec | Import only that range, in seconds, with a few seconds more on each side, because the cut lands on keyframes. Send both or neither, and trim the result for an exact cut. |
exactSection | true cuts the section at exactly the requested range instead, with no margin. |
requireAudio | A result without sound fails by default, because it usually means a degraded source. Send false to accept a silent clip. |
confirmationId | The content rights confirmation for this link, from POST /v1/content-rights/confirm. See Content rights. |
An import of a link runs the content rights check before anything is fetched. A confirmation that does not fit, or a site that the deployment does not allow, answers 403 with the code that says why. See Content rights.
Follow the download with GET /v1/download-video/progress/:downloadId, a stream of server-sent events. About every 500 ms it sends { phase, percent, videoUrl?, thumbnailUrl?, error? }, and it ends at completed, with the stored videoUrl, or at failed, with the error. Start listening as soon as the import starts, because the progress is kept only briefly after the download ends. At most 4 downloads run per account at once; a fifth answers 429 too_many_downloads.
DL=$(curl -s -X POST https://app.nodaro.ai/v1/download-video \
-H "Authorization: Bearer $NODARO_API_KEY" -H 'Content-Type: application/json' \
-d '{ "url": "https://www.youtube.com/watch?v=VIDEO_ID", "maxHeight": 720 }' | jq -r .downloadId)
curl -sN https://app.nodaro.ai/v1/download-video/progress/$DL \
-H "Authorization: Bearer $NODARO_API_KEY"const { downloadId } = await client.media.downloadVideo({
url: 'https://www.youtube.com/watch?v=VIDEO_ID',
maxHeight: 720,
})
for await (const event of client.media.downloadVideoProgress(downloadId)) {
console.log(`${event.phase} ${event.percent}%`)
if (event.phase === 'completed') console.log('stored at', event.videoUrl)
}nodaro media download "https://www.youtube.com/watch?v=VIDEO_ID" --max-height 720 --watch
nodaro media metadata "https://www.youtube.com/watch?v=VIDEO_ID"Two related routes answer at once:
POST /v1/video-metadatawith{ url }reads a video's duration, size, title and live status without downloading it. Use it to decide whether to import only a section.POST /v1/save-to-storagewith{ mediaUrl, filename?, mediaType? }copies any external media URL into your storage, on the server. It returns a job.
Edit video
| Method | Path | What it does | Price |
|---|---|---|---|
POST | /v1/trim-video | Cut a video to a range. | Trim Video |
POST | /v1/add-captions | Burn captions into a video. | Add Captions |
POST | /v1/still-to-video | One image and one audio track become an MP4. | 0 credits |
POST | /v1/slideshow | 2 to 100 images and an optional audio track become an MP4. | 0 credits |
POST | /v1/video-overlay | Place 1 to 20 timed image or video layers over a video. | 22 credits with image layers only; per started 5 seconds of the base with a video layer |
POST | /v1/media/process | Cut or crop a stored file, and answer at once. | Free |
POST | /v1/edit-plan | Plan an edit of a recording from its transcript: tighten it, find clips, mark chapters or cut a trailer. The result is an edit decision list, not media. | Edit Plan |
POST | /v1/camera-switch | Put each cut of an edit on the camera of whoever is speaking. The sound never changes. | Camera Switch |
POST | /v1/apply-edl | Render an edit decision list into one video or audio file, at final or as a proxy Preview. | Apply EDL |
POST | /v1/tighten-episode | Tighten a podcast or interview in one call: transcribe it, remove silences and filler, render the episode, and optionally add captions and a music bed. | Tighten Episode |
POST | /v1/episode-chapters | Find a recording's chapters in one call and write them as show notes. Nothing is rendered. | Its steps plus a per-run fee. See Chapters. |
POST | /v1/episode-trailer | Cut a 20 to 40 second trailer of a recording, in each social format you choose. | Its steps plus a per-run fee. See Trailer. |
POST | /v1/clip-pack | Cut a recording into a pack of short clips, framed and captioned. | Its steps plus a per-run fee. See Clip Pack. |
POST | /v1/speaker-layout | Frame a whole recording by speaker, at the same length, with nothing cut. | Its steps plus a per-run fee. See Speaker Layout. |
POST | /v1/shot-cuts | Find where every shot of a video starts and ends, measured from the file. | A flat price per run. See Shot Cuts. |
POST | /v1/speaker-view | Render an edit with its speakers on screen, in a layout, with a switch at each speaker change. Priced per minute of output. | Speaker View |
POST | /v1/split-media | Cut a video, an audio file or both into parts between a minimum and a maximum length, or a video into one part per shot. | 22 credits per run |
POST | /v1/video-mask | Track a subject described in text and return a black-and-white mask video. Priced by the clip's length. | Video Mask |
POST | /v1/mask-composite | Place a foreground video over a background video through a mask video. | 11 credits per started 5 seconds |
POST | /v1/video-depth | Turn a video into a depth video, near bright and far dark. Priced by the clip's length. | Video Depth |
Edit Plan, Camera Switch, Speaker View and the planning steps of Tighten Episode, Chapters, Trailer, Clip Pack and Speaker Layout run on Nodaro Cloud. A self-hosted install runs them through its Nodaro Cloud connection, and answers 503 nodaro_connection_required without one.
POST /v1/camera-switch takes leadMs and lagMs (whole milliseconds, 0 to 5,000, default 0): leadMs moves each cut earlier, and lagMs delays it. POST /v1/speaker-view takes accentColor (#RRGGBB), the color of the Border emphasis. With no switch, Speaker View cuts between the speakers, and with no emphasis it draws none. A Speaker View render from videoUrl refuses a file over 500 MB with 422 video_too_large.
POST /v1/shot-cuts takes { videoUrl, frames? }: an MP4, MOV, MKV or WebM video up to 30 minutes and about 10.7 GiB. It scores every decoded frame, and each hard cut starts a new shot. Dissolves and fades are not cuts. The job's output_data.json is { shotCount, cutsSec, durationSec, shots: [{ index, startSec, endSec, durationSec, firstFrameUrl?, lastFrameUrl? }] }, in seconds to the millisecond. With frames: true, the exact first and last frame of each shot are saved as PNG, for up to 1,000 shots. A file that is not a readable video, or is over a limit, fails and is refunded. The price is the same with or without frames.
POST /v1/split-media takes videoUrl, audioUrl or both, chunkDuration (the longest a part may be, at least 1 second), minPartDuration (default 4, never above chunkDuration) and audioFormat (mp3, wav or aac). The finished job's output_data has videoUrls, audioUrls, chunkCount and partDurationsSec.
{ videoUrl, mode: "shots" } instead cuts a video into one part per shot, at the cuts measured from the file. It takes no part length and a video only: a separate audioUrl is a 400 validation_error. Each part keeps the video's own sound. Videos can be up to 30 minutes long, with at most 200 shots, at the same price. The output then adds shotCount and shots: [{ index, startSec, endSec, durationSec }]. See Split into Chunks for how the parts are cut.
GET /v1/edit-plan/capabilities lists the Edit Plan modes this server can plan: { modes, source, perMinute }. tighten, clips and chapters are always listed, and trailer only when the server can plan a trailer. perMinute is true when Edit Plan is charged per started minute of the master recording. Otherwise it is charged at the 15, 30, 60, 90, 120 or 180-minute step the recording rounds up to. The SDK reads the route with client.edit.editPlanCapabilities(), and the CLI with nodaro edit capabilities. A mode the server does not list answers 400 mode_not_available on POST /v1/edit-plan, before anything is charged.
On Nodaro Cloud, POST /v1/edit-plan also refuses these before anything is charged: a request with no mode (422 unknown_mode), no source with a media URL (422 no_source), a master source whose length cannot be read or is zero (422 probe_failed, 422 invalid_duration), a source over 180 minutes (422 source_too_long) and a transcript with no timed words (400 empty_transcript). planTier defaults to standard. The MCP tool plan_edit is offered on Nodaro Cloud only, not on a self-hosted install.
Tighten an episode
POST /v1/tighten-episode runs Tighten Episode on a recording. The body is { mediaUrls, masterAudioUrl?, cameras?, musicUrl?, output?, framing?, framingSource?, captions?, captionStyle?, reviewBeforeRender?, planTier?, instructions?, styleGuide?, musicDuckAmount? }. mediaUrls holds one video or audio recording, up to 3 hours long and with sound, or up to six recordings of one conversation. See Several cameras. framing is { aspect, layout } or null. The route answers 202 with { jobId, executionId }.
- Follow the job with
GET /v1/jobs/:id/status. Itsoutput_datacarries the episode (videoUrl, oraudioUrlfor an audio episode), the edit (json), the transcript (transcript) andquality:proxyfor the preview of a review run,finalotherwise. A run of several recordings or a Master audio addscameras, who was put on which camera. A failed run keeps what completed and names the step that failed infailedStep. - Retry safely. An
Idempotency-Keyheader makes a retry of the same request answer with the samejobIdand"deduped": true, and start nothing. - Price it first.
POST /v1/tighten-episode/estimatetakes the same body and answers{ data: { totalCredits, stepsCredits, feeCredits, durationSec } }without starting anything. It addsestimate: truewhen a step is a metered LLM call's estimate: show the total as≈N. The estimate prices the render at the recording's full length, so the run usually costs less. - Continue a run.
POST /v1/tighten-episode/continuewith{ jobId, mode }continues one of your runs."mode": "from-failed"runs only what a failed run did not complete."mode": "render-final", with an optionaleditedEdlthat holds your edit of the plan, renders the final of a review run.
A Render final of a run that was not a preview answers render_final_not_preview, and one of an edit that was already rendered answers render_final_already_rendered on every route except Chapters. Continuing a run that did not fail answers 409 continuation_not_failed.
Chapters, Trailer, Clip Pack and Speaker Layout
Four more routes run a podcast or interview edit in one call, the way POST /v1/tighten-episode does. They are the routes of the Chapters, Trailer, Clip Pack and Speaker Layout nodes. Each answers 202 with { jobId, executionId }, and each runs its steps as jobs of their own. A run costs its steps at their own rates plus a per-run fee. Each route has an /estimate route that takes the same body and answers { data: { totalCredits, stepsCredits, feeCredits, durationSec } }, and a /continue route. An Idempotency-Key makes a retry answer the same run.
| Route | Body | Result in output_data |
|---|---|---|
POST /v1/episode-chapters | { mediaUrls: [<one recording>], planTier?, instructions?, styleGuide? }. planTier defaults to economy. | text: the show notes, one M:SS Title line per chapter (H:MM:SS once a chapter starts at 1:00:00). json: the same chapters as [{ startMs, title }], the first at 0. |
POST /v1/episode-trailer | { mediaUrls, masterAudioUrl?, cameras?, introCardUrl?, formats?, reviewBeforeRender?, planTier?, instructions?, styleGuide? }. formats is a list of 9:16, 1:1, 4:5 and 16:9, by default the first three. introCardUrl is a video of 15 seconds at most. | formats: [{ format, url }]. videoUrl: the first format. failedFormats: the formats that could not be rendered. json: the trailer's edit. |
POST /v1/clip-pack | { mediaUrls, masterAudioUrl?, cameras?, count?, targetDurationSec?, framing?, framingSource?, captions?, captionStyle?, reviewBeforeRender?, planTier?, instructions?, styleGuide? }. count is 1 to 50 (default 8) and targetDurationSec 5 to 180 (default 60). | clips: [{ url, title?, hook?, clipKey, thumbnailUrl?, captionsFailed? }]. videoUrl (or audioUrl): the first clip. failedClips and failedCaptions: how many clips came without a render or without captions. json: the clip set. |
POST /v1/speaker-layout | { mediaUrls, masterAudioUrl?, cameras?, targetAspect?, layout?, switchType?, emphasisStyle?, framingSource?, reviewBeforeRender? }. targetAspect defaults to 9:16. | videoUrl: the framed video, as long as the recording. durationSec, json (the edit), transcript and quality. |
mediaUrls holds one recording up to 3 hours long, with sound, or up to six recordings of one conversation. Trailer and Speaker Layout need a video, and an audio-only run is refused with 400 media_needs_video. Speaker Layout takes at most five cameras: a sixth place is for the sound, a Master audio or one sound-only recording. A run of several recordings or a Master audio adds cameras to output_data.
- Refused before anything is created or charged: a recording that cannot be read (
400 media_unreadable), has no sound (400 media_has_no_audio) or runs over 3 hours (400 media_too_long), and a Master audio that cannot be read (400 master_audio_unreadable). See Errors. - A run that keeps part of its work completes. A Trailer with some formats, or a Clip Pack with some clips, completes and keeps its fee. A clip that failed is not charged. With nothing rendered, the run fails and its fee is refunded.
- A Chapters run that finds no chapters fails, and its fee is refunded. Every step of it completed, so
/continueanswers409 continuation_not_failed. Run it again, for example with other instructions. - Continue a run with
{ jobId, mode }."mode": "from-failed"runs what a failed run did not complete."mode": "render-final"renders a review run's final, with no fee. On Trailer it takes an optionaleditedEdl, and on Clip Pack aneditedEdlthat keeps some clips. A Clip Pack review that drops every clip answers409 use_case_nothing_kept. Speaker Layout takes noeditedEdl: one sent answers400 use_case_no_plan. Chapters renders nothing, so a Render final of it answers409 render_final_not_preview.
In the SDK, the routes are client.edit.episodeChapters, episodeTrailer, clipPack and speakerLayout, each with an estimate… method, and continueUseCase continues any of them. In the CLI they are nodaro edit episode-chapters, episode-trailer, clip-pack and speaker-layout. Over MCP, use make_chapters, make_trailer, make_clip_pack, speaker_layout and continue_use_case.
Inside a workflow, a Tighten Episode node with Review before render on needs a person to review it, in the editor or on an app's own page. In a workflow run started from the API, the SDK, MCP's run_workflow or a trigger, the nodes before it run and are charged, and the node then fails with preview_review_required, with nothing charged for it. Turn Review before render off for those runs. A review-on node fed by a List is refused before any of its runs starts, with use_case_review_list_driven. The SDK calls the same routes with client.edit.tightenEpisode, client.edit.estimateTightenEpisode and client.edit.continueUseCase, and the CLI with nodaro edit tighten-episode and nodaro edit continue-use-case. Over MCP, use tighten_episode and continue_use_case.
Trim a video
POST /v1/trim-video takes videoUrl and the range in the unit you prefer: startTime and endTime in seconds, trimStartFrames and trimEndFrames, trimStartSeconds and trimEndSeconds, or keepFirstSeconds or keepLastSeconds.
Burn in captions
POST /v1/add-captions takes videoUrl and the words from the first source it finds, in this order: captions (word-timed entries), transcript, text on the subtitle style, then automatic transcription of the speech.
| Field | What it does |
|---|---|
style | subtitle (a static block), or a kinetic style: word-highlight, karaoke, tiktok-words, word-pop or bouncy. |
text | On subtitle: the caption itself, burned as one static block for the whole video. On a kinetic style: only the fallback when transcription finds nothing. |
captions | Word-timed entries, { text, startMs, endMs }, one per word for the kinetic styles. A transcription's words fit as they are. |
auto_transcribe, transcribe_provider | Transcribe the speech when nothing else gives the words. A kinetic style needs word timings: incredibly-fast-whisper (the default) or elevenlabs-stt. |
look | outline (the default on kinetic styles) or clean (the default on subtitle). |
maxWordsPerLine | 1 to 20 words per line, on top of the width of the frame. 1 or 2 gives a punchy read. |
highlightColor, animate | Kinetic styles only; 400 on subtitle. |
position, positionY, fontSize, fontFamily, fontWeight, color, backgroundColor, strokeColor, strokeWidth, uppercase | The look of the text. |
segments | Give ranges of the video their own style, look and position. |
A plain-text subtitle with no styling is the cheap render. A kinetic style, any styling field, timed captions, automatic transcription or segments renders at the higher price and keeps the source frame rate. See Add Captions for every option and both prices.
Turn stills into video
POST /v1/still-to-videotakes{ imageUrl, audioUrl, motion?, intensity?, resolution?, aspectRatio?, fps?, fit?, padColor? }. The video lasts as long as the audio; there is no duration field.motionisnone(the default),zoom-in,zoom-out,pan-left,pan-rightorken-burns, at anintensityfrom 1 to 10. See Still to Video.POST /v1/slideshowtakesimageUrls(2 to 100), an optionalaudioUrl,imageDurations(seconds per image,nullfor automatic) orperImageDuration, atransitionand the same look fields. With audio, the slideshow lasts as long as the audio; pinned durations that do not add up are scaled, and the job output says so. Without audio, it lasts the number of images timesperImageDuration, in silence. See Slideshow.
Both render on the server with no AI model and cost 0 credits. resolution is 720p, 1080p or 4K, fps is 24 or 30, and fit: "contain" letterboxes with padColor instead of cropping.
Overlay images and videos on a video
POST /v1/video-overlay places 1 to 20 image or video layers over a video in one render. Each layer is { imageUrl | videoUrl, sound?, start, end?, preset?, corner?, anchor?, x?, y?, width?, height?, fit?, opacity?, animate?, zIndex? }, with exactly one of imageUrl and videoUrl. start and end are seconds; without end, the layer lasts until the video ends. preset is card, corner-badge or full-frame, and an explicit position or size overrides it. A layer with neither is a corner badge, bottom-right unless corner says otherwise. animate is on by default for an image layer and off for a video layer.
- Video layers. A request can carry at most 2 video layers. A video layer plays from its first frame at
start, and holds its last frame until its window ends. It is silent unless it hassound: { volume }, from 0 to 1. Its sound is then mixed with the base video's audio. With no sound on, the base audio is untouched. baseBoxplaces the base video on the frame:{ anchor, width, x?, y?, height?, fit? }. It needsoutputAspect, and the uncovered part of the frame showsbackgroundColor.layoutplaces the base video, the first video layer, or both:video-bottom,video-top,stack-whole,stack-filledorpip. Every layout exceptpipneedsoutputAspect. The two stacks andpipneed a video layer, and a stack takes nobaseBox. Otherwise the request answers400 validation_error.
outputAspect (16:9, 9:16, 1:1 or 4:5) changes the frame, with baseFit and backgroundColor. The job returns { videoUrl, thumbnailUrl, width, height, durationSec, warnings }. Each user may send 30 requests per minute. See Video Overlay.
Price. A run with image layers only costs 22 credits, whatever the number of layers. A run with a video layer is priced per started 5 seconds of the base video. For such a run, the server measures the base video first. It refuses these requests before any charge:
| Error | When |
|---|---|
400 video_overlay_too_long | The base video is over 60 seconds. |
400 invalid_media_duration | The length of the base video cannot be read. |
400 video_overlay_starts_after_end | Every layer, or the first video layer of a stack, starts after the base video ends. |
A run with image layers only, whose layers all start after the end, fails at the render and is refunded.
Cut or crop a stored file at once
POST /v1/media/process cuts or crops a file you stored and answers in the same request, for free. The body is { sourceUrl, type, trim?, crop?, format?, deleteSource? }: type is video or audio, trim is { startTime, endTime }, crop is { x, y, width, height }, and format is mp4, webm, mp3, wav, m4a or aac. It returns { data: { url, thumbnailUrl, assetId, sizeBytes, mimeType, metadata } }. deleteSource: true deletes the source afterwards, when it is yours and nothing else uses it.
Edit audio
Each route returns a job.
| Method | Path | Body | What it does |
|---|---|---|---|
POST | /v1/audio-separation | { audioUrl, mode?, quality? } | Split a track: vocal_instrumental (the default) or full stems, at auto, fast or best quality. See Audio Separation. |
POST | /v1/audio-isolation | { audioUrl } | Keep the main voice and remove the background. See Voice Extractor. |
POST | /v1/audio-fx | { audioUrl, preset?, mix?, delayMs?, decay?, eqLow?, eqHigh? } | A reverb, echo, telephone or megaphone effect. See Audio FX. |
POST | /v1/mix-audio | { audioUrls, trackVolumes?, duck?, endAt? } | Layer 2 to 20 tracks, each at 0 to 200 percent. duck is { under, amount?, thresholdDb?, ratio?, attackMs?, releaseMs? }: every track except audioUrls[under] dips while that track is loud. The price is the same with or without duck. endAt is "longest" (the default) or "voice", which ends the mix when the duck.under track ends and needs duck. See Mix Audio. |
POST | /v1/adjust-volume | { audioUrl or videoUrl, volume?, normalize?, fadeIn?, fadeOut? } | Change the level, normalize, or fade. See Adjust Volume. |
POST | /v1/pitch-tempo | { audioUrl or videoUrl, semitones?, tempo?, preserveFormants? } | Shift the pitch by -12 to 12 semitones, or change the tempo from 0.5 to 2 without changing the pitch. A video keeps its picture, and its tempo stays at 1. 22 credits per started minute of the input, up to 30 minutes. See Pitch & Tempo. |
POST | /v1/combine-audio | { segments: [{ url, startTime?, endTime? }] } | Join segments end to end. See Combine Audio. |
POST | /v1/trim-audio | { audioUrl or videoUrl, startTime?, endTime?, audioFormat? } | Cut audio, or take it out of a video, as mp3 (the default), wav or aac. See Trim Audio. |
POST | /v1/silence-detect | { audioUrl, thresholdDb?, minSilenceMs?, padMs? } | Find the silent ranges of a recording. 11 credits. See Silence Detect. |
POST | /v1/audio-sync | { sources: [{ id, url }], reference? } | Measure the offsets between 2 to 6 recordings of one conversation. See Audio Sync. |
POST | /v1/speaker-map | { sources: [{ id, url, kind? }], transcript, offsets?, masterSourceId?, wideSourceId?, marginDb? } | Find which camera each speaker is on, from the cameras' own microphones. A flat price per run. See Speaker Map. |
speaker-map takes 2 to 6 sources with unique ids. kind: "audio" marks a sound-only recording, which is never anyone's camera, and neither are the master and the wide shot. The transcript needs speaker labels, and offsets is an audio-sync result. While a speaker talks, the camera that hears them loudest by at least marginDb (0 to 30, default 3) is theirs. Otherwise the speaker gets "", shown on the wide shot. A bad source list answers 400 validation_error, and a transcript without speakers 422 no_speakers, before anything is reserved. Its output_data.json is { version, speakerMap, confidence, notes, edl? }. Send speakerMap to POST /v1/camera-switch.
silence-detect takes an audio or video file. thresholdDb is -90 to 0 (default -35), minSilenceMs 1 to 600,000 (default 700) and padMs 0 to 60,000 (default 120). Its output_data.json is { version, ranges: [{ startMs, endMs }], durationMs }.
audio-sync costs 11 credits per source after the first: 11 for 2 sources, 33 for 4, 55 for 6. The ids must be unique, and reference must be one of them (the first source by default). Its output_data.json is { version, reference, offsets: [{ sourceId, offsetMs, confidence, driftMsPerHour }], notes }, where a time on the reference equals the time on the source plus offsetMs. Drift is measured and noted, never corrected.
Transcribe speech
POST /v1/transcribe turns speech into text and returns a job. The body is { audioUrl, provider?, language?, diarize?, tagAudioEvents?, wordTimestamps?, targetLanguage?, confirmationId? }, and audioUrl can also be a video. An audioUrl that links to a video's page is an import of that link: send the confirmationId confirmed for it. See Content rights.
provider | Word timings | Notes |
|---|---|---|
elevenlabs-stt | Always | The only engine that honors diarize (who spoke each word) and tagAudioEvents (laughter, applause). |
incredibly-fast-whisper | With wordTimestamps: true | Without the flag, the job succeeds, is charged, and returns phrases only. |
whisper | Never | Phrases only. wordTimestamps: true answers 400 validation_error before any credit is spent. |
nemotron-3-5-transcribe (Nemotron 3.5 Transcribe) | With wordTimestamps: true | Adds a speaker per word. The only engine that translates: see below. |
On Nemotron 3.5 Transcribe, targetLanguage (to or from English) returns a translation instead, with no word timings. On another engine, or beside wordTimestamps: true, it answers 400 validation_error. Nemotron is priced per started minute of the audio. The file is measured before anything is held, and the run is charged for the length the model decoded. A file whose length cannot be read holds the price of 60 minutes. It takes only an https link to a public host, up to 60 minutes and 200 MB. Anything else answers 400 before anything is charged: invalid_media_url, media_duration_exceeds_limit, media_size_exceeds_limit or media_page_link_unsupported. The output may carry truncated. See Translate the speech.
Omitting provider runs whisper, so name an engine whenever you need word timings. The finished job's output_data holds text, language, words (one { text, startMs, endMs, speaker? } per word, in milliseconds), json (the normalized transcript, also in milliseconds) and, on the older engines only, segments in seconds. words drops straight into captions on POST /v1/add-captions.
curl -X POST https://app.nodaro.ai/v1/transcribe \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "audioUrl": "https://cdn.nodaro.ai/uploads/talk.mp3", "provider": "elevenlabs-stt", "diarize": true }'const { jobId } = await client.audio.transcribe({
audioUrl: 'https://cdn.nodaro.ai/uploads/talk.mp3',
provider: 'elevenlabs-stt',
})
// once completed, burn the words in as kinetic captions
const words = (await client.jobs.get(jobId)).output_data.words
await client.media.addCaptions({
videoUrl: 'https://cdn.nodaro.ai/uploads/talk.mp4',
captions: words,
style: 'word-highlight',
autoTranscribe: false,
})nodaro audio transcribe --audio https://cdn.nodaro.ai/uploads/talk.mp3 --provider elevenlabs-stt --watch
nodaro jobs get <jobId> --json | jq '.output_data.words' > words.json
nodaro media add-captions https://cdn.nodaro.ai/uploads/talk.mp4 \
--captions-file words.json --style word-highlight --no-auto-transcribe --watchPublish to social networks
The connect flows open pop-up windows and are meant for the web app. Publishing works with a personal API token. OAuth app tokens cannot manage connections.
| Method | Path | What it does |
|---|---|---|
GET | /v1/social/providers | The supported networks and whether each is available on this deployment. |
GET | /v1/social/connections | Your connected accounts. |
DELETE | /v1/social/connections/:id | Disconnect an account. |
POST | /v1/social/telegram/connect | Connect Telegram with a bot token: { botToken }. |
POST | /v1/social/connect/custom | Connect a network that uses fields instead of a sign-in: { platform, fields }. |
POST | /v1/social/publish | Publish now. Returns a job. 11 credits. |
POST | /v1/social/scheduled-posts | Schedule a post. 11 credits, charged when it publishes. |
GET | /v1/social/scheduled-posts | Your scheduled posts, filtered by from, to and status. |
PATCH | /v1/social/scheduled-posts/:id | Edit a post while it is queued or draft. |
DELETE | /v1/social/scheduled-posts/:id | Cancel a queued post. Its history is kept. |
GET /v1/social/providers lists every network with { id, label, connectKind, editor, category, capabilities, available }. category is social (a feed you post to) or publishing (a site you publish articles on). A network the deployment has not set up is listed with available: false, never hidden. Networks that connect with fields (Bluesky, Dev.to, Hashnode, Medium, WordPress and Lemmy) describe their fields in customFields, and Nodaro checks the credential with the network before saving it.
POST /v1/social/publish takes { platform, action, connectionId?, caption?, mediaUrl or mediaItems, … }. Two failures need different handling:
503 publish_retryable: nothing was posted. Sending the identical request again is safe.500 publish_failed: the outcome is unknown. Sending again may post twice, so check the network first.
A scheduled post takes { connectionId, action, scheduledAt, caption?, media? }, where each media item is { type, r2Key or url }. The media must be files stored on this deployment, which Nodaro turns into fresh links when the post goes out; links to other sites are refused. Editing a post that is already publishing answers 409 not_editable. See Publish to Social and Publishing to social.
SDK and CLI equivalents
| Area | TypeScript SDK | CLI |
|---|---|---|
| Voices and voice changing | client.voices.list, searchLibrary, libraryFacets, listFavorites, favorite, unfavorite, listClones, deleteClone, change, recast, analyze, exportMix, design, remix, dub | nodaro voice list, favorites, favorite, unfavorite, changer, recast, analyze, export, design, remix, dub, clones |
| Media | client.media.downloadVideo, downloadVideoProgress, videoMetadata, trimVideo, trimAudio, addCaptions, stillToVideo, slideshow, videoOverlay, saveToStorage, process | nodaro media download, metadata, trim-video, trim-audio, add-captions, still-to-video, slideshow, video-overlay, save |
| Audio | client.audio.separate, isolate, applyFx, mix, adjustVolume, pitchTempo, combine, transcribe | nodaro audio separate, isolate, fx, mix, adjust-volume, pitch-tempo, combine, transcribe |
| Editing helpers | client.edit.silenceDetect, audioSync, speakerMap, editPlanCapabilities, and client.media.shotCuts | nodaro edit silence-detect, audio-sync, speaker-map, capabilities |
Over MCP, the same routes are tools such as voice_changer, voice_changer_pro, voice_changer_pro_analyze, voice_changer_pro_export, dubbing, transcribe, add_captions and separate_audio. See the MCP tools reference.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | validation_error | A field is missing or invalid, for example wordTimestamps on whisper, a style field that needs a kinetic caption style, or an all-muted export. |
400 | provider_not_configured | The deployment has not set up that social network. |
401 | unauthorized | The token is missing, invalid or revoked. |
402 | insufficient_credits | Nodaro Cloud only. The account cannot cover the job. |
404 | not_found | A Voice Changer Pro route was called on a deployment that does not serve it. |
409 | limit_reached | You already have 500 favorite voices. |
409 | not_editable | The scheduled post is already publishing. |
410 | voice_cloning_retired | Voice cloning is no longer offered. |
413 | — | The dubbed span is longer than 30 minutes. |
429 | too_many_downloads | 4 video imports are already running for your account. |
500 | publish_failed | The post's outcome is unknown. Check the network before you send it again. |
502 | preview_unavailable | The provider's preview file cannot be served. |
503 | publish_retryable | Nothing was posted. The identical request is safe to send again. |
503 | voice_library_warming | The deployment is reading the Voice Library for the first time. Retry after Retry-After seconds. |
Frequently asked questions
How do I change the voices of several speakers in one video?
Use POST /v1/voice-changer-pro with orderedVoices, one entry per detected speaker in order. A null entry keeps that speaker's own voice. To check the speakers first, run the analyze step, then recast to stems and export your own mix.
Can I clone a voice through the API?
No. Voice cloning is retired and the clone routes answer 410 voice_cloning_retired. Clones made before still work as voice ids. Design a new voice from a description with POST /v1/voice-design instead.
Which transcription engine gives word timings?
elevenlabs-stt always returns word timings, and incredibly-fast-whisper returns them when you send wordTimestamps true. whisper returns phrases only. Omitting provider runs whisper, so name an engine when you need words.
How long can a dubbed clip be?
The dubbed span can be up to 30 minutes, and it is priced per minute with a 1-minute minimum. For a longer source, dub a window with startTime and endTime.
Which media tools are free?
Still to Video, Slideshow and the synchronous cut-and-crop route POST /v1/media/process cost 0 credits. Video Overlay costs 22 credits per run with image layers only. With a video layer, it is priced per started 5 seconds of the base video. Silence Detect costs 11 credits.
Related
Voice Changer Pro
Add Captions
Transcribe
Jobs
Uploads
Last updated on
Studio productions
Read and edit Studio productions over REST: apply atomic operations, generate stills and clips, land finished jobs, plan the export and share or copy a film.
Content rights
Before Nodaro imports a video link, the person confirms a content rights declaration. Read it, confirm it per link, or authorize keys and agents once.