Realtime TTS-2 is live. Built for realtime conversation that feels human. Read the Realtime TTS-2 announcement

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.

Read the archive from your storage

Fetch the object with your own credentials. It is a zip. Open resultsPath inside it and resolve every artifact path it names against the same archive.

cURL
# 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"
# }

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:

text
results.json
audio.mp3            # or the extension of the encoding you requested
timestamps.json      # only when timestampType was requested

with results.json, the results document, holding the artifact paths:

json
{ "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:

text
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:

json
{
  "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.

python
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.

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:

CheckRefusal
The URL is https, carries a recognizable signature (X-Amz-Signature, X-Goog-Signature or an Azure sig), and an Azure SAS grants writeINVALID_ARGUMENT naming the rule
The signature stays valid for at least 12 hours (async) or 48 hours (batch) after submit, read from the URL itselfINVALID_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 highINVALID_ARGUMENT naming both sizes; split the job
A zero-byte PUT to the URL succeedsFAILED_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.codeMeaningWhat to do
FAILED_PRECONDITIONThe destination refused the upload; the message names the object URL (never the signature) and the HTTP status, or says the URL expiredMint a fresh URL and resubmit
UNAVAILABLEThe destination stayed unreachable through every retryResubmit

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:

ValueDelivered to resultsUriHosted by Inworld
omittedZIPFILES — today's per-file signed URLs
ZIPone archive, as aboveone signed URL for an archive in Inworld storage, with resultsPath and expireTime
FILESrejected: a pre-signed URL receives exactly one objecttoday'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

LimitValue
Destinations per jobOne URL, one object, one archive
Signature validityAt least 12 hours after submit for async, 48 for batch; at most 7 days on S3 and Google Cloud Storage
Archive sizeThe destination's single-upload limit — see Checks at submit and How much text fits
RetriesTransient 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:

DestinationMP3 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 Storageeffectively unlimitedeffectively 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.

API reference