API reference
Turn a short clip of a real person into an avatar, then generate videos of them saying any text. Base URL: https://pitchtwin.ai/api/v1
Quickstart
Three calls: create an avatar once, then generate as many videos as you need.
curl -X POST https://pitchtwin.ai/api/v1/avatars \
-H "Authorization: Bearer $PITCHTWIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Sarah — office", "video_url": "https://example.com/sarah.mp4" }'
→ 202 { "id": "av_3f9c1e7a2b4d6e80", "status": "processing", … }curl https://pitchtwin.ai/api/v1/avatars/av_3f9c1e7a2b4d6e80 -H "Authorization: Bearer $PITCHTWIN_KEY"
→ 200 { "id": "av_3f9c1e7a2b4d6e80", "status": "ready", … }curl -X POST https://pitchtwin.ai/api/v1/generations \
-H "Authorization: Bearer $PITCHTWIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"avatar_id": "av_3f9c1e7a2b4d6e80",
"text": "Hi Mark, saw Acme just raised. Congrats! Got a quick idea for you.",
"webhook_url": "https://your.app/hooks/pitchtwin"
}'
→ 202 { "id": "9b1e…", "mode": "tts", "status": "queued", "estimated_seconds": 5, … }curl -X POST https://pitchtwin.ai/api/v1/generations -H "Authorization: Bearer $PITCHTWIN_KEY" -H "Content-Type: application/json" -d '{
"avatar_id": "av_3f9c1e7a2b4d6e80",
"audio": {
"url": "https://your.cdn/line-42.mp3",
"alignment": { "characters": ["H","i"], "start_times": [0, 0.09], "end_times": [0.09, 0.18] }
}
}'
→ 202 { "id": "4c7a…", "mode": "audio", "status": "queued", "tokens_charged": 4, … }When the video is ready we POST it to your webhook_url. You can also poll GET /generations/:id.
Authentication
Create a key in Dashboard → API keys and send it as a bearer token. Keys are secret: call the API from your server, never from a browser or mobile app.
Authorization: Bearer pt_live_…
Uploads
Avatars accept a public https URL. If your file isn't public, upload it to us first and pass the returned ref instead.
curl -X POST https://pitchtwin.ai/api/v1/uploads -H "Authorization: Bearer $PITCHTWIN_KEY"
→ 201 {
"file_id": "0f2c…",
"ref": "upload:0f2c…",
"upload_url": "https://…",
"method": "PUT",
"max_bytes": 52428800,
"expires_in": 7200
}
curl -X PUT "<upload_url>" -H "Content-Type: video/mp4" --data-binary @sarah.mp4Avatars
An avatar is one person in one look: one source clip and one voice. Want a different outfit or background? Create another avatar. We normalize the clip (rotation, codecs, resolution) and keep a voice sample, which is used when you ask us to synthesize speech. Each avatar is tied to the engine version it was created with, so the output never changes silently.
| video_url | string | Required. https URL or upload:<file_id>. 10–120 s, one person facing the camera, talking. MP4/MOV, H.264 or HEVC. |
| voice_url | string | Optional. Separate voice sample (≥ 10 s, ideally 1–2 min of clean speech). By default we use the clip's own audio if it's at least 10 s. Only needed for text/script mode. |
| name | string | Optional. For your own reference. |
Returns 202 with status: "processing". It moves to ready or failed (see error).
{
"id": "av_3f9c1e7a2b4d6e80",
"object": "avatar",
"name": "Sarah — office",
"status": "ready", // processing | ready | failed
"error": null,
"width": 1080,
"height": 1920,
"duration_ms": 31040,
"has_voice": true, // false → only audio mode is available
"engine": "latentsync@fal",
"created_at": "2026-09-26T12:00:00Z",
"ready_at": "2026-09-26T12:01:10Z"
}Deleting an avatar removes its clip and voice sample. Videos you already generated are kept.
Generations
| avatar_id | string | Required. A ready avatar. |
| text | string | We synthesize the speech. See Text & script. |
| script | array | We synthesize the speech, with pauses. See Text & script. |
| audio | object | You bring the audio (+ optional timings). See Your own audio. |
| voice.speed | number | Text/script only. 0.7–1.2, default 1. |
| voice.language | string | Text/script only. ISO 639-1 code, e.g. "en", "es", "ru". Leave it out for the best quality: the language is detected from the text. Set it only if detection gets it wrong (uses a faster, slightly lighter voice model). |
| webhook_url | string | Optional. https URL we POST the result to. |
Send exactly one of text, script or audio.
Returns 202. Status goes queued → synthesizing → rendering → succeeded, or failed. Audio mode skips synthesizing. Rendering usually takes several times the video's length.
{
"id": "9b1e2c4a-…",
"object": "generation",
"avatar_id": "av_3f9c1e7a2b4d6e80",
"mode": "tts", // tts | audio
"status": "succeeded",
"video_url": "https://…/video.mp4", // signed, valid 24 h; fetch again for a fresh link
"audio_url": "https://…/audio.mp3", // the exact soundtrack of the video
"duration_ms": 5120,
"words": [ // word timings in seconds, e.g. for captions
{ "text": "Hi", "start": 0.0, "end": 0.18 },
{ "text": "Mark,", "start": 0.21, "end": 0.55 },
…
],
"tokens_charged": 8,
"error": null,
"input": { "text": "Hi Mark, …" },
"created_at": "…",
"finished_at": "…"
}Two modes
| text / script | 1.5 tokens / s | We synthesize the speech in the avatar's cloned voice, then lipsync. The avatar needs has_voice: true. |
| audio | 1 token / s | You send finished audio, e.g. from your own ElevenLabs account, a recording or another TTS. We lipsync it as is. |
Both modes return the same generation object, including words: word-level timings of the final soundtrack. In text/script mode they come from our TTS. In audio mode they come from the alignment you send; without it, words is empty.
Text & script
Send the words one of two ways.
Plain text: the natural choice for most messages.
{ "avatar_id": "av_…", "text": "Hi Mark, saw Acme just raised. Congrats!" }Script: an array of segments, each optionally followed by a pause. Use it when timing matters: a beat after the name, a pause before the call to action.
{
"avatar_id": "av_…",
"script": [
{ "text": "Hi Mark,", "pause_after_ms": 400 },
{ "text": "saw Acme just raised. Congrats!", "pause_after_ms": 800 },
{ "text": "I've got a quick idea for your onboarding." }
]
}| script[].text | string | Required. What to say in this segment. |
| script[].pause_after_ms | integer | Optional. Exact silence after the segment, 0–10000 ms. The person keeps moving naturally during the pause; only the mouth is re-synced. |
Segments between pauses are voiced together, and each part hears its neighbours, so intonation flows across pauses. Up to 200 segments, 50 pauses and 5,000 characters per generation.
Your own audio
Send finished speech and we'll lipsync it. Timings are optional. If you send them, we check them against the audio and return them as words.
{
"avatar_id": "av_…",
"audio": {
"url": "https://your.cdn/line-42.mp3", // or "upload:<file_id>"
"alignment": {
"characters": ["H", "i", " ", "M", "a", "r", "k"],
"start_times": [0.00, 0.09, 0.18, 0.21, 0.30, 0.38, 0.46],
"end_times": [0.09, 0.18, 0.21, 0.30, 0.38, 0.46, 0.55]
}
}
}| audio.url | string | Required. https URL or upload:<file_id>. MP3, WAV, M4A, OGG… up to 10 min. |
| audio.alignment.characters | string[] | Optional. One entry per character, spaces included. |
| audio.alignment.start_times | number[] | Seconds from the start of the audio. Same length as characters. |
| audio.alignment.end_times | number[] | Seconds. Same length as characters. |
ElevenLabs' /with-timestamps output works as is: we also accept character_start_times_seconds and character_end_times_seconds. You pay for the exact length of the audio, charged when the request is accepted.
Webhooks
If you pass webhook_url, we POST the finished generation to it. Respond with any 2xx. Delivery is attempted once; if you miss it, poll GET /generations/:id.
POST https://your.app/hooks/pitchtwin
PitchTwin-Signature: t=1790430000,v1=5f2b…
{ "type": "generation.succeeded", "data": { …generation object… } }
// or "generation.failed", with data.error set and tokens refundedVerify the signature with the webhook secret from Dashboard → API keys: HMAC-SHA256 of `${t}.${raw_body}`, hex-encoded.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min tolerance
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Tokens & balance
Tokens are charged per second of finished video: 1 token / s with your own audio, 1.5 tokens / s when we synthesize the speech. Audio mode is charged exactly up front. In text/script mode we hold an estimate and settle to the real length once the speech is ready. Failed generations are refunded in full.
→ 200 { "balance": 1840, "unit": "second_of_video" }Node.js example
The full flow for a product that already voices its users with ElevenLabs: create an avatar once, then send each line with its timestamps.
const PT = "https://pitchtwin.ai/api/v1";
const headers = { Authorization: `Bearer ${process.env.PITCHTWIN_KEY}`, "Content-Type": "application/json" };
async function pt(path: string, init: RequestInit = {}) {
const res = await fetch(PT + path, { ...init, headers });
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body;
}
// 1. once per user look
export async function createAvatar(videoUrl: string) {
let av = await pt("/avatars", { method: "POST", body: JSON.stringify({ video_url: videoUrl }) });
while (av.status === "processing") {
await new Promise((r) => setTimeout(r, 5000));
av = await pt(`/avatars/${av.id}`);
}
if (av.status !== "ready") throw new Error(av.error);
return av.id as string;
}
// 2. per line: pass ElevenLabs /with-timestamps output straight through
export async function renderLine(avatarId: string, audioUrl: string, elevenAlignment: object) {
return pt("/generations", {
method: "POST",
body: JSON.stringify({
avatar_id: avatarId,
audio: { url: audioUrl, alignment: elevenAlignment },
webhook_url: "https://your.app/hooks/pitchtwin",
}),
}); // → { id, status: "queued", … }; result arrives on your webhook
}Errors
Errors use standard HTTP status codes and a consistent body:
{ "error": { "code": "insufficient_tokens", "message": "This generation needs up to 14 tokens. Top up in the dashboard." } }| 400 | invalid_input | Send exactly one of text, script or audio. |
| 400 | invalid_text / invalid_script | Empty text, bad segment, or pause outside 0–10000 ms. |
| 400 | text_too_long / too_many_pauses | Over 5,000 characters or 50 pauses. |
| 400 | invalid_audio_url / unreadable_audio | Audio URL isn't https, can't be downloaded, or isn't decodable audio. |
| 400 | audio_too_long | Audio longer than 10 minutes. |
| 400 | invalid_alignment | Arrays differ in length, times go backwards, or run past the end of the audio. |
| 400 | invalid_speed / invalid_language | voice.speed outside 0.7–1.2, or language isn't an ISO 639-1 code. |
| 400 | invalid_video_url / invalid_webhook_url | Must be https (or upload:<id> for media). |
| 401 | invalid_api_key | Missing, wrong or revoked key. |
| 402 | insufficient_tokens | Top up in Billing. |
| 404 | not_found / avatar_not_found / upload_not_found | No such object on your account. |
| 409 | avatar_not_ready | Avatar is still processing or failed. |
| 409 | avatar_has_no_voice | Text/script mode needs a voice sample. Send audio instead, or recreate the avatar with voice_url. |
| 500 | internal_error | Our fault. Safe to retry. |
Every call is visible in Dashboard → Logs.
Limits
- Source clip: 10–120 s, up to 200 MB by URL or 50 MB by upload.
- Voice sample: at least 10 s of clean speech (1–2 min gives the best clone).
- Text/script: up to 5,000 characters, 200 segments, 50 pauses, 10 s per pause.
- Audio: up to 10 minutes.
- Video and audio links are signed and expire after 24 h. Fetch the generation again for a fresh link.
Changelog
| 2026-09-26 | audio mode | POST /generations accepts audio + optional character alignment (ElevenLabs format supported), billed at 1 token/s. |
| 2026-09-26 | words | Generations return word timings (words) and the exact soundtrack (audio_url). |
| 2026-09-26 | has_voice | Avatars no longer need a voice sample; has_voice tells you if text/script mode is available. |
| 2026-09-26 | pricing | Text/script mode is billed at 1.5 tokens/s. |
| 2026-09-26 | voice | voice.speed is now 0.7–1.2; voice.language takes ISO 639-1 codes ("en"). |
| 2026-09-26 | v1 | Avatars, uploads, generations (text / script with pauses), webhooks, balance. |