# Plotweaver Developer API

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

```json
[
  {
    "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.

```bash
curl https://api.plotweaver.app/v1/voices?language=ig \
  -H "X-API-Key: $PLOTWEAVER_API_KEY"
```

```json
[
  {
    "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 | |

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

```json
{
  "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) |

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

```python
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)
```

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

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

```bash
curl -X POST https://api.plotweaver.app/v1/transcriptions \
  -H "X-API-Key: $PLOTWEAVER_API_KEY" \
  -F file=@speech.wav -F language=yo
```

```json
{
  "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.

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

```json
{
  "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.

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

```json
{ "text": "How are you doing today?", "source_language": "en", "target_language": "ha" }
```

```json
{
  "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"`.

```json
{
  "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 |
