Build with Realtime TTS
Deliver job results to your storage
Have async and batch jobs upload their results to a bucket you own, on any cloud, with nothing retained by Inworld
Preview. This capability is a preview release and may be further refined before it is marked stable. See release stages for what that means.
By default an async or batch job keeps its results in Inworld storage for about 7 days and hands you signed URLs to download them. Results delivery turns that around: you give the job a URL to your own bucket, pre-signed for upload, and the job writes its results there as one zip archive. Your results only pass through Inworld on the way to your storage and are not retained, and the job is billed once the upload succeeds.
It works with any cloud whose storage issues pre-signed upload URLs — Amazon S3 and S3-compatible services, Google Cloud Storage and Azure Blob Storage — and needs no credentials of yours on Inworld's side. The URL is the whole authorization, and it is yours to mint, scope and expire.
If your workspace has Zero Data Retention enabled, async and batch jobs must use results delivery. A job that names no destination is refused at submit with FAILED_PRECONDITION, because results kept in Inworld storage would be retained content.
How it works
Mint a pre-signed upload URL
Using your own cloud credentials, pre-sign a PUT to the object you want the archive written to, for example tts/job-42.zip. The signature must stay valid for at least 12 hours for an async job and 48 hours for a batch, and S3 and Google Cloud Storage cap a signature at 7 days. See Minting a URL for each cloud.
Submit the job with outputConfig.resultsUri
Pass the URL as outputConfig.resultsUri, on the request for an async job or at the top level of a batch. Submit validates the URL, then writes an empty object to it as a probe, so a URL Inworld cannot write to is refused now rather than after synthesis. That empty object is the marker of an accepted job; the delivery overwrites it.
Poll the operation
GET /lro/v1alpha/{name}, exactly as for a hosted job. When done is true, a successful response carries resultsUri — your own URL without its signature — and resultsPath, the name of the results document inside the archive. There are no signed URLs and no expireTime, because nothing is left with Inworld to expire.
# RESULTS_URI holds a URL you pre-signed for PUT (see "Minting a URL").
# It carries a signature, so keep it out of logs and shell history.
# 1. Submit
OPERATION=$(curl -s 'https://api.inworld.ai/tts/v1/voice:synthesizeAsync' \
--header "Authorization: Basic $INWORLD_API_KEY" \
--header 'Content-Type: application/json' \
--data "$(jq -n --arg uri "$RESULTS_URI" '{
text: "Hello, world! What a wonderful day to be a text-to-speech model!",
voiceId: "Dennis",
modelId: "inworld-tts-2",
audioConfig: { audioEncoding: "MP3" },
timestampType: "WORD",
outputConfig: { resultsUri: $uri }
}')" | jq -r '.name')
# 2. Poll until done: true
until curl -s "https://api.inworld.ai/lro/v1alpha/$OPERATION" \
--header "Authorization: Basic $INWORLD_API_KEY" \
| tee operation.json | jq -e '.done == true' > /dev/null; do
sleep 5
done
# 3. The archive is in your bucket. resultsUri names it without the signature.
jq '.response' operation.json
# {
# "resultsUri": "https://my-bucket.s3.us-east-2.amazonaws.com/tts/job-42.zip",
# "resultsPath": "results.json"
# }import json
import os
import time
import zipfile
import requests
BASE = "https://api.inworld.ai"
HEADERS = {"Authorization": "Basic <api-key>"}
# A URL you pre-signed for PUT (see "Minting a URL"). It carries a signature,
# so keep it out of logs.
results_uri = os.environ["RESULTS_URI"]
# 1. Submit
operation = requests.post(
f"{BASE}/tts/v1/voice:synthesizeAsync",
headers=HEADERS,
json={
"text": "Hello, world! What a wonderful day to be a text-to-speech model!",
"voiceId": "Dennis",
"modelId": "inworld-tts-2",
"audioConfig": {"audioEncoding": "MP3"},
"timestampType": "WORD",
"outputConfig": {"resultsUri": results_uri},
},
).json()
# 2. Poll
while not operation.get("done"):
time.sleep(5)
operation = requests.get(
f"{BASE}/lro/v1alpha/{operation['name']}", headers=HEADERS
).json()
if "error" in operation:
raise RuntimeError(f"Job failed: {operation['error']['message']}")
# 3. The archive is in your bucket at operation["response"]["resultsUri"].
# Fetch it with your own storage SDK or CLI, then read it:
archive = zipfile.ZipFile("job-42.zip")
results = json.loads(archive.read(operation["response"]["resultsPath"]))
audio = archive.read(results["audioPath"])What the archive contains
resultsPath is always results.json today; read it from the response rather than assuming the name, so the layout can gain a version without breaking your integration. Every path in the document is relative to the document itself, so it resolves against the same archive.
An async job's archive:
results.json
audio.mp3 # or the extension of the encoding you requested
timestamps.json # only when timestampType was requestedwith results.json, the results document, holding the artifact paths:
{ "audioPath": "audio.mp3", "timestampsPath": "timestamps.json" }A batch's archive holds one entry per successful item, addressed by the item's position in the request:
results.json
items/0.mp3
items/0.timestamps.json
items/1.mp3
...and results.json is the same results file a hosted batch produces, with paths in place of signed URLs. Correlate by customId, never by position:
{
"totalItems": 3,
"completedItems": 2,
"failedItems": 1,
"results": [
{"customId": "chapter-01", "audioPath": "items/0.mp3", "timestampsPath": "items/0.timestamps.json"},
{"customId": "chapter-02", "audioPath": "items/1.mp3"},
{"customId": "chapter-03", "error": {"code": 3, "message": "text exceeds per-item limit"}}
]
}A failed item has no files in the archive; its error is in the document. Every timestamps file has the form described in Reading the timestamps document. Audio is stored uncompressed inside the zip and JSON documents are deflated, so the archive is about the size of the audio it holds.
Minting a URL
Mint the URL from your own credentials, for a PUT to the exact object the archive should be written to. Each URL is single-use in practice: the job overwrites the probe's empty object with the archive, so give every job its own object name.
Mint against the bucket's regional endpoint. A URL on the global endpoint (bucket.s3.amazonaws.com) can answer with a redirect for a bucket outside us-east-1, and the submit refuses redirects.
import boto3
from botocore.config import Config
region = "us-east-2"
s3 = boto3.client(
"s3",
region_name=region,
endpoint_url=f"https://s3.{region}.amazonaws.com",
config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}),
)
results_uri = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "my-bucket", "Key": "tts/job-42.zip"},
ExpiresIn=3 * 24 * 3600, # seconds; at most 7 days
)The signing identity needs s3:PutObject on the key. A URL signed with temporary credentials (an assumed role, AWS SSO) stops working when those credentials expire, whatever ExpiresIn says, so sign with an IAM user's key or a role session that outlives the job. The aws s3 presign command mints GET URLs only.
from datetime import timedelta
from google.cloud import storage
blob = storage.Client().bucket("my-bucket").blob("tts/job-42.zip")
results_uri = blob.generate_signed_url(
version="v4",
method="PUT",
expiration=timedelta(days=3), # at most 7 days
)Signing needs a private key or the IAM signBlob permission on a service account; the service account needs storage.objects.create on the bucket. The equivalent command is gcloud storage sign-url --http-verb=PUT --duration=72h gs://my-bucket/tts/job-42.zip.
from datetime import datetime, timedelta, timezone
from azure.storage.blob import BlobSasPermissions, generate_blob_sas
account, container, blob = "myaccount", "tts", "job-42.zip"
sas = generate_blob_sas(
account_name=account,
container_name=container,
blob_name=blob,
account_key="<account key>",
permission=BlobSasPermissions(create=True, write=True),
expiry=datetime.now(timezone.utc) + timedelta(days=3),
)
results_uri = f"https://{account}.blob.core.windows.net/{container}/{blob}?{sas}"The SAS must carry the write permission (sp includes w). Create alone is not enough: the delivery overwrites the empty object the submit-time probe wrote, and a create-only SAS cannot overwrite. Container-level SAS URLs work too, pointed at the blob's path.
Checks at submit
Everything about the destination is checked before an operation exists, so a bad URL costs a submit call, never synthesis time. In order:
| Check | Refusal |
|---|---|
The URL is https, carries a recognizable signature (X-Amz-Signature, X-Goog-Signature or an Azure sig), and an Azure SAS grants write | INVALID_ARGUMENT naming the rule |
| The signature stays valid for at least 12 hours (async) or 48 hours (batch) after submit, read from the URL itself | INVALID_ARGUMENT naming the expiry and the minimum |
| The results are estimated to fit the destination's single-upload limit — 5 GiB on S3, 5,000 MiB on Azure (256 MiB under a SAS API version older than 2019-12-12), effectively unbounded on Google Cloud Storage. The estimate comes from your text, speaking rate and encoding, and errs high | INVALID_ARGUMENT naming both sizes; split the job |
A zero-byte PUT to the URL succeeds | FAILED_PRECONDITION with the HTTP status the destination returned, or UNAVAILABLE if it did not answer within 10 seconds |
The size check runs before the probe, so a job refused for size leaves nothing in your bucket.
When delivery fails
The upload runs after synthesis. A transient failure — a 429, a 5xx, a dropped connection — is retried for a bounded window. Anything the destination will keep refusing ends the job: a 4xx, a redirect, a URL whose signature lapsed before the results were ready. The operation then carries an error:
Operation error.code | Meaning | What to do |
|---|---|---|
FAILED_PRECONDITION | The destination refused the upload; the message names the object URL (never the signature) and the HTTP status, or says the URL expired | Mint a fresh URL and resubmit |
UNAVAILABLE | The destination stayed unreachable through every retry | Resubmit |
A job whose delivery fails is not billed and nothing of it is retained, so the synthesis is redone on resubmit.
Packaging
outputConfig.packaging chooses between one archive and one file per artifact:
| Value | Delivered to resultsUri | Hosted by Inworld |
|---|---|---|
| omitted | ZIP | FILES — today's per-file signed URLs |
ZIP | one archive, as above | one signed URL for an archive in Inworld storage, with resultsPath and expireTime |
FILES | rejected: a pre-signed URL receives exactly one object | today's per-file signed URLs |
A delivered job and a hosted job with packaging: ZIP produce the same archive, so one integration that reads resultsUri and resultsPath serves both. A hosted job with the default FILES packaging keeps the per-artifact URLs described on the async and batch pages, with no resultsPath.
Limits
| Limit | Value |
|---|---|
| Destinations per job | One URL, one object, one archive |
| Signature validity | At least 12 hours after submit for async, 48 for batch; at most 7 days on S3 and Google Cloud Storage |
| Archive size | The destination's single-upload limit — see Checks at submit and How much text fits |
| Retries | Transient upload failures are retried for a bounded window; refusals are not |
How much text fits
Submit estimates the archive from your text, speaking rate and encoding, and refuses a job whose estimate exceeds the destination's single-upload limit. The estimate errs high on purpose, so these are the most characters one job can carry to each destination at speaking rate 1.0:
| Destination | MP3 or Opus, 128 kbps (default) | LINEAR16 or WAV, 48 kHz |
|---|---|---|
| Amazon S3 (5 GiB) | ~3.3M Latin-script, ~2.5M Korean, ~1.3M Chinese or Japanese characters | ~550K, ~410K, ~230K |
| Azure Blob Storage (5,000 MiB) | ~3.2M, ~2.4M, ~1.3M | ~540K, ~400K, ~220K |
| Azure, SAS API version before 2019-12-12 (256 MiB) | ~160K, ~120K, ~69K | ~27K, ~20K, ~11K |
| Google Cloud Storage | effectively unlimited | effectively unlimited |
Chinese and Japanese characters and Korean syllable blocks each take longer to speak than a letter, so fewer fit. A slower speaking rate lowers every figure in proportion: at 0.5, half as many fit. An async job carries at most 100,000 characters, so only a legacy-version Azure SAS can refuse one for size. For a batch, the request size and your queued-character budget usually bind well before these figures.