Documentation

PlotWeaver API

Text to speech, voice cloning, speech to text and translation for African languages. Base URL https://api.plotweaver.app/v1

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