Text to speech, speech to text, translation and voice cloning for African languages.
This is the full guide. Signed in, the dashboard shows only the products enabled for your organisation.
- Base URL:
https://api.plotweaver.app/v1 - Interactive reference:
https://api.plotweaver.app/docs - This document as Markdown:
GET /v1/docs.md
Authentication
Create an API key in the dashboard (API Keys → Create key) and send it on every request:
X-API-Key: pw_live_xxxxxxxxxxxxxxxxxxxxxxxx
Keep keys on your server. Do not ship them in a mobile app or browser bundle; call Plotweaver from your backend instead.
| Status | Meaning |
|---|---|
401 |
Missing, invalid, revoked or expired key |
403 |
detail.code says why: password_change_required (set a new password in the dashboard), service_not_enabled (this product isn't enabled for your account), access_expired (your access period has ended) or account_suspended |
Services at a glance
| Service | Endpoint | Languages |
|---|---|---|
| Text to speech | POST /v1/speech |
73 (see GET /v1/languages) |
| Streaming text to speech | POST /v1/speech/stream |
yo, ha, ig, pcm, en |
| Voice cloning | POST /v1/voices, POST /v1/speech/clone |
73 |
| Speech to text | POST /v1/transcriptions |
yo, ha, ig, pcm, en |
| Real-time speech to text | WSS /v1/transcriptions/stream |
yo, ha, ig, pcm, en |
| Translation | POST /v1/translations |
any pair of yo, ha, ig, pcm, en |
Languages are addressed by code (yo) or name (Yoruba), case-insensitively.
TTS models
You normally do not choose a model; the API picks the right one. You can pin one with "model".
| Model | Used for | Default when |
|---|---|---|
storm-v1 |
Nigerian languages with Plotweaver's built-in voices. Highest quality, supports streaming. | Language is yo, ha, ig, pcm or en and no cloned voice is given |
storm-v2 |
Voice cloning in Nigerian languages | You use a cloned voice created in a Nigerian language |
storm-v3 |
73 African languages, preset voice or cloning. Also available for Nigerian languages if you prefer its sound. | Any other language |
Languages
GET /v1/languages
No authentication required. Optional ?service=tts|tts_clone|asr|translation.
[
{
"code": "yo",
"name": "Yoruba",
"services": ["tts", "tts_clone", "asr", "translation"],
"tts_models": ["storm-v1", "storm-v2", "storm-v3"],
"default_tts_model": "storm-v1",
"default_voice_id": "morenike"
},
{
"code": "sw",
"name": "Swahili",
"services": ["tts", "tts_clone"],
"tts_models": ["storm-v3"],
"default_tts_model": "storm-v3",
"default_voice_id": null
}
]
GET /v1/languages/{code} returns one entry.
Voices
GET /v1/voices
Built-in voices plus the voices you have cloned. Each built-in voice has a samples list with one playable clip per language it supports (and sample_url for the most relevant one), so you can listen before generating. Sample links are signed and expire after an hour; fetch the list again for fresh links.
Query: language (every voice that can speak it: native speakers first, then cross-lingual voices), type=builtin|cloned.
Cross-lingual voices. Every built-in voice can speak all five Nigerian languages (yo, ha, ig, pcm, en), and so can voices cloned with storm-v2. A voice speaking a language it was not recorded in is cross-lingual: the list marks it cross_lingual: true, and POST /v1/speech returns X-Cross-Lingual: true. Quality is best in a voice's native language; recommended: true marks the pairings we have checked by ear. STORM v3 has no built-in voices: use its default voice for the language, or a voice cloned with it.
Code-switching. Text that mixes languages ("Ẹ kú àárọ̀ everyone, the meeting starts at ten") is read in one voice. Set language to the main language of the text. The same applies to speech to text: set language to the language mostly spoken in the audio.
curl https://api.plotweaver.app/v1/voices?language=ig \
-H "X-API-Key: $PLOTWEAVER_API_KEY"
[
{
"voice_id": "adaeze",
"name": "Adaeze",
"type": "builtin",
"model": "storm-v1",
"gender": "female",
"native_languages": ["ig"],
"languages": ["ig", "pcm", "en"],
"is_native": true,
"sample_url": "https://…/samples/adaeze/ig.wav?X-Amz-Signature=…",
"samples": [
{ "language": "ig", "url": "https://…/samples/adaeze/ig.wav?…" },
{ "language": "pcm", "url": "https://…/samples/adaeze/pcm.wav?…" },
{ "language": "en", "url": "https://…/samples/adaeze/en.wav?…" }
]
}
]
Built-in voices
voice_id |
Gender | Native | Also approved for |
|---|---|---|---|
morenike |
female | Yoruba | : |
adewale |
male | Yoruba | Igbo |
amina |
female | Hausa | : |
adaeze |
female | Igbo | Nigerian Pidgin, Nigerian English |
yemi |
male | Nigerian English, Nigerian Pidgin | : |
john |
male | Nigerian English, Nigerian Pidgin | : |
POST /v1/voices: create a cloned voice
multipart/form-data
| Field | Required | Notes |
|---|---|---|
name |
yes | Your label |
language |
yes | Language spoken in the clip |
file |
yes | 4 to 8 s of clean speech, one speaker, no music (wav, mp3, flac, ogg, m4a; max 10 MB) |
reference_text |
for non-Nigerian languages | Exact transcript of the clip |
description |
no |
curl -X POST https://api.plotweaver.app/v1/voices \
-H "X-API-Key: $PLOTWEAVER_API_KEY" \
-F name="Narrator" -F language=yo -F file=@my_voice.wav
Returns 201 with id: use it as voice_id in POST /v1/speech. Voices in Nigerian languages are cloned with storm-v2 and can speak any of yo, ha, ig, pcm, en; others use storm-v3.
GET /v1/voices/{voice_id}, PATCH /v1/voices/{voice_id} (name, description), DELETE /v1/voices/{voice_id}.
GET /v1/voices/{voice_id}/audio returns {"url": "...", "expires_in": 3600}, a signed link to the clip you uploaded for a cloned voice.
Creating a voice is not charged; speech generated with it is billed as voice cloning.
Text to speech
POST /v1/speech
{
"text": "Ẹ kú àárọ̀, ẹ káàbọ̀ sí Plotweaver.",
"language": "yo",
"voice_id": "morenike",
"model": null,
"fast": false,
"normalize": true
}
| Field | Required | Notes |
|---|---|---|
text |
yes | Up to 1,000 characters after normalisation. Split longer text into sentences. |
language |
yes | Code or name |
voice_id |
no | Built-in id or your cloned voice id. Omitted: the language's default voice. |
model |
no | storm-v1, storm-v2, storm-v3 to pin an engine |
fast |
no | Skip quality retries for lower latency |
normalize |
no | Default true. Rewrites currencies, numbers, units, times and symbols into words before synthesis (e.g. ₦5.75 million → five point seven five million naira). |
Response: audio/wav bytes.
| Header | Meaning |
|---|---|
X-Request-ID |
Quote this when contacting support |
X-Model |
Engine used |
X-Voice |
Voice used |
X-Language |
Language code |
X-Characters |
Billable characters (after normalisation) |
curl -X POST https://api.plotweaver.app/v1/speech \
-H "X-API-Key: $PLOTWEAVER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Sannu, yaya kake?", "language": "ha"}' \
--output hello.wav
import requests
r = requests.post(
"https://api.plotweaver.app/v1/speech",
headers={"X-API-Key": API_KEY},
json={"text": "How far, wetin dey happen?", "language": "pcm", "voice_id": "yemi"},
timeout=120,
)
r.raise_for_status()
open("out.wav", "wb").write(r.content)
const res = await fetch("https://api.plotweaver.app/v1/speech", {
method: "POST",
headers: { "X-API-Key": process.env.PLOTWEAVER_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ text: "Habari yako?", language: "sw" }),
});
const audio = Buffer.from(await res.arrayBuffer());
Generation takes a few seconds to ~20 s depending on length. Use a client timeout of at least 120 s.
POST /v1/speech/stream
Same JSON body. storm-v1 and built-in voices only. Returns raw PCM as it is generated: signed 16-bit little-endian, mono, 24 kHz (X-Audio-Format: pcm_s16le, X-Sample-Rate: 24000). A streamed take cannot be retried mid-stream, so prefer POST /v1/speech when quality matters more than latency.
Billing: once audio has started, the whole text is billed, including when you close the stream early (for example when the caller interrupts). A stream that fails before any audio, or because of an error on our side, is not billed. For live calls, send one sentence per request so an interruption costs only that sentence.
curl -sN -X POST https://api.plotweaver.app/v1/speech/stream \
-H "X-API-Key: $PLOTWEAVER_API_KEY" -H "Content-Type: application/json" \
-d '{"text": "Bawo ni", "language": "yo"}' \
| ffplay -autoexit -nodisp -f s16le -ar 24000 -ac 1 -i pipe:0
POST /v1/speech/clone: one-off cloning
Clone from a clip for a single request without saving the voice. multipart/form-data: text, language, reference_audio (file), reference_text (required for non-Nigerian languages), optional model, fast, normalize. Returns audio/wav.
Speech to text
POST /v1/transcriptions
multipart/form-data: file (wav, mp3, m4a, ogg, flac…; max 25 MB), language (yo, ha, ig, pcm, en), and optionally timestamps (segment) and response_format (json, srt, vtt).
curl -X POST https://api.plotweaver.app/v1/transcriptions \
-H "X-API-Key: $PLOTWEAVER_API_KEY" \
-F file=@speech.wav -F language=yo
{
"request_id": "b0c7…",
"language": "yo",
"text": "Ẹ kú àárọ̀",
"audio_duration_s": 3.2,
"model": "storm-asr"
}
Long audio is chunked and merged automatically. Billed by the minute of audio, measured to the second.
Timestamps and subtitles. Add timestamps=segment to get the transcript in timed pieces. The audio is split at pauses with voice activity detection, each piece is transcribed, and each comes back with its start and end in seconds. Pieces follow natural pauses and run up to 15 seconds, the right size for subtitles and for cutting silence in an edit. Video files work too (mp4, mov, webm…). The price is the same.
curl -X POST https://api.plotweaver.app/v1/transcriptions \
-H "X-API-Key: $PLOTWEAVER_API_KEY" \
-F file=@interview.mp4 -F language=yo -F timestamps=segment
{
"request_id": "b0c7…",
"language": "yo",
"text": "Ẹ kú àárọ̀. Ẹ káàbọ̀ sí ètò wa.",
"audio_duration_s": 9.4,
"model": "storm-asr",
"segments": [
{ "start": 0.0, "end": 1.9, "text": "Ẹ kú àárọ̀." },
{ "start": 3.1, "end": 9.4, "text": "Ẹ káàbọ̀ sí ètò wa." }
]
}
For a subtitle file instead of JSON, send response_format=srt or response_format=vtt. Timed transcripts take up to 2 hours of audio.
WSS /v1/transcriptions/stream: real-time speech to text
For live calls: stream the caller's audio as they speak and get the transcript the moment they stop.
Connect to wss://api.plotweaver.app/v1/transcriptions/stream with your key in the X-API-Key header (or as api_key= in the query string, for clients that cannot set headers, such as browsers). The language is English unless you add ?language= with yo, ha, ig or pcm, for example wss://api.plotweaver.app/v1/transcriptions/stream?language=yo.
| Direction | Message | Meaning |
|---|---|---|
| You send | binary frames | raw audio: PCM signed 16-bit little-endian, mono, 16 kHz (32,000 bytes a second). Send small frames (20 to 100 ms) as they are captured. |
| You send | {"event": "end"} |
the speaker has stopped: finish the transcript |
| We send | {"event": "partial", "text": "..."} |
the transcript so far, while audio arrives |
| We send | {"event": "final", "text": "..."} |
the finished transcript |
| We send | {"event": "heartbeat"} |
the connection is alive |
| We send | {"event": "error", "status": 401, "detail": "..."} |
the request was refused (then the socket closes) |
Send only speech: detect the end of speech on your side (a voice activity detector) and send {"event": "end"} straight away rather than streaming silence.
import asyncio, json, os
from websockets.asyncio.client import connect
async def transcribe(pcm: bytes) -> str:
url = "wss://api.plotweaver.app/v1/transcriptions/stream" # English; add ?language=yo for Yoruba
async with connect(url, additional_headers={"X-API-Key": os.environ["PLOTWEAVER_API_KEY"]}) as ws:
for i in range(0, len(pcm), 3200): # 100 ms frames
await ws.send(pcm[i:i + 3200])
await ws.send(json.dumps({"event": "end"}))
async for message in ws:
event = json.loads(message)
if event["event"] == "final":
return event["text"]
if event["event"] == "error":
raise RuntimeError(event["detail"])
Billed per second of audio you send, however the connection ends. One connection carries up to 30 minutes of audio and closes after 30 seconds without any message from you. Refusals use the same status codes as the HTTP endpoints (401, 402, 403, 429), sent as an error event; the close code is 4000 plus the status.
Translation
POST /v1/translations
{ "text": "How are you doing today?", "source_language": "en", "target_language": "ha" }
{
"request_id": "4e7d…",
"source_language": "en",
"target_language": "ha",
"translated_text": "Yaya kake yau?",
"pivoted": false,
"pivot_intermediate_text": null,
"model": "storm-mt"
}
Up to 2,000 characters. Pairs without English (e.g. yo → ig) are translated through English; the response has pivoted: true and the intermediate English text. Spot-check pivoted pairs before production use.
Usage and billing
Every call is recorded against the API key that made it. You pay through Stripe by card, on a monthly plan you choose in the dashboard under Billing. The API works once a plan is active.
Plans
| Pay as you go | Starter | Growth | Enterprise | |
|---|---|---|---|---|
| Monthly fee | $0 | $99 | $499 | agreed with PlotWeaver |
| Usage credit included | $2 welcome credit, once | $99 every month | $499 every month | the full fee |
| Text to speech, per 1,000 characters | $0.06 | $0.05 | $0.04 | from $0.04 |
| Cloned-voice speech, per 1,000 characters | $0.10 | $0.085 | $0.07 | from $0.07 |
| Speech to text, per minute | $0.015 | $0.0125 | $0.01 | from $0.01 |
| Translation, per million characters | $15 | $12.50 | $10 | from $10 |
| Creating a cloned voice, once per voice | $2 | $2 | $2 | $2 |
| Requests per minute | 60 | 300 | 1,000 | 3,000 |
| Support | Priority, within 1 working day | Priority, same day | Dedicated contact | |
| Early access to new models and voices | Yes | Yes |
The whole monthly fee is usage credit for that month, spent at your plan's rates, and it doesn't roll over. New accounts get a one-time $2 welcome credit to try every service before choosing a plan; whatever is left moves into your first plan month. Usage beyond the credit is billed at your plan's rates.
The request limit counts every call to speech, transcription and translation across all of your keys. Over it, calls return 429 with detail.code rate_limit_exceeded and a Retry-After header.
Plans run on calendar months (UTC). Your first month's fee and credit are prorated from the day you start. On the 1st, Stripe charges the new month's fee together with the previous month's usage after credit. If unbilled usage reaches $50 during a month, it is charged straight away and taken off the month-end bill.
Upgrades take effect immediately, with the fee prorated, and new requests are charged at the new plan's rates. Downgrades and cancellation take effect at the end of the month. Change plan, update your card or download receipts from Billing > Manage billing.
If a payment fails, the API keeps working for 7 days while Stripe retries the card. After that, calls are refused with 402 until the invoice is paid.
detail.code |
Meaning |
|---|---|
billing_setup_required |
No active plan and no welcome credit left: choose a plan and add a card |
payment_overdue |
A payment failed and the grace period has ended: update your card |
GET /v1/billing/account
Your plan, payment status and this month's bill: usage by product, credit used and left, amounts already charged and what is still due. Sign in with your dashboard account (bearer token). GET /v1/billing/payments lists the invoices Stripe has charged, with links to each receipt.
Allowances and rates
Each product can have a monthly allowance and a rate:
| Product | Unit | Counted as |
|---|---|---|
| Text to speech | characters | characters after normalisation (X-Characters) |
| Voice cloning | characters | characters spoken in a cloned voice |
| Speech to text | minutes | audio length, measured to the second (real time: audio sent) |
| Translation | characters | characters of source text |
Allowances reset on the first day of each month (UTC). When a request would go past the allowance it is refused with 429 and detail.code: "usage_limit_reached"; the body says how much is used, the limit and when it resets. Contact PlotWeaver to raise the limit or add to this month's allowance. A trial with an end date stops with 403 and detail.code: "access_expired".
{
"detail": {
"code": "usage_limit_reached",
"product": "tts",
"unit": "characters",
"limit": 500000,
"used": 499650,
"requested": 900,
"resets_at": "2026-11-01T00:00:00+00:00"
}
}
GET /v1/billing
This month so far: for each product its allowance, usage, remaining, rate and running cost, plus the total. Sign in with your dashboard account (bearer token).
GET /v1/billing/invoices
Your invoices, newest first. The current month is a draft that updates as you use the API; past months are issued, then paid once PlotWeaver records your payment.
GET /v1/billing/invoices/{period} returns one month (period is YYYY-MM) and GET /v1/billing/invoices/{period}/pdf downloads it.
GET /v1/usage/summary
Query: start, end (dates, UTC, inclusive; default the last 30 days, maximum 366 days).
Returns totals (requests, failed requests, characters, audio seconds, voices cloned, cost, languages used) and breakdowns by_product, by_service, by_model, by_language, language_models (which model served each language), by_day and by_key (requests, usage and cost for each of your API keys, plus calls made from the dashboard).
GET /v1/usage/report.pdf (same query) downloads it as a PDF with your current rates.
Errors
Errors are JSON: {"detail": "...", "path": "...", "timestamp": "..."}.
| Status | Meaning | What to do |
|---|---|---|
400 |
Invalid input: unsupported language, voice not available for the language, text too long | Fix the request |
401 |
Bad API key | Check the key |
402 |
billing_setup_required or payment_overdue (see detail.code) |
Choose a plan or update your card under Billing |
403 |
access_expired, service_not_enabled or account_suspended (see detail.code) |
Contact PlotWeaver |
404 |
Voice not found | Check voice_id |
413 |
Upload too large | Shorten the audio |
422 |
Body failed validation | See detail |
429 |
rate_limit_exceeded: your plan's requests per minute are used up (see Retry-After). usage_limit_reached: this month's allowance is used up. |
Wait and retry, or upgrade for a higher limit |
502 |
The model engine failed | Retry once; contact support with X-Request-ID if it persists |
503 |
Engine busy or unreachable | Retry with backoff |