# Build with Speech Engine

> Create an authorised personal voice and generate private narration with a scoped API key.

> **Note: Check readiness first.** Speech Engine is a separately configured service. GET /v1/speech/capabilities returns actual readiness, English pilot support, limits, consent text and configured credit rates. If unavailable, enrollment and generation return 503 before a new charge. Existing stock TTS remains separate.

## Before you begin
Create a **live** key in [API keys](https://developer.vidmoat.com/developer/apps) and explicitly grant `speech.read`, `speech.voices.write` and `speech.generate`. Existing keys do not inherit these private-voice permissions. Never put a key in browser code, URLs or logs. Revoke it in the same key manager; subsequent requests will fail authentication.

Your account needs AI voiceovers and enough credits. Voice profiles are private to the owner by default, including when a team credit pool funds the job. The owner can separately approve a preview and explicitly publish a voice from the signed-in Speech Lab. Developer keys and agents cannot grant that publication consent. Developer-bot end users and test-key inference are not enabled in the initial pilot.

## Permission and recording
Show the exact consent text returned by capabilities and obtain explicit permission from the voice owner. Send consent=true and the current consentVersion. Consent records the account and time; it does not prove legal identity. Agents must send the person to Speech Lab to enroll rather than granting consent for them.

Use WAV, MP3, M4A or WebM, a sample rate of 16 kHz or higher mono/stereo, **10 to 120 seconds** and **25 MB maximum**. Aim for 20 to 30 seconds of one person speaking naturally without music. The worker checks decoding, clipping, detected speech and an estimated background-noise ratio. It prepares a reusable reference and an actual synthesis preview; it does not retrain a foundation model.

## End-to-end examples
Set VIDMOAT_KEY as a server-side environment variable. These examples check HTTP errors, enroll an authorised recording, poll its real job, generate speech, and download it. Replace my-voice.wav with a recording you are permitted to use. Never copy the example consent flag without the person’s permission.

```bash cURL
# Requires curl and jq. Never echo the key or signed audio URL.
BASE=https://api.vidmoat.com/v1/speech
curl --fail-with-body "$BASE/capabilities" -H "Authorization: Bearer $VIDMOAT_KEY"
curl --fail-with-body "$BASE/voices" -H "Authorization: Bearer $VIDMOAT_KEY"
CONSENT_VERSION=$(curl --fail-with-body -sS "$BASE/capabilities" -H "Authorization: Bearer $VIDMOAT_KEY" | jq -r '.consent.version')
ENROLL=$(curl --fail-with-body -sS "$BASE/voices" -H "Authorization: Bearer $VIDMOAT_KEY" -H "Idempotency-Key: $(uuidgen)" -F 'audio=@my-voice.wav' -F 'name=My voice' -F 'language=en' -F 'consent=true' -F "consentVersion=$CONSENT_VERSION")
VOICE_ID=$(printf '%s' "$ENROLL" | jq -r .voiceId)
wait_job() {
  for attempt in $(seq 1 400); do
    JOB=$(curl --fail-with-body -sS "$BASE/generations/$1" -H "Authorization: Bearer $VIDMOAT_KEY") || return 1
    STATE=$(printf '%s' "$JOB" | jq -r .status)
    case "$STATE" in succeeded) return 0;; failed|cancelled) printf '%s' "$JOB" | jq .error >&2; return 1;; esac
    sleep 5
  done
  echo 'Polling timed out; keep the job ID and resume polling later.' >&2; return 1
}
wait_job "$(printf '%s' "$ENROLL" | jq -r .id)" || exit 1
GEN=$(curl --fail-with-body -sS "$BASE/generations" -H "Authorization: Bearer $VIDMOAT_KEY" -H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" --data "$(jq -nc --arg voice "$VOICE_ID" '{voiceId:$voice,text:"Every story starts with a voice.",speed:1}')")
ID=$(printf '%s' "$GEN" | jq -r .id)
wait_job "$ID" || exit 1
URL=$(curl --fail-with-body -sS "$BASE/generations/$ID/audio" -H "Authorization: Bearer $VIDMOAT_KEY" | jq -r .url)
curl --fail-with-body "$URL" -o narration.wav
```

```js JavaScript / TypeScript
// Node 20+. This runs on YOUR SERVER, never in a public browser bundle.
import { readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const base = 'https://api.vidmoat.com/v1/speech';
const authorization = 'Bearer ' + process.env.VIDMOAT_KEY;
async function request(path, init = {}) {
  const r = await fetch(base + path, { ...init, headers: { authorization, ...init.headers } });
  const data = await r.json();
  if (!r.ok) throw Object.assign(new Error(data.error?.message || 'Request failed'), { status: r.status, code: data.error?.code, retryAfter: r.headers.get('Retry-After') || data.error?.retry_after });
  return data;
}
async function wait(id) {
  for (let i = 0; i < 400; i++) {
    const job = await request('/generations/' + id);
    if (job.status === 'succeeded') return job;
    if (['failed', 'cancelled'].includes(job.status)) throw new Error(job.error?.message || job.status);
    await new Promise(resolve => setTimeout(resolve, 5000));
  }
  throw new Error('Keep the job ID and resume polling; do not resubmit.');
}
const capabilities = await request('/capabilities');
if (!capabilities.available) throw new Error('Speech Engine is unavailable');
const voices = await request('/voices');
// First display capabilities.consent.text and obtain the person's permission.
const form = new FormData();
form.set('audio', new Blob([await readFile('my-voice.wav')]), 'my-voice.wav');
form.set('name', 'My voice'); form.set('language', 'en');
form.set('consent', 'true'); form.set('consentVersion', capabilities.consent.version);
const enrollment = await request('/voices', { method: 'POST', headers: { 'Idempotency-Key': randomUUID() }, body: form });
await wait(enrollment.id);
// Persist this key with the inputs BEFORE making the request, for safe retries.
const idempotencyKey = randomUUID();
const job = await request('/generations', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ voiceId: enrollment.voiceId, text: 'Every story starts with a voice.', speed: 1 }) });
await wait(job.id);
const audio = await request('/generations/' + job.id + '/audio');
const download = await fetch(audio.url);
if (!download.ok) throw new Error('Download failed; request a new link');
await writeFile('narration.wav', Buffer.from(await download.arrayBuffer()));
```

```python Python
# pip install requests. Keep the key server-side.
import os, time, uuid, requests
base = 'https://api.vidmoat.com/v1/speech'
session = requests.Session()
session.headers['Authorization'] = 'Bearer ' + os.environ['VIDMOAT_KEY']
def request(method, path, **kwargs):
    r = session.request(method, base + path, timeout=120, **kwargs)
    data = r.json()
    if not r.ok:
        raise RuntimeError({'status': r.status_code, 'error': data.get('error'), 'retry_after': r.headers.get('Retry-After')})
    return data
def wait(job_id):
    for _ in range(400):
        job = request('GET', '/generations/' + job_id)
        if job['status'] == 'succeeded': return job
        if job['status'] in ('failed', 'cancelled'): raise RuntimeError(job.get('error'))
        time.sleep(5)
    raise TimeoutError('Keep the job ID and resume polling; do not resubmit.')
cap = request('GET', '/capabilities')
if not cap['available']: raise RuntimeError('Speech Engine is unavailable')
voices = request('GET', '/voices')
# Display cap['consent']['text'] and obtain explicit permission first.
with open('my-voice.wav', 'rb') as audio:
    enrollment = request('POST', '/voices', headers={'Idempotency-Key': str(uuid.uuid4())}, files={'audio': ('my-voice.wav', audio, 'audio/wav')}, data={'name': 'My voice', 'language': 'en', 'consent': 'true', 'consentVersion': cap['consent']['version']})
wait(enrollment['id'])
request_id = str(uuid.uuid4())  # persist with inputs before sending; reuse on retry
job = request('POST', '/generations', headers={'Idempotency-Key': request_id}, json={'voiceId': enrollment['voiceId'], 'text': 'Every story starts with a voice.', 'speed': 1})
wait(job['id'])
audio = request('GET', '/generations/' + job['id'] + '/audio')
# Separate request: never forward the API key to an audio storage host.
download = requests.get(audio['url'], timeout=60)
download.raise_for_status()
with open('narration.wav', 'wb') as output: output.write(download.content)
```

## Jobs, credits and recovery
POSTs return **202** with a durable job ID. Keep both that ID and your Idempotency-Key. Poll every five seconds. A transport timeout is not a model failure: query/retry the same request key; a different key buys a different job. A failed/cancelled job is terminal and refunds its reserved amount exactly once.

Generation uses configured credits per **started 1,000 Unicode characters**, measured after the complete script is validated. Enrollment has its own configured rate. GET capabilities shows both; there is no hardcoded public price. Six submissions/minute, 50/day, two active jobs per account; a shared bounded worker queue also applies. Redis failure refuses new spending. History and [Usage](https://developer.vidmoat.com/developer/usage) show credits, failures and successful audio duration.

| HTTP / code | Recovery |
| --- | --- |
| 401 unauthorized | Check or replace the key; a revoked key cannot be reused. |
| 403 insufficient_scope | Grant the named scope on the existing key/app. |
| 402 plan_required / insufficient_credits / quota_exceeded | Check plan, credits and active job count; do not loop retries. |
| 409 conflict | Check whether the key was reused for different inputs or the job already started. |
| 413 payload_too_large | Shorten the script/recording. |
| 422 prompt_rejected | Revise the refused input; nothing was queued. |
| 429 rate_limited | Honour Retry-After, or error.retry_after. |
| 503 unavailable | Back off; preserve the request key. Check capabilities before new spending. |

## Private files and editor use
Download URLs expire after **900 seconds** and must not be logged or published. Get a fresh URL through the authenticated API. Deletion immediately revokes previous links; file cleanup is retried. Deleting a voice also deletes its generated audio, so projects using it will need replacement audio.

To place speech in an edit, call **POST /v1/speech/generations/{id}/import** with projectId, requiring speech.read and projects.write. Then use the existing project command API to add an audio clip with the returned URL and measured result.duration. This shares only the generated audio with that project. Vidmoat renews its private source on playback/export; the voice reference remains private.

## Shared voices and distribution
GET /v1/speech/voices?source=community lists up to 20 published voices. Optional q searches the public title, creator and description; follow nextCursor with cursor. Published voices can be used by passing their id to the normal generation endpoint. The generated job and credit charge belong to the caller; recordings and reusable references are never exposed.

Owners publish or withdraw from [Speech Lab](https://www.vidmoat.com/dashboard/speech), after approving their enrollment preview and giving separate publication permission. A public /voices/{id} page provides attribution and discovery. GET /v1/speech/voices/{id}/usage requires speech.read and ownership, and returns generations, creators and ownGenerations as aggregate counts of completed audio jobs. It does not expose other users or their scripts. Sharing is permission to use a voice, not a verified identity or royalty programme. Withdrawal stops new jobs and preview access; accepted jobs and existing audio may remain until the voice is deleted.

## Review a narration draft
POST /v1/speech/directions accepts voiceId, brief (up to 2,000 characters), optional currentScript, speed and supported expression, with speech.generate and an Idempotency-Key. It reserves capabilities.pricing.direction credits. Poll /v1/speech/generations/{id}; a succeeded direction job returns result.reply, result.script and result.speed, with applied=false and audioGenerated=false. Present that draft for review, then separately submit the chosen text to /generations. This never generates audio or publishes a voice automatically. Invalid/failed drafts refund the reserved credits.

## Agent workflow
MCP and web/native/channel agents use agent_workspace: **speech_voices → speech_generate → speech_status → speech_prepare**. speech_generate requires speechVoiceId, speechText and a stable requestId; speechSpeed/speechLanguage are optional, and speechExpression is optional when capabilities.settings.expression is present. speech_prepare returns an addClip command, not a claim that an edit already happened. Apply it through the existing host and verify the audio. Programmatic credentials still need explicit speech scopes; preparing an edit also needs projects.write.

## Limits and quality
The English pilot uses a separately validated Chatterbox English 0.1.6 direct-reference runtime when enabled, with compatible OpenVoice V2/Melo jobs retained. Check capabilities.pipeline and settings before sending controls. New expressive output is mono 24 kHz PCM WAV and uses the model’s Perth watermark. Legacy output is mono 22.05 kHz. Pace is 0.75 to 1.25; expressive pace adjustment preserves pitch. Optional expression ranges from 0 to 1: subtle .25, balanced .5 and animated .75. These are delivery intensity controls, not guaranteed emotions. An unsupported expression input is rejected. This is reference conditioning, not dedicated per-user training. Cloning can still differ from the speaker, so review previews before publishing. Other upstream languages and Ghanaian accents/Pidgin require end-to-end evaluation before being advertised.

The full endpoint contracts are under **API reference → Speech Engine**. For a job failure, retain its job ID and error code and contact [support](https://www.vidmoat.com/support). Do not send private recordings, scripts or API keys in logs.

---

Source: https://developer.vidmoat.com/developer/docs/speech-engine
Previous: [Test and live keys](https://developer.vidmoat.com/developer/docs/concepts/test-and-live-keys.md)
Next: [Webhooks](https://developer.vidmoat.com/developer/docs/webhooks.md)
All documentation: https://developer.vidmoat.com/llms-full.txt
