> ## Documentation Index
> Fetch the complete documentation index at: https://docs.inworld.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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

<Note>
**Preview.** This capability is a preview release and may be further refined before it is marked stable. See [release stages](https://docs.inworld.ai/portal/support.md) for what that means.
</Note>

By default, an [async](https://docs.inworld.ai/tts/synthesize-speech-async.md) or [batch](https://docs.inworld.ai/tts/synthesize-speech-batch.md) job keeps its results in Inworld storage for about 7 days and returns signed download URLs. With results delivery, you provide a pre-signed upload URL to your own bucket. The job writes its results there as one zip archive. Results only pass through Inworld on the way to your bucket and are not retained. The job is billed once the upload succeeds.

It works with any cloud whose storage issues pre-signed upload URLs, including Amazon S3 and S3-compatible services, Google Cloud Storage and Azure Blob Storage. Inworld needs no credentials of yours. The URL is the whole authorization, and it is yours to mint, scope and expire.

<Note>
If your workspace has [Zero Data Retention](https://docs.inworld.ai/portal/zero-data-retention.md) 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.
</Note>

## How it works

<Steps>
  <Step title="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](#minting-a-url) for each cloud.
  </Step>
  <Step title="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.
  </Step>
  <Step title="Poll the operation">
    `GET /lro/v1alpha/{name}`, exactly as for a hosted job. When `done` is `true`, a successful `response` carries `resultsUri` and `resultsPath`. The first is your own URL without its signature; the second is 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.
  </Step>
  <Step title="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.
  </Step>
</Steps>

<CodeGroup>

```bash 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"
# }
```

```python Python
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"])
```

</CodeGroup>

## 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](https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech-async.md#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](https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech-batch.md#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](https://docs.inworld.ai/tts/synthesize-speech-async.md#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.

<Tabs>
  <Tab title="Amazon S3">
    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. Sign with an IAM user's key or a role session that outlives the job. The `aws s3 presign` command mints `GET` URLs only.
  </Tab>
  <Tab title="Google Cloud Storage">
    ```python
    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`.
  </Tab>
  <Tab title="Azure Blob Storage">
    ```python
    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.
  </Tab>
</Tabs>

## 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. Transient failures such as a `408`, a `429`, a `5xx`, or a dropped connection are retried for a bounded window. Other `4xx` responses, redirects, or a URL whose signature lapsed before the results were ready end the job. 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](https://docs.inworld.ai/tts/synthesize-speech-async.md) and [batch](https://docs.inworld.ai/tts/synthesize-speech-batch.md) 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](#checks-at-submit) and [How much text fits](#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.

## API reference

<CardGroup cols={2}>
  <Card title="Synthesize Speech (Async)" icon="code" href="https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech-async.md">
    Submit an asynchronous synthesis job
  </Card>

  <Card title="Synthesize Speech (Batch)" icon="code" href="https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech-batch.md">
    Submit a batch of synthesis requests
  </Card>
</CardGroup>
