# 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.

Source: https://nodaro.ai/docs/developers/api/voice-and-media

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](https://nodaro.ai/docs/developers/api/authentication), and [Jobs](https://nodaro.ai/docs/developers/api/jobs#poll-many-jobs-at-once) for polling many jobs at once. To send local files, upload them first; see [Uploads](https://nodaro.ai/docs/developers/api/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`, `language`, `page`, `page_size` and more. |
| `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](https://nodaro.ai/docs/nodes/audio/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".

```ts

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 } : {}),
})
```

## 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](https://nodaro.ai/docs/nodes/audio/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](https://nodaro.ai/docs/nodes/audio/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**

```bash
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 }'
```

**TypeScript SDK**

```ts
const { jobId } = await client.voices.change({
videoUrl: 'https://cdn.nodaro.ai/uploads/talking.mp4',
voiceId: 'Aria',
})
```

**CLI**

```bash
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](https://nodaro.ai/docs/nodes/audio/voice-changer) for prices and tips.

## Recast several speakers

[Voice Changer Pro](https://nodaro.ai/docs/nodes/audio/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](https://nodaro.ai/docs/self-hosting/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. |

### 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? }`. `engine` is `sts` (the default, speech-to-speech) or `v3`, which performs the line again from the transcript with ElevenLabs v3 and takes a `stability` of 0, 0.5 or 1 only. `volumeMode` is `match` (the default), `normalize` or `manual`, with `volume` from 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](https://nodaro.ai/docs/nodes/audio/voice-changer-pro) for the prices.

### 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.

**curl**

```bash
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 }
}"
```

**TypeScript SDK**

```ts
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 },
})
```

**CLI**

```bash
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 --watch
```

In 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, such as a YouTube or TikTok page, that the dubbing model fetches itself). 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](https://nodaro.ai/docs/nodes/audio/dubbing).

## Import a video from a link

`POST /v1/download-video` imports a social video (YouTube, TikTok, Instagram, X or Facebook) 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. Send both or neither. |
| `requireAudio` | A result without sound fails by default, because it usually means a degraded source. Send `false` to accept a silent clip. |

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`.

**curl**

```bash
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"
```

**TypeScript SDK**

```ts
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)
}
```

**CLI**

```bash
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-metadata` with `{ 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-storage` with `{ 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](https://nodaro.ai/docs/nodes/video/trim-video) |
| `POST` | `/v1/add-captions` | Burn captions into a video. | [Add Captions](https://nodaro.ai/docs/nodes/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 layers over a video. | 20 credits |
| `POST` | `/v1/media/process` | Cut or crop a stored file, and answer at once. | Free |

### 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](https://nodaro.ai/docs/nodes/video/add-captions) for every option and both prices.

### Turn stills into video

- **`POST /v1/still-to-video`** takes `{ imageUrl, audioUrl, motion?, intensity?, resolution?, aspectRatio?, fps?, fit?, padColor? }`. The video lasts as long as the audio; there is no duration field. `motion` is `none` (the default), `zoom-in`, `zoom-out`, `pan-left`, `pan-right` or `ken-burns`, at an `intensity` from 1 to 10. See [Still to Video](https://nodaro.ai/docs/nodes/video/still-to-video).
- **`POST /v1/slideshow`** takes `imageUrls` (2 to 100), an optional `audioUrl`, `imageDurations` (seconds per image, `null` for automatic) or `perImageDuration`, a `transition` and 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 times `perImageDuration`, in silence. See [Slideshow](https://nodaro.ai/docs/nodes/video/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 on a video

`POST /v1/video-overlay` places 1 to 20 image layers over a video in one render and keeps the base audio untouched. Each layer is `{ imageUrl, start, end?, preset?, corner?, anchor?, x?, y?, width?, height?, fit?, opacity?, animate?, zIndex? }`. `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.

`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 }`. It costs 20 credits per run, whatever the number of layers, and each user may send 30 requests per minute. See [Video Overlay](https://nodaro.ai/docs/nodes/video/video-overlay).

### 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](https://nodaro.ai/docs/nodes/audio/audio-separation). |
| `POST` | `/v1/audio-isolation` | `{ audioUrl }` | Keep the main voice and remove the background. See [Voice Extractor](https://nodaro.ai/docs/nodes/audio/voice-extractor). |
| `POST` | `/v1/audio-fx` | `{ audioUrl, preset?, mix?, delayMs?, decay?, eqLow?, eqHigh? }` | A reverb, echo, telephone or megaphone effect. See [Audio FX](https://nodaro.ai/docs/nodes/audio/audio-fx). |
| `POST` | `/v1/mix-audio` | `{ audioUrls, trackVolumes? }` | Layer 2 to 20 tracks, each at 0 to 200 percent. See [Mix Audio](https://nodaro.ai/docs/nodes/audio/mix-audio). |
| `POST` | `/v1/adjust-volume` | `{ audioUrl or videoUrl, volume?, normalize?, fadeIn?, fadeOut? }` | Change the level, normalize, or fade. See [Adjust Volume](https://nodaro.ai/docs/nodes/audio/adjust-volume). |
| `POST` | `/v1/combine-audio` | `{ segments: [{ url, startTime?, endTime? }] }` | Join segments end to end. See [Combine Audio](https://nodaro.ai/docs/nodes/audio/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](https://nodaro.ai/docs/nodes/audio/trim-audio). |
| `POST` | `/v1/silence-detect` | `{ audioUrl, thresholdDb?, minSilenceMs?, padMs? }` | Find the silent ranges of a recording. 10 credits. See [Silence Detect](https://nodaro.ai/docs/nodes/audio/silence-detect). |
| `POST` | `/v1/audio-sync` | `{ sources: [{ id, url }], reference? }` | Measure the offsets between 2 to 6 recordings of one conversation. See [Audio Sync](https://nodaro.ai/docs/nodes/audio/audio-sync). |

`silence-detect` defaults to `thresholdDb: -35`, `minSilenceMs: 700` and `padMs: 120`, and accepts audio or video. Its `output_data.json` is `{ version, ranges: [{ startMs, endMs }], durationMs }`.

`audio-sync` costs 10 credits per source after the first: 10 for 2 sources, 30 for 4, 50 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? }`, and `audioUrl` can also be a video.

| `provider` | Word timings | Notes |
| --- | --- | --- |
| [`elevenlabs-stt`](https://nodaro.ai/docs/models/audio/elevenlabs-stt) | Always | The only engine that honors `diarize` (who spoke each word) and `tagAudioEvents` (laughter, applause). |
| [`incredibly-fast-whisper`](https://nodaro.ai/docs/models/audio/incredibly-fast-whisper) | With `wordTimestamps: true` | Without the flag, the job succeeds, is charged, and returns phrases only. |
| [`whisper`](https://nodaro.ai/docs/models/audio/whisper) | Never | Phrases only. `wordTimestamps: true` answers `400 validation_error` before any credit is spent. |

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**

```bash
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 }'
```

**TypeScript SDK**

```ts
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,
})
```

**CLI**

```bash
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 --watch
```

## Publish 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. 10 credits. |
| `POST` | `/v1/social/scheduled-posts` | Schedule a post. 1 credit, 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](https://nodaro.ai/docs/nodes/publish/publish-to-social) and [Publishing to social](https://nodaro.ai/docs/guides/publishing-to-social).

## SDK and CLI equivalents

| Area | TypeScript SDK | CLI |
| --- | --- | --- |
| Voices and voice changing | `client.voices.list`, `searchLibrary`, `listClones`, `deleteClone`, `change`, `recast`, `analyze`, `exportMix`, `design`, `remix`, `dub` | `nodaro voice list`, `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`, `combine`, `transcribe` | `nodaro audio separate`, `isolate`, `fx`, `mix`, `adjust-volume`, `combine`, `transcribe` |
| Editing helpers | `client.edit.silenceDetect`, `audioSync` | `nodaro edit silence-detect`, `audio-sync` |

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](https://nodaro.ai/docs/mcp/tools).

## 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` | `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. |
| `503` | `publish_retryable` | Nothing was posted. The identical request is safe to send again. |

## 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 20 credits per run, and Silence Detect 10 credits.
