YepAPI

Command Palette

Search for a command to run...

We added 19 text-to-speech models. The hard part was billing them.

Nineteen TTS models now run through the same media queue as image and video. Here's why we bill on UTF-8 bytes instead of characters, and what broke.

YepAPI TeamEngineering & Product
4 min read

Nineteen text-to-speech models went live on YepAPI this week — Deepgram, Fish Audio, Microsoft, Google, Mistral, xAI, Qwen, MiniMax, hexgrad, Canopy Labs, Sesame, and Zyphra, all reachable with the same API key you already use for SERP data and image generation.

No new endpoint. TTS jobs go through /v1/media/queue, the same async queue that already handles image and video generation. If you've generated an image with us, you already know how to synthesise speech.

An illustrated flamingo DJ at a pair of turntables wearing headphones
Nineteen voices, one queue.

Calling it#

Submit a job, poll for the result. That's the whole interface.

TYPESCRIPT
const res = await fetch('https://api.yepapi.com/v1/media/queue', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.YEPAPI_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'mistralai/voxtral-mini-tts',
    prompt: 'Your text to speak goes here.',
    options: { voice: 'en_paul_neutral' },
  }),
});

const { data } = await res.json();
// data.jobId — poll /v1/media/status/:jobId

When the job completes, job.result.audio carries a mimeType and a base64 payload:

TYPESCRIPT
import { writeFileSync } from 'node:fs';

const status = await fetch(
  `https://api.yepapi.com/v1/media/status/${data.jobId}`,
  { headers: { 'x-api-key': process.env.YEPAPI_KEY } },
);
const { data: job } = await status.json();

if (job.status === 'completed') {
  writeFileSync('speech.mp3', Buffer.from(job.result.audio.base64, 'base64'));
}

Why we bill on bytes, not characters#

This is the part that took the longest, and it's the part nobody writes about.

Speech models are priced per character of input. Sounds simple. It isn't — because providers don't agree on what a character is.

Take the string café. Is that four characters or five? In UTF-8 it's five bytes, because é takes two. In UTF-16 — what JavaScript's .length gives you — it's four. An emoji like 🎧 is four bytes and two UTF-16 code units. A provider counting one way and a client counting the other produces an invoice neither side can reproduce.

We settled on UTF-8 bytes for three reasons:

  1. It's unambiguous. There is exactly one UTF-8 encoding of a given string.
  2. It's the wire format. Bytes are what actually crosses the network to the provider.
  3. It never under-bills. For pure ASCII it's identical to character count; for multibyte text it's conservative in the direction that doesn't leave us holding the cost.

If your text is English, bytes and characters are the same number. If it isn't, bytes are the honest one.

The second decision was where to compute it. Speech endpoints return raw audio — there's no usage envelope in the response the way there is for chat completions. We could have made a second call to reconcile the cost after the fact, but that adds latency to every job for an accounting detail. So the cost is computed locally from the input length before the job is dispatched, and the price is known before a single byte of audio exists.

The practical upshot: you can price a job client-side before you call us. Three details to mirror in your math: rates are published in dollars per 1,000 characters, charges round up to the next whole cent, and every job has a 1-cent minimum. Published rates are themselves rounded up slightly, so this estimate can run a cent high near a boundary — it never runs low.

TYPESCRIPT
const bytes = new TextEncoder().encode(text).length;
const rawCents = (bytes / 1000) * ratePerThousandUsd * 100;
const costCents = Math.max(1, Math.ceil(rawCents));

What broke#

Two things, and it's worth being specific about both.

ProviderSymptomStatus
Fish AudioIntermittent HTTP 520Transient — recovers on retry
hexgrad / KokoroHTTP 200 with no response bodyPersistent upstream issue

The Kokoro case is the interesting one. The provider returns a 200 OK and then never sends a body — the connection just sits there. From the client's perspective this is indistinguishable from a slow model, which is exactly why it's nasty: a naive implementation waits out its full timeout and then reports something vague about a decode failure.

That's what ours did on the first pass. The error surfaced transport-level detail that meant nothing to the person who just wanted an MP3.

We fixed it mid-rollout. A stalled provider now returns a specific error that says the upstream stalled, confirms no charge was applied, and suggests trying a different model. The failure mode didn't change — we can't fix someone else's server — but what you get told about it did.

Picking a model#

Nineteen options is too many to evaluate one by one, so here's the short version:

  • Cheapest usable output — the low end of the range runs around $0.0014 per 1,000 characters. Because of the 1-cent-per-job minimum, a short notification costs $0.01 on any model — the cheap rates start paying off on longer scripts.
  • Emotion control — Mistral's Voxtral tags voices by delivery (en_paul_neutral, en_paul_happy, en_paul_sad), so you pick the tone at request time instead of prompting for it.
  • Voice cloning — available on fish-audio/s2.1-pro only. Every other model will reject a cloning payload with a 400 rather than silently ignoring it.
  • PCM output — Google's Gemini TTS models are PCM-only. Ask for MP3 and you'll get a validation error up front, not a corrupt file later.

A few guards worth knowing about: input is capped at 50KB per job, and an empty prompt is rejected before it reaches the provider.

FAQ#

How is text-to-speech billed?#

By the length of your input text, measured in UTF-8 bytes, at the per-1,000-character rate published on each model's page — rounded up to the next whole cent, with a 1-cent minimum per job. For English text, bytes and characters are the same number, so you can estimate the cost client-side before submitting the job; published rates are rounded up slightly, so the estimate can only ever run a cent high, never low.

Do I need a different API key for speech models?#

No. Text-to-speech runs through /v1/media/queue with the same YepAPI key you use for every other endpoint.

What happens if a speech provider fails?#

You aren't charged for a failed job. If the provider stalls or returns an error, the job is marked failed and the error message tells you whether retrying is likely to help or whether you should switch models.

Which models support voice cloning?#

Only fish-audio/s2.1-pro. Sending a cloning payload to any other model returns a 400 rather than silently dropping the parameter.

Is there a limit on input length?#

Yes — 50KB per job. For longer scripts, split at natural sentence or paragraph boundaries and concatenate the resulting audio.

Topics

text-to-speechopenroutermedia-apibilling

Start vibe coding with one API key.

One API key. 100+ endpoints. Yep, that's it.