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

# Data retention

> How long ModelRunner keeps your request payloads and generated media, how to shorten it, and how to delete a request's data outright.

Every request produces two kinds of data: the **JSON payloads** (the input you sent and the output the model returned, which power your dashboard history) and the **media files** the model generated, hosted on the ModelRunner CDN. Each has its own default retention and its own control.

| Data                                       | Default                    | Control                                                     |
| ------------------------------------------ | -------------------------- | ----------------------------------------------------------- |
| **Request payloads** (input / output JSON) | 180 days                   | `X-Modelrunner-Store-IO: 0` to opt out, or delete on demand |
| **Generated media** (CDN files)            | Kept until you delete it   | `X-Modelrunner-Object-Lifecycle-Preference`                 |
| **Uploaded input files**                   | Kept until you delete them | Same header, on the upload call                             |

The 180-day payload default applies to requests created **on or after** this policy took effect. Older requests are kept until you delete them — nothing was removed retroactively.

## Generated media

Set an expiration on the files a request produces with the `X-Modelrunner-Object-Lifecycle-Preference` header:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://queue.modelrunner.run/{ownerName}/{modelName}" \
    -H "Authorization: Key $MODELRUNNER_KEY" \
    -H "Content-Type: application/json" \
    -H 'X-Modelrunner-Object-Lifecycle-Preference: {"expiration_duration_seconds": 3600}' \
    -d '{"prompt": "a sunset"}'
  ```

  ```javascript JavaScript theme={null}
  const result = await modelrunner.subscribe('owner/model-name', {
  	input: { prompt: 'a sunset' },
  	headers: {
  		'X-Modelrunner-Object-Lifecycle-Preference': JSON.stringify({
  			expiration_duration_seconds: 3600,
  		}),
  	},
  })
  ```

  ```python Python theme={null}
  import json
  import os

  import httpx

  response = httpx.post(
      "https://queue.modelrunner.run/owner/model-name",
      json={"prompt": "a sunset"},
      headers={
          "Authorization": f"Key {os.environ['MODELRUNNER_KEY']}",
          "X-Modelrunner-Object-Lifecycle-Preference": json.dumps(
              {"expiration_duration_seconds": 3600}
          ),
      },
  )
  response.raise_for_status()
  request_id = response.json()["request_id"]
  ```
</CodeGroup>

<Note>
  **The Python SDK cannot send these headers.** `modelrunner_ai.subscribe()` — and `run`, `submit`, and `stream` — take no `headers` argument, so passing one raises `TypeError: SyncClient.subscribe() got an unexpected keyword argument 'headers'`. The only headers those calls set are `Authorization`, `User-Agent`, and the two behind the `hint` and `priority` arguments. Send the request over raw HTTP as above, or set an account-wide default in the [dashboard](#setting-a-default-for-your-account). `httpx` is already a dependency of `modelrunner-ai`, so there is nothing extra to install.

  The submit returns immediately with `request_id` and the polling URLs — see the [request lifecycle](/docs/guides/request-lifecycle) for reading the result back. The JavaScript client does support a per-request `headers` option, as shown above.
</Note>

| Field                         | Notes                                                                               |
| ----------------------------- | ----------------------------------------------------------------------------------- |
| `expiration_duration_seconds` | Integer seconds, from 60 up to 157,680,000 (5 years). Use `null` for no expiration. |

The countdown starts when the request **finishes**, not when you submit it — so a 60-second expiration on a model that takes two minutes still gives you a full minute of file life after the output exists.

<Warning>
  Expired files are permanently deleted and cannot be recovered. Download anything you need to keep before it expires.
</Warning>

The same header works on [file uploads](/docs/guides/file-uploads), setting an expiration on the file you upload — send it on `POST /storage/upload/initiate`. There the countdown starts at **upload** time, since the bytes are landing immediately.

One exception is worth knowing: **an uploaded file that you then use as a request input stops expiring.** Another request may reference the same upload, so we stop treating it as disposable. The same applies to a file you favorite, tag, or use as a model, wrapper or collection image. Delete those explicitly when you are done with them.

<Note>
  For a [multipart upload](/docs/guides/file-uploads#multipart-upload-large-files), send the header on `POST /storage/upload/complete` instead — that is the call that records the file.

  One client-side gap: the JavaScript SDK's `storage.upload()` switches to multipart above 90 MB and completes that upload without going through the API, so a `lifecycle` option is not applied to files that large. Use the raw HTTP flow, or delete large uploads explicitly.
</Note>

### Access controls are not supported

CDN URLs are unguessable but **public to anyone holding the link**. There is no per-user access-control list, and an `initial_acl` field in the lifecycle header is rejected with a `400` rather than ignored — a silently-dropped ACL would leave you believing files are private when they are readable. Treat the URL itself as the secret, and use a short expiration for sensitive output.

## Request payloads

Inputs and outputs are stored for **180 days**, which is what makes your [dashboard history](https://modelrunner.ai/settings/requests) work.

To keep them out of storage entirely, send `X-Modelrunner-Store-IO: 0`:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://queue.modelrunner.run/{ownerName}/{modelName}" \
    -H "Authorization: Key $MODELRUNNER_KEY" \
    -H "Content-Type: application/json" \
    -H "X-Modelrunner-Store-IO: 0" \
    -d '{"prompt": "a sunset"}'
  ```

  ```javascript JavaScript theme={null}
  const result = await modelrunner.subscribe('owner/model-name', {
  	input: { prompt: 'a sunset' },
  	headers: { 'X-Modelrunner-Store-IO': '0' },
  })
  ```

  ```python Python theme={null}
  import os

  import httpx

  response = httpx.post(
      "https://queue.modelrunner.run/owner/model-name",
      json={"prompt": "a sunset"},
      headers={
          "Authorization": f"Key {os.environ['MODELRUNNER_KEY']}",
          "X-Modelrunner-Store-IO": "0",
      },
  )
  response.raise_for_status()
  ```
</CodeGroup>

<Note>
  **This is not "never written".** We hold the output briefly while we move media onto the CDN, build thumbnails and calculate what to charge you — usage-priced models are billed from the output itself. Payloads are removed shortly after the request settles, once your result has been handed over and any webhook body has been rendered. Budget for a window of roughly **15 minutes**, not zero.

  Your media is unaffected: files stay for as long as the expiration above allows.
</Note>

Because the payload is gone afterwards, a request submitted this way is only readable **once** in practice — from the response to your own poll, or from a [webhook](/docs/guides/webhooks) delivery. Capture what you need at that point.

## Deleting a request's data

```bash theme={null}
curl -X DELETE "https://queue.modelrunner.run/requests/{requestId}/payloads" \
  -H "Authorization: Key $MODELRUNNER_KEY" \
  -H "X-Idempotency-Key: $(uuidgen)"
```

You can also append `/payloads` to the `response_url` the submit returned.

This removes the input and output JSON **and** deletes the media files that request generated:

```json theme={null}
{
  "request_id": "V1StGXR8_Z5jdHi6B",
  "payloads_deleted": true,
  "cdn_delete_results": [
    { "link": "https://media.modelrunner.ai/abc123.png", "exception": null }
  ]
}
```

|             |                                                                                         |
| ----------- | --------------------------------------------------------------------------------------- |
| **Deletes** | The payload JSON, and the CDN files in the request's output                             |
| **Keeps**   | Input files you uploaded — another request may use the same file                        |
| **Keeps**   | The request's timestamp, model, timings and price, so your billing history stays intact |

`X-Idempotency-Key` is optional; repeating the same delete is safe either way.

<Warning>
  Deletion is permanent. The files return `403` immediately afterwards and cannot be restored.
</Warning>

A delete returns `409` in three cases, each protecting something:

* **The request has not finished.** Its payload does not exist yet in final form.
* **It has not been charged yet.** Usage-priced models compute the bill from the output, so removing it first would produce a wrong charge.
* **It is published as an example** on a model or wrapper page. Detach it first.

## Reading a request whose payloads are gone

The request record survives a purge — only the payloads are emptied. `input` and `output` come back as `{}`, and a **`payloadsPurgedAt`** timestamp tells you why.

```json theme={null}
{
  "id": "V1StGXR8_Z5jdHi6B",
  "status": "COMPLETED",
  "input": {},
  "output": {},
  "payloadsPurgedAt": "2026-08-06T12:00:00.000Z",
  "totalPrice": "0.04"
}
```

<Note>
  Key your handling off `payloadsPurgedAt`, not off an empty output — `{}` is also what a failed generation looks like. See the `billingStatus` warning on the [webhooks page](/docs/guides/webhooks) for the same distinction.
</Note>

## Setting a default for your account

Rather than sending headers on every call, set a default under **Settings → Account** in the dashboard: payload retention, a media expiration, and whether to store payloads at all. It applies to every request on the account.

A header on an individual request always wins over the account default — including `"expiration_duration_seconds": null`, which is how you exempt one request from an account-wide media expiration.
