Two requests: generate, then download
Send the Persian script to POST /v1/speech from your server. The response contains a signed audio URL, its expiry, the file type and the generated duration. Download that URL without the API key and save the file with the correct extension. For Basic recordings that should survive a client disconnect, use a background speech job instead.
Before you start: access, a key and a short script
VowelMarks Developer access is manually approved. Apply in the developer portal, then create an approved test key with the speech scope. Website accounts and API access are separate. Test and live keys have separate allowances; the API plans page explains the available options.
Both examples below run on a server or your own computer. Use Python 3.10 or later, or Node.js 22 or later. Neither needs a third-party package. Keep the key in an environment variable or your deployment's secret store, never in browser code or a public repository.
The script for this example
کتاب تازه روی میز است. نرگس آن را برای کلاس میخواند.
An original lesson sentence: “The new book is on the table. Narges reads it for class.”
Download script.txt and save it as script.txt.
Set VOWELMARKS_API_KEY in your shell. Also set a stable request identifier:VOWELMARKS_REQUEST_ID=lesson-001-basic. This value is an idempotency key: for 24 hours, retrying the same request with the same key reuses its recorded outcome instead of starting a second generation. Use a new value when the text, voice or settings change.
Generate and save the recording in Python
Save this as persian-tts.py, or download the complete Python example. Run python3 persian-tts.py script.txt. The default request uses Kian, a Basic voice, at its normal pace.
"""Server-side VowelMarks example. Python 3.10+, no external packages."""
import json
import os
import sys
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def make_speech(text, request_id, premium=False):
api_key = os.environ["VOWELMARKS_API_KEY"]
base = os.environ.get("VOWELMARKS_API_BASE_URL", "https://api.vowelmarks.com")
payload = {"text": text, "tier": "basic", "voice": "kian", "pace": 1}
if premium:
payload = {
"text": text, "tier": "premium", "voice": "Kore", "pace": 1,
"style": "instructional", "expressiveness": "calm",
"director_notes": "Read clearly for an adult Persian lesson. Keep pauses short."
}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = Request(
base.rstrip("/") + "/v1/speech", data=body, method="POST",
headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
"Idempotency-Key": request_id,
},
)
try:
with urlopen(request, timeout=900) as response:
result = json.load(response)
except HTTPError as error:
detail = json.loads(error.read()).get("error", {})
raise RuntimeError(
f"HTTP {error.code}: {detail.get('code', 'api_error')}; "
f"retryable={detail.get('retryable', False)}; "
f"Retry-After={error.headers.get('Retry-After', 'not supplied')}"
) from None
extensions = {"audio/wav": ".wav", "audio/mpeg": ".mp3"}
extension = extensions.get(result["mime_type"])
if extension is None:
raise RuntimeError("Unexpected audio MIME type")
# The signed URL authorizes this GET. Do not attach the API key.
with urlopen(result["audio_url"], timeout=120) as response:
audio = response.read()
if not audio:
raise RuntimeError("The audio download was empty")
destination = Path("persian-lesson" + extension)
destination.write_bytes(audio)
print(f"Saved {destination}: {result['duration_seconds']:.2f} seconds")
return result
if __name__ == "__main__":
if len(sys.argv) not in (2, 3) or (len(sys.argv) == 3 and sys.argv[2] != "--premium"):
raise SystemExit("Usage: python3 persian-tts.py script.txt [--premium]")
# newline="" preserves the input file's line endings.
with open(sys.argv[1], encoding="utf-8", newline="") as source:
text = source.read()
make_speech(text, os.environ["VOWELMARKS_REQUEST_ID"], premium=len(sys.argv) == 3)
A successful run saves persian-lesson.wav or persian-lesson.mp3and prints the duration. Open the saved file and listen to both sentences. The API response is not the recording itself: the second HTTP request retrieves the audio bytes.
The same task in server-side JavaScript
Save this as persian-tts.mjs, or download the Node.js example. Run node persian-tts.mjs script.txt with the same environment variables. This is not a script to paste into your website's HTML.
// Server-side VowelMarks example. Node.js 22+, no external packages.
import { readFile, writeFile } from "node:fs/promises";
export async function makeSpeech(text, requestId, premium = false) {
const apiKey = process.env.VOWELMARKS_API_KEY;
if (!apiKey) throw new Error("Set VOWELMARKS_API_KEY on your server");
const base = process.env.VOWELMARKS_API_BASE_URL ?? "https://api.vowelmarks.com";
const payload = premium ? {
text, tier: "premium", voice: "Kore", pace: 1,
style: "instructional", expressiveness: "calm",
director_notes: "Read clearly for an adult Persian lesson. Keep pauses short."
} : { text, tier: "basic", voice: "kian", pace: 1 };
const response = await fetch(base.replace(/\/$/u, "") + "/v1/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": requestId
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(900_000)
});
const result = await response.json();
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${result.error?.code ?? "api_error"}; ` +
`retryable=${result.error?.retryable === true}; ` +
`Retry-After=${response.headers.get("Retry-After") ?? "not supplied"}`
);
}
const extension = { "audio/wav": ".wav", "audio/mpeg": ".mp3" }[result.mime_type];
if (!extension) throw new Error("Unexpected audio MIME type");
// No Authorization header: the signed URL authorizes this GET.
const audioResponse = await fetch(result.audio_url, {
signal: AbortSignal.timeout(120_000)
});
if (!audioResponse.ok) throw new Error(`Audio download: HTTP ${audioResponse.status}`);
const audio = Buffer.from(await audioResponse.arrayBuffer());
if (!audio.length) throw new Error("The audio download was empty");
const destination = "persian-lesson" + extension;
await writeFile(destination, audio);
console.log(`Saved ${destination}: ${result.duration_seconds.toFixed(2)} seconds`);
return result;
}
if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file:").href) {
const [, , filename, flag] = process.argv;
if (!filename || (flag && flag !== "--premium")) {
throw new Error("Usage: node persian-tts.mjs script.txt [--premium]");
}
const requestId = process.env.VOWELMARKS_REQUEST_ID;
if (!requestId) throw new Error("Set a stable VOWELMARKS_REQUEST_ID for this request");
await makeSpeech(await readFile(filename, "utf8"), requestId, flag === "--premium");
}
In a web app, your browser calls your own server, and that server calls VowelMarks. This lets you decide who can generate audio and how much they can use while keeping the API key private. A browser security error is not a reason to expose that key.
Try Premium with a direction that fits the lesson
Run either example with --premium, using a new request key such aslesson-001-premium. It selects Kore with an instructional style and calm expression. The director's notes ask for clear delivery and short pauses. Premium uses your organization's Premium speech allowance, separate from Basic.
The prompt describes the delivery, not replacement narration. Keep the words to be spoken intext; keep instructions in director_notes. Basic does not accept Premium style or director-note fields. For more useful directions than “sound natural,” see the Persian voice-direction guide.
Use a background job for longer Basic recordings
For a longer Basic recording, create a job with POST /v1/speech/jobs instead of holding the generation request open. The service returns 202 Acceptedwith a job ID and a status_url. Store that ID with your own lesson record.
curl https://api.vowelmarks.com/v1/speech/jobs \
-H "Authorization: Bearer $VOWELMARKS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lesson-001-basic-job" \
--data '{"text":"کتاب تازه روی میز است.","tier":"basic","voice":"kian","pace":1}'- Keep the returned job ID. Do not create another job because the user reloaded the page.
- Read
status_urlwith the same server-side Bearer key. Space out polling and honor rate-limit responses. - Wait for
succeeded. A queued or running job is not a downloadable recording. - Download
audio_urlwithout the key. Useresult.mime_typeto choose the extension. - For
failedorcancelled, stop and handle that status rather than waiting forever.
A closed connection is not job cancellation. The job reference explains explicit cancellation, retention and refreshed download URLs. Use the synchronous endpoint for Premium; this Basic job flow does not promise Premium background generation or a public captions API.
Retry a request without generating the recording twice
The examples send one generation request and surface API errors rather than retrying indefinitely. If a request times out after being sent, its outcome is unknown. Retry the exact body and endpoint with the original key. Creating a new key can start a new generation. The same key with changed text or settings produces an idempotency conflict.
- Authentication or permission error: fix the key or its speech scope.
- Quota exhausted: check the relevant Basic or Premium allowance; repeating the request will not refill it.
- Retryable capacity or rate-limit error: honor
Retry-After, retry sequentially and stop after a small bounded number of attempts. - Audio download interrupted: retry the file GET while the signed URL is valid. Do not regenerate merely to fetch the same file.
Signed audio URLs last one hour. Keep them out of logs and analytics, and save completed audio in storage you control if the lesson should work next week. Requests allow up to 6,000 Unicode code points, subject to the 32 KiB JSON-body limit. Those are API limits, not a guarantee that a large narration will be quick or sound consistent.
Test the Persian, not just the status code
Before connecting a whole lesson library, listen to a few recordings. Include the names, numbers and vocabulary your app uses. Start with the reusable Persian pronunciation test, then add sentences from your actual lesson or product. For a separate marked-text display, follow the source-preserving diacritization tutorial.
Ready to connect your own app? Compare the Persian API options and apply for Developer access. For a single recording without code, use the Persian text-to-speech web tool.
Frequently asked questions
Can I generate Persian text to speech in Python without an SDK?
Yes. The example uses Python's standard-library HTTP client to send a VowelMarks API request, read the JSON result and download the audio. You need approved API access and a key with speech permission.
Can I put my VowelMarks API key in a JavaScript website?
No. Run the JavaScript example in Node.js on your server. Your frontend should call your own backend, not carry a Developer API key.
Does the Persian TTS API return MP3 or WAV?
Check mime_type in the response. Basic can return audio/wav or audio/mpeg; Premium returns audio/wav. The web tool's export choices are a separate interface.
Can I queue Premium speech as a background API job?
Use POST /v1/speech for Premium. The public background-job workflow described here is for Basic speech.
Sources and review notes
Sources are listed for the claims they support. Original practice examples are identified in the article. Product capabilities and access terms were checked on the review date and can change.
- VowelMarks Speech API reference. Request fields, supported voices, Basic jobs, download formats and audio URL lifetime.
- VowelMarks authentication and errors. Server-side keys, safe retries, request limits and structured errors.
- VowelMarks OpenAPI v1. The machine-readable request and response contract used by these examples.
- Narakeet's Python TTS tutorial. An editorial reference for completing the request-to-audio task. The VowelMarks code and Persian script here are original; the APIs are not interchangeable.