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
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.
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.
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.
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.
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.
Explore Related Features
Discover more ways PostTTS can help you engage your audience
Build With Our API
Full REST API for custom integrations.
Learn moreSpeak Every Language
Reach a global audience with text-to-speech in 11 languages including Chinese, English, Japanese, Korean, German, French, Russian, Vietnamese, Portuguese, Spanish, and Italian.
Learn moreAlways Up to Date
PostTTS automatically detects content changes and regenerates audio to keep everything in sync.
Learn moreStart using the PostTTS API
Create a free account, grab your API key, and make your first request in under a minute.
Create Free AccountFree plan includes 10 posts/month · No credit card required