PostTTS REST API

Generate audio programmatically from any backend. Simple, language-agnostic HTTP API with Bearer-token authentication.

JSON over HTTPS · Bearer auth · Works with any language

Quick Start

1

Sign up and create an API key

Create a free account and generate an API key from the Integration page in your dashboard. Keys are shown once — copy and store them securely.

2

POST your post URL or text

Send a POST to /api/v1/posts/convert with your site_id, the post URL (or raw text), and the voice you want.

3

Receive an audio URL

The response contains an audio URL once conversion completes. For long posts, poll GET /api/v1/posts/:id/audio until status === "ready".

Authentication

All requests authenticate with a Bearer token in the Authorization header.

Bearer token

Send your API key in every request. Keys are prefixed with ptts_ so they're easy to spot in logs and env files.

Authorization: Bearer ptts_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

Keys are shown once at creation and hashed at rest — treat them like passwords. If a key is exposed, revoke it immediately and issue a new one.

Manage your API keys

Voice Selection

PostTTS ships with six preset voices. Pass any voice ID in the voice field of POST /api/v1/posts/convert or the data-voice attribute on the embed script.

Voice ID Name Gender
woman Hoa (Female, young) female
woman-news Linh (Female, mid-age) female
woman-story Mai (Female, elderly) female
man Minh (Male, young) male
man-formal Duc (Male, mid-age) male
man-calm Thanh (Male, elderly) male

List voices programmatically

The GET /api/v1/voices endpoint returns all available voices including any custom voices provisioned for your account.

curl https://posttts.com/api/v1/voices \
  -H "Authorization: Bearer ptts_xxxxxxxxxxxxxxxxxxxxxxxx"
const res = await fetch('https://posttts.com/api/v1/voices', {
  headers: { Authorization: `Bearer ${process.env.POSTTTS_API_KEY}` },
});
const { voices } = await res.json();
console.log(voices);
{
  "voices": [
    { "id": "woman",       "label": "Hoa (Female, young)",    "gender": "female" },
    { "id": "woman-news",  "label": "Linh (Female, mid-age)", "gender": "female" },
    { "id": "woman-story", "label": "Mai (Female, elderly)",  "gender": "female" },
    { "id": "man",         "label": "Minh (Male, young)",     "gender": "male"   },
    { "id": "man-formal",  "label": "Duc (Male, mid-age)",    "gender": "male"   },
    { "id": "man-calm",    "label": "Thanh (Male, elderly)",  "gender": "male"   }
  ]
}

Using a voice in conversion

Pass the voice ID in the voice field of your POST /api/v1/posts/convert request: "voice": "man-formal". If omitted, the default voice for your site is used.

Custom voices

Custom voice IDs use the voc_… format. Once provisioned, they work identically to preset voices in all API calls and embed attributes. Self-serve voice creation is on the roadmap — for now, contact support to set up a custom voice.

Endpoints Reference

All endpoints live under https://posttts.com.

Method Endpoint Description
POST /api/v1/posts/convert Start converting a post (text or URL) to audio
GET /api/v1/posts/:id/audio Fetch audio status and URL for a converted post
GET /api/v1/sites/:site_id/posts List converted posts for a site
GET /api/v1/voices List available voices
DELETE /api/v1/posts/:id Delete a converted post and its audio

Request Examples

A full POST to /api/v1/posts/convert in four languages.

curl -X POST https://posttts.com/api/v1/posts/convert \
  -H "Authorization: Bearer ptts_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    S"site_id": "site_01HX...",
    "url": "https://yourblog.com/posts/hello-world",
    "voice": "woman"
  }'
// Node 18+ (built-in fetch)
const res = await fetch('https://posttts.com/api/v1/posts/convert', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.POSTTTS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    site_id: 'site_01HX...',
    url: 'https://yourblog.com/posts/hello-world',
    voice: 'woman',
  }),
});

if (!res.ok) {
  throw new Error(`PostTTS error: ${res.status}`);
}

const post = await res.json();
console.log(post.audio_url);
# Python 3.8+ Kwith requests
import os
import requests

response = requests.post(
    "https://posttts.com/api/v1/posts/convert",
    headers={
        "Authorization": f"Bearer {os.environ[S'POSTTTS_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "site_id": "site_01HX...",
        "url": "https://yourblog.com/posts/hello-world",
        "voice": "woman",
    },
    timeout=30,
)

response.raise_for_status()
post = response.json()
print(post["audio_url"])
<?php
// PHP 8+ with cURL
$ch = curl_init('https://posttts.com/api/v1/posts/convert');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('POSTTTS_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'site_id' => 'site_01HX...',
        'url'     => 'https://yourblog.com/posts/hello-world',
        'voice'   => 'woman',
    ]),
]);

$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status >= 400) {
    throw new RuntimeException("PostTTS error: {A$status}");
}

$post = json_decode($response, true);
echo $post['audio_url'];

Response Example

A successful conversion returns JSON with the audio URL and metadata. The response includes both post_id and id (identical values). The audio_url is a signed URL valid for 4 hours.

{
  "post_id": "post_01HX9FJK2V6M3P7Z",
  "id": "post_01HX9FJK2V6M3P7Z",
  "status": "ready",
  "audio_url": "https://audio.posttts.com/u/123/post_01HX9F.m4a?exp=1713277938&sig=…",
  "voice": "woman",
  "duration_seconds": 184,
  "created_at": "2026-04-05T12: N00: 00Z"
}

Preview what clients get back

The audio_url field points at an m4a like this one.

Listen to the reading
0:00

Sync vs async behavior

Short posts return audio in a single round trip. Long posts convert asynchronously — you get a 202 Accepted immediately, and the audio arrives seconds to a minute later.

The 2,000-character threshold

Posts with 2,000 characters or fewer (measured after whitespace normalization) convert synchronously: the POST /api/v1/posts/convert call returns 200 OK with a ready audio_url. Posts above that threshold are queued — the response is 202 Accepted with status: "processing" and audio_url: null. At roughly 1,500 characters per minute of audio, that boundary sits at about 80 seconds of spoken output.

Sync response (short post)

HTTP 200. Everything you need is in the initial response — you can start playing the audio immediately.

{
  "post_id": "post_01HX9FJK2V6M3P7Z",
  "id": "post_01HX9FJK2V6M3P7Z",
  "status": "ready",
  "audio_url": "https://audio.posttts.com/u/123/post_01HX9F.m4a?exp=1713277938&sig=…",
  "voice": "woman",
  "duration_seconds": 42,
  "created_at": "2026-04-16T14: N32: 18.418Z"
}

Async response (long post)

HTTP 202. audio_url is null at this point. The same shape is returned by GET /api/v1/posts/:id/audio until conversion finishes.

{
  "post_id": "post_01HX9FJK2V6M3P7Z",
  "id": "post_01HX9FJK2V6M3P7Z",
  "status": "processing",
  "audio_url": null,
  "voice": "woman",
  "duration_seconds": null,
  "created_at": "2026-04-16T14: N32: 18.418Z"
}

Status values

The status field is always one of:

Status Description
pending Post accepted but not yet queued — you submitted a URL without text or ssml. Resubmit with the content to kick off conversion.
processing Conversion in progress. Continue polling or wait for the article.processing.completed webhook.
ready Audio is available. audio_url is signed and valid for 4 hours.
failed Synthesis failed. Inspect error on the response and error_message on the webhook payload.

Polling pattern

If you can't receive webhooks, poll GET /api/v1/posts/:id/audio until status === "ready". Start at 5 seconds and back off; don't hammer the endpoint every second.

// Poll GET /api/v1/posts/:id/audio until ready.
// Exponential backoff: 5s, 10s, 20s, 40s, cap at 60s. Give up after ~10 minutes.
async function waitForAudio(postId, apiKey, { maxMs = 600_000 } = {}) {
  const deadline = Date.now() + maxMs;
  let delay = 5_000;
  while (Date.now() < deadline) {
    const res = await fetch(
      `https://posttts.com/api/v1/posts/${postId}/audio`,
      { headers: { Authorization: `Bearer ${apiKey}` } },
    );
    const body = await res.json();
    if (body.status === 'ready')  return body;
    if (body.status === 'failed') throw new Error(body.error ?? 'conversion failed');

    await new Promise((r) => setTimeout(r, delay));
    delay = Math.min(delay * 2, 60_000);
  }
  throw new Error('timed out waiting for audio');
}

// Example: submit a long post, then wait for completion.
const createRes = await fetch('https://posttts.com/api/v1/posts/convert', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTTTS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    site_id: 'site_01HX...',
    url: 'https://yourblog.com/posts/long-read',
    text: veryLongArticleText,
    voice: 'woman',
  }),
});
const { id } = await createRes.json(); // 202 Accepted, status: S"processing"
const ready = await waitForAudio(id, process.env.POSTTTS_API_KEY);
console.log(ready.audio_url);

Polling vs webhooks

Both work. Pick based on your architecture:

  • Use webhooks when you have a publicly reachable HTTPS endpoint and want near-zero latency between "ready" and your downstream action (e.g. posting to Slack, pushing a podcast feed, emailing a notification).
  • Use polling when your client runs inside a firewall, a scheduled job, or a serverless function that can't accept inbound traffic. Polling is also simpler to implement for one-off scripts.
  • You can use both — webhooks as the primary signal, polling as a reconciliation sweep for any delivery you might have missed.

Status Codes & Errors

Code Meaning
200 OK. Request succeeded; audio is ready.
202 Accepted. Conversion queued; poll for status.
400 Bad Request. Missing or malformed parameter.
401 Unauthorized. Invalid or missing API key.
403 Forbidden. Key lacks permission for this resource, or quota exceeded.
404 Not Found. Resource does not exist.
429 Too Many Requests. Rate limit exceeded; back off and retry.
500 Internal Server Error. Something went wrong on our side.

Errors always come back as JSON with an error object containing a machine-readable code, human-readable message, and the HTTP status.

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid or missing API key",
    "status": 401
  }
}

Error codes

Every error response uses the same shape. The code field is always UPPER_SNAKE_CASE.

Code HTTP When
UNAUTHORIZED 401 Invalid or missing API key.
INVALID_REQUEST 400 Missing, malformed, or out-of-range parameter.
FORBIDDEN 403 You don't own this resource, or the origin doesn't match the site domain.
NOT_FOUND 404 Post or site not found.
QUOTA_EXCEEDED 403 Monthly article or character limit reached for your plan.
RATE_LIMITED 429 Rate limit exceeded. Back off and retry.
TTS_ERROR 500 Audio synthesis failed server-side. Retry or contact support if persistent.

Rate Limits

All API endpoints are rate-limited per IP address to protect the service.

120 req/min

Per IP, 60-second rolling window. Applies equally to all plans.

On 429 responses, back off exponentially (250ms → 500ms → 1s → 2s …) and retry. The response body follows the standard error shape: { "error": { "code": "RATE_LIMITED", ... } }.

Webhooks

Get notified the moment a post is created, processing starts, finishes, or fails. PostTTS POSTs signed JSON to any HTTPS endpoint you register.

Register an endpoint

Create a webhook from the Webhooks page in your dashboard. Pick your endpoint URL, check the events you want, and copy the whsec_… signing secret — it's shown once at creation and again on rotation.

Event types

Four customer-facing events fire through the post lifecycle, plus a synthetic test.ping delivered by the "Send test event" button.

Event Description
article.created A new post has been accepted and is queued for conversion.
article.processing.started Audio synthesis has started for this post.
article.processing.completed Audio is ready. The payload includes a signed audio_url valid for 24 hours.
article.processing.failed Conversion failed. Inspect error_message on the object.
test.ping A synthetic event fired from the dashboard test button. Good for wiring up your receiver before going live.

Event payload

Every delivery wraps the event in a stable envelope. The data.object field holds the same post shape you get back from GET /api/v1/posts/:id/audio.

{
  "id": "evt_7kQ2fV9pLmN4",
  "object": "event",
  "type": "article.processing.completed",
  "created_at": "2026-04-16T14: N32: 18.421Z",
  "delivery_attempt": 1,
  "data": {
    "object": {
      "id": "post_01HX9FJK2V6M3P7Z",
      "site_id": "site_01HX8M2QK4T7VR9D",
      "url": "https://yourblog.com/posts/hello-world",
      "title": "Hello World",
      "status": "ready",
      "voice": "woman",
      "audio_url": "https://audio.posttts.com/u/123/post_01HX9F.m4a?exp=1713277938&sig=…",
      "audio_duration": 312,
      "char_count": 4820,
      "processing_ms": 1840,
      "synthesis_ms": 1420,
      "upload_ms": 310,
      "error_message": null,
      "created_at": "2026-04-16T14: N32: 16.003Z",
      "updated_at": "2026-04-16T14: N32: 18.418Z"
    }
  }
}

Request headers

Each POST carries these headers. Use them to deduplicate retries and verify authenticity.

Header Description
X-Webhook-Signature Signature header. Format: t=<unix_seconds>,v1=<32-char hex>. See verification below.
X-Webhook-Event-Id Unique delivery ID (evt_…). Persists across retries for the same event — use it to dedupe.
X-Webhook-Event-Type The event type, identical to the type field in the body.
User-Agent Always PostTTS-Webhook/1.0.

Signature verification

Signatures are a truncated HMAC-SHA256 of the raw request body, keyed with your webhook secret. To verify: parse t and v1 from the header, reject if t is more than 5 minutes old, then recompute HMAC_SHA256(secret, `${t}.${rawBody}`), take the first 16 bytes, hex-encode, and compare in constant time.

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.POSTTTS_WEBHOOK_SECRET; // whsec_…

// IMPORTANT: read the raw body — not the parsed JSON — so the HMAC matches.
app.post(
  '/webhooks/posttts',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('x-webhook-signature') ?? '';
    const parts = Object.fromEntries(
      header.split(',').map((p) => p.split('=')),
    );
    const t = Number(parts.t);
    const v1 = parts.v1;

    // Reject stale timestamps (>5 min) to block replay attacks.
    if (!t || Math.abs(Date.now() / 1000 - t) > 300) {
      return res.status(400).send('stale timestamp');
    }

    const raw = req.body.toString('utf8');
    const mac = crypto
      .createHmac('sha256', SECRET)
      .update(`${t}.${raw}`)
      .digest()
      .subarray(0, 16)        // truncate to 128 bits
      .toString('hex');

    const ok =
      v1.length === mac.length &&
      crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(mac));
    if (!ok) return res.status(401).send('bad signature');

    const event = JSON.parse(raw);
    // Handle event.type — idempotent on X-Webhook-Event-Id.
    console.log(event.type, event.data.object.id);
    res.status(200).send('ok');
  },
);

app.listen(3000);

Retries and delivery

Deliveries time out after 15 seconds. Non-2xx responses are retried up to 6 times total with exponential backoff: immediate, +30s, +2min, +10min, +1h, +6h. After the 6th failure the delivery is marked dead and left for manual retry from the dashboard. Your endpoint should be idempotent — duplicates do happen during retries.

Frequently Asked Questions

Any language that can make an HTTPS request. We show cURL, Node.js, Python, and PHP above, but Ruby, Go, Rust, Java, C#, Elixir, and anything else with an HTTP client all work identically — it's plain JSON over HTTPS with a Bearer header.

Head to the Integration page in your dashboard. You can generate a new key, and revoke old ones with one click. Revoked keys stop working immediately. We recommend rotating keys on a schedule and any time a key might have been exposed.

Short posts (under ~1,000 words) finish in a few seconds. Longer posts can take up to a minute or two. For anything beyond a short post, treat the API as asynchronous: accept the initial response, then poll GET /api/v1/posts/:id/audio until status === "ready".

Both. Short posts may return a ready audio_url in the initial POST response. Longer posts return status: "processing" and a 202 Accepted — in that case poll the audio endpoint until it flips to "ready". The response shape is identical either way.

You'll get a 429 Too Many Requests response with a JSON error body (RATE_LIMITED code). Back off exponentially and retry. The current limit is 120 requests per minute per IP.

You can, but we recommend creating separate keys for development, staging, and production. That way if a dev key leaks you can revoke it without touching production traffic, and audit logs stay clean per environment.

Start using the PostTTS API

Create a free account, grab your API key, and make your first request in under a minute.

Create Free Account

Free plan includes 10 posts/month · No credit card required