# Your own storage

> Upload rendered files to Amazon S3, S3-compatible storage, Azure Blob Storage, Google Cloud Storage or SFTP, with destination settings, credentials, connection tests, path templates, hosted copies, zero retention, retries, limits and errors.



Storage destinations upload rendered files to storage you own: an Amazon S3 bucket, an S3-compatible service, Azure Blob Storage, Google Cloud Storage or an SFTP server. Uploads run in the background once a render has succeeded, they are retried when they fail, and the render object reports where each file went. You decide whether we keep a hosted copy as well. Storage destinations are included from the Starter plan, and uploads don't cost renders.

## Why use your own storage [#why-use-your-own-storage]

* **Keep documents in your systems.** Every file lands in your bucket or on your server, under a path you choose, for your archive or the systems that process it.
* **Apply your own retention and access rules.** With `keep_hosted_copy: false`, we delete our copy once the upload has succeeded, so your storage holds the only copy.
* **Combine it with zero retention.** We keep the file only until it has reached your storage, and never longer than about a day.
* **Feed systems that watch a folder.** On an SFTP server, a file appears only once it is complete.

## Destinations [#destinations]

A destination is one bucket, container or server. Its settings go in `config` and its secrets in `credentials`.

| `provider`      | Storage                                                                                                                   | Credentials                                         |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `s3`            | Amazon S3                                                                                                                 | Access keys                                         |
| `s3_compatible` | Services with an S3 API, such as Cloudflare R2, Backblaze B2, Wasabi, Scaleway, OVHcloud, Hetzner Object Storage or MinIO | Access keys                                         |
| `azure_blob`    | Azure Blob Storage                                                                                                        | SAS URL or connection string                        |
| `gcs`           | Google Cloud Storage                                                                                                      | Service account key                                 |
| `sftp`          | Your own server over SFTP                                                                                                 | Password or private key, plus the server's host key |

Uploads leave from our EU region and only go to public addresses: object storage over HTTPS, SFTP servers over SSH. Every destination can have a `prefix`, a folder that all its files go under, such as `invoices/`.

### Amazon S3 [#amazon-s3]

| `config` field     | Description                                                                                                                                                                                                                                                          |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bucket`           | Required. The bucket name, 3 to 63 characters                                                                                                                                                                                                                        |
| `region`           | Required. The bucket's region, such as `eu-central-1`                                                                                                                                                                                                                |
| `prefix`           | Optional folder for every file, up to 500 characters                                                                                                                                                                                                                 |
| `storage_class`    | Optional. `STANDARD`, `STANDARD_IA`, `ONEZONE_IA`, `INTELLIGENT_TIERING` or `GLACIER_IR`. Every uploaded object gets this class, and so does the connection test's probe. Leave it out to use the bucket's default.                                                  |
| `sse`              | Optional. `AES256` or `aws:kms`. With `aws:kms` and a `kms_key_id`, uploads are encrypted with that KMS key; otherwise the bucket's default encryption applies.                                                                                                      |
| `kms_key_id`       | The KMS key for `aws:kms`                                                                                                                                                                                                                                            |
| `force_path_style` | Optional, default `false`: requests name the bucket in the host name, `<bucket>.s3.<region>.amazonaws.com`. `true` puts it in the path, `s3.<region>.amazonaws.com/<bucket>`. See [Bucket in the host name or in the path](#bucket-in-the-host-name-or-in-the-path). |

| `credentials` field | Description                                                                                  |
| ------------------- | -------------------------------------------------------------------------------------------- |
| `access_key_id`     | Required                                                                                     |
| `secret_access_key` | Required                                                                                     |
| `session_token`     | Optional: the session token of temporary credentials. Uploads stop working when they expire. |

Only instant-access storage classes are allowed, because the connection test reads its probe back. Archive classes such as `GLACIER` and `DEEP_ARCHIVE` are refused with `400 validation_error` at `/config/storage_class`.

### S3-compatible storage [#s3-compatible-storage]

`s3_compatible` takes the same fields and credentials as Amazon S3, except `storage_class`, which only Amazon S3 supports. These fields differ:

| `config` field     | Description                                                                                                                                                                                                                                                           |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpoint`         | Required. The service's URL, with `https://` and a public host name or address. Any port works.                                                                                                                                                                       |
| `region`           | Optional, default `auto`. The region name your service expects.                                                                                                                                                                                                       |
| `force_path_style` | Optional, default `true`: the bucket goes in the path, `<endpoint>/<bucket>/<key>`, which most services accept and self-hosted ones such as MinIO usually require. `false` puts it in the host name, `<bucket>.<endpoint host>/<key>`, for services that expect that. |

```json
{
  "name": "R2 EU",
  "provider": "s3_compatible",
  "config": {
    "endpoint": "https://<account-id>.eu.r2.cloudflarestorage.com",
    "region": "auto",
    "bucket": "example-documents",
    "force_path_style": true
  },
  "credentials": { "access_key_id": "…", "secret_access_key": "…" }
}
```

#### Bucket in the host name or in the path [#bucket-in-the-host-name-or-in-the-path]

`force_path_style` decides where requests name the bucket. The destination shows the value that applies, also when you left it out.

| `force_path_style` | Amazon S3                                                       | S3-compatible                            |
| ------------------ | --------------------------------------------------------------- | ---------------------------------------- |
| `false`            | `https://<bucket>.s3.<region>.amazonaws.com/<key>`, the default | `https://<bucket>.<endpoint host>/<key>` |
| `true`             | `https://s3.<region>.amazonaws.com/<bucket>/<key>`              | `<endpoint>/<bucket>/<key>`, the default |

A host name can't carry every bucket name, so these always use the path: bucket names with dots, underscores or capital letters, which a provider's certificate doesn't cover, and endpoints that are IP addresses.

### Azure Blob Storage [#azure-blob-storage]

| `config` field | Description                                                                           |
| -------------- | ------------------------------------------------------------------------------------- |
| `account`      | Required. The storage account name, not its URL: 3 to 24 lowercase letters and digits |
| `container`    | Required. 3 to 63 lowercase letters, digits and `-`, starting with a letter or digit  |
| `prefix`       | Optional folder for every file                                                        |

Send one of these credentials:

| `credentials` field | Description                                                                                                                                              |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sas_url`           | A shared access signature URL, such as `https://exampledocs.blob.core.windows.net/invoices?sv=…&sig=…`. We use its token with `account` and `container`. |
| `connection_string` | A connection string that contains the account key (`AccountKey=…`)                                                                                       |

### Google Cloud Storage [#google-cloud-storage]

| Field                              | Description                                                                                    |
| ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| `config.bucket`                    | Required. The bucket name, 3 to 222 characters                                                 |
| `config.prefix`                    | Optional folder for every file                                                                 |
| `credentials.service_account_json` | Required. The JSON key file of a service account, as Google Cloud created it, sent as a string |

### SFTP [#sftp]

| `config` field | Description                                                                                                                    |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `host`         | Required. The server's public host name or IP address, without `sftp://`, a user or a port                                     |
| `port`         | Optional, default `22`                                                                                                         |
| `username`     | Required                                                                                                                       |
| `host_key`     | Required. The SHA-256 fingerprint of the server's host key, as `ssh-keygen -lf` prints it: `SHA256:` followed by 43 characters |
| `prefix`       | Optional folder. A prefix that starts with `/` is an absolute path; otherwise it is inside the login directory.                |

As credentials, send `password`, or `private_key` (the whole key file, in OpenSSH or PEM format) with a `passphrase` if the key is encrypted.

```json
{
  "name": "Invoices server",
  "provider": "sftp",
  "config": {
    "host": "sftp.example.com",
    "port": 22,
    "username": "renders",
    "prefix": "/incoming/invoices",
    "host_key": "SHA256:e2uuyLCsFPfkWbJjOEXNLQO/3MSsf1kJQ/7eLsf3BEM"
  },
  "credentials": {
    "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----\n",
    "passphrase": "…"
  }
}
```

The host key pins the server: if it shows another key, the connection is refused before any credentials are sent. We use the server's Ed25519 key if it has one, then ECDSA, then RSA, so pin the first of those your server offers:

```bash
ssh-keyscan -t ed25519 sftp.example.com | ssh-keygen -lf -
```

In the dashboard, the connection test shows the key the server presents and offers to trust it. Compare it with your server's fingerprint before you do. If the server's key changes later, uploads fail until you check the new key and save it as `config.host_key`.

Each file is written under a hidden temporary name in its target folder and renamed once it is complete, so a process that watches the folder never reads half a file. An upload that fails or runs out of time removes its temporary file. Missing folders are created, and a file with the same name is replaced.

## Credentials [#credentials]

Credentials are write-only. Send them in `credentials` when you create a destination; responses only say whether they are set:

```json
"credentials": { "configured": true }
```

* They are encrypted with AES-256-GCM before they are stored, and decrypted only to upload a file or to run a connection test.
* To rotate them, send the complete new `credentials` object with `PATCH /v1/storage-destinations/{id}`. Requests without `credentials` keep the stored ones.
* In the dashboard, saving credentials asks you to confirm your identity again.

The credentials need permission to write files under the prefix. The connection test also reads its probe back and deletes it, so allow reading and deleting as well. For Amazon S3, that is `s3:PutObject`, `s3:GetObject` and `s3:DeleteObject`, plus `s3:AbortMultipartUpload` so a failed upload of a large file can be cleaned up, see [Upload a render](#upload-a-render). On an SFTP server, the user must be able to create folders and to write, rename, read and delete files.

## Create and manage destinations [#create-and-manage-destinations]

In the dashboard, open **Storage** and choose **Add destination**. The form can test your settings before you save them. Owners, Admins and Developers can manage destinations.

Through the API, use a key with the `storage:write` scope:

```bash
curl https://api-eu.dynamicdocumentapi.com/v1/storage-destinations \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Invoice archive",
    "provider": "s3",
    "config": {
      "bucket": "example-documents",
      "region": "eu-central-1",
      "prefix": "invoices/",
      "storage_class": "STANDARD_IA"
    },
    "credentials": {
      "access_key_id": "…",
      "secret_access_key": "…"
    },
    "path_template": "{{ data.customer_id }}/{{ render.id }}.{{ file.ext }}",
    "is_default": true
  }'
```

The response is the destination, with `201 Created`:

```json
{
  "id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V",
  "object": "storage_destination",
  "name": "Invoice archive",
  "provider": "s3",
  "config": {
    "region": "eu-central-1",
    "bucket": "example-documents",
    "prefix": "invoices/",
    "force_path_style": false,
    "storage_class": "STANDARD_IA"
  },
  "credentials": { "configured": true },
  "path_template": "{{ data.customer_id }}/{{ render.id }}.{{ file.ext }}",
  "is_default": true,
  "keep_hosted_copy": true,
  "last_test": { "at": null, "status": null, "error": null },
  "created_at": "2026-09-17T15:00:12Z",
  "updated_at": "2026-09-17T15:00:12Z"
}
```

| Field              | Description                                                                                                                                                                         |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`             | Required. Up to 80 characters, shown in the dashboard and in `storage.upload_failed` events                                                                                         |
| `provider`         | Required. `s3`, `s3_compatible`, `azure_blob`, `gcs` or `sftp`. It can't be changed later; create a new destination instead.                                                        |
| `config`           | Required. The provider's settings, see [Destinations](#destinations)                                                                                                                |
| `credentials`      | Required when you create the destination. Write-only.                                                                                                                               |
| `path_template`    | Where files go below the prefix, see [Path templates](#path-templates). Up to 500 characters.                                                                                       |
| `is_default`       | `true` makes this the [default destination](#the-default-destination). Default `false`.                                                                                             |
| `keep_hosted_copy` | Default `true`. With `false`, a render whose destinations all say so keeps no hosted copy and, without `delivery.type`, answers without file URLs, see [Hosted copy](#hosted-copy). |

The response adds the `id` (`dst_…`), `last_test` (see [Test the connection](#test-the-connection)), `created_at` and `updated_at`.

| Endpoint                                  | Description                                                                                                                                                    |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/storage-destinations`            | List all destinations, sorted by name, in one page                                                                                                             |
| `POST /v1/storage-destinations`           | Create a destination                                                                                                                                           |
| `GET /v1/storage-destinations/{id}`       | Retrieve a destination                                                                                                                                         |
| `PATCH /v1/storage-destinations/{id}`     | Change a destination. `config` and `credentials` are each replaced as a whole, so send every field of the one you change.                                      |
| `DELETE /v1/storage-destinations/{id}`    | Delete a destination. Files already uploaded stay in your storage; uploads that haven't succeeded yet stop, see [Retries and failures](#retries-and-failures). |
| `POST /v1/storage-destinations/{id}/test` | Run the connection test                                                                                                                                        |

Listing and retrieving destinations needs the `storage:read` or the `storage:write` scope; the other endpoints need `storage:write`. A workspace can have up to 20 destinations.

### The default destination [#the-default-destination]

One destination per workspace can be the default. Renders that don't send `delivery.storage` are uploaded to it. Marking a destination as the default, with `is_default: true` or **Make default** in the dashboard, removes the flag from the previous one.

## Test the connection [#test-the-connection]

`POST /v1/storage-destinations/{id}/test` writes a small probe file, `dynamic-document-api-connection-test-<id>.txt`, below the prefix, reads it back and deletes it. It runs from our EU region like the uploads, and the probe gets the destination's storage class.

```bash
curl -X POST https://api-eu.dynamicdocumentapi.com/v1/storage-destinations/dst_01J9ZN2B4D6F8H0K2M4P6R8T0V/test \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY"
```

```json
{
  "object": "storage_test",
  "ok": false,
  "steps": [
    { "name": "credentials", "ok": true, "detail": "configuration accepted" },
    { "name": "write", "ok": false, "detail": "… 403 Forbidden … AccessDenied …" }
  ],
  "destination": {
    "id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V",
    "object": "storage_destination",
    "last_test": { "at": "2026-09-17T15:01:05Z", "status": "failed", "error": "… 403 Forbidden … AccessDenied …" }
  }
}
```

The `destination` above is shortened. The response is `200` whenever the test ran: `ok: false` means one of the checks failed, not the request. Each step has `name`, `ok` and a `detail`:

| Step                      | What it checks                                                                    |
| ------------------------- | --------------------------------------------------------------------------------- |
| `connect`                 | SFTP: the server can be reached                                                   |
| `host_key`                | SFTP: the server shows the pinned host key                                        |
| `credentials`             | The settings and credentials can be used. On SFTP, the server accepted the login. |
| `sftp`                    | SFTP: the SFTP service started after the login (only listed when it didn't)       |
| `write`, `read`, `delete` | The probe was written, read back and deleted                                      |
| `deliverer`               | Only listed when our region didn't answer within 20 seconds                       |

The result is saved on the destination as `last_test`: `at`, `status` (`succeeded` or `failed`) and `error`, the detail of the first failed step. Changing a destination doesn't reset `last_test`, so test again after you change its settings.

When an SFTP server could be reached, the response also contains `host_key`, the fingerprint of the key it showed. If that isn't `config.host_key`, the `host_key` step fails. Check the key, then save it as `config.host_key`.

## Upload a render [#upload-a-render]

A render is uploaded to the destinations in `delivery.storage` when the request sends it, and to the default destination otherwise:

```bash
curl https://api-eu.dynamicdocumentapi.com/v1/renders \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" },
    "data": { "number": "2026-0042", "customer_id": "c_981" },
    "output": { "format": "pdf", "filename": "invoice-{{ data.number }}.pdf" },
    "reference": "inv_2026_0042",
    "delivery": {
      "storage": [
        { "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "path": "{{ data.customer_id }}/{{ file.filename }}" }
      ]
    }
  }'
```

| `delivery` field | Description                                                                                                                                                                                                                                        |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storage`        | Up to 10 destinations, each `{"destination_id": "dst_…", "path": "…"}`. `path` is optional and replaces the destination's `path_template` for this render. Without `storage`, the default destination applies; `[]` uploads nowhere.               |
| `hosted`         | Whether we keep a hosted copy too, see [Hosted copy](#hosted-copy)                                                                                                                                                                                 |
| `type`           | `url` needs a hosted copy, `none` returns the render object without file URLs, and `binary` and `base64` return the file in the sync response as well. Without `type`, a render gets `url` when it keeps a hosted copy and `none` when it doesn't. |
| `retention`      | `"none"` for [zero retention](#zero-retention-with-your-own-storage)                                                                                                                                                                               |

Uploads start once the render has succeeded. A sync response arrives before they finish, so its upload results are still `pending`; see [Upload results](#upload-results). A render that fails uploads nothing.

Every object gets the file's content type, such as `application/pdf` or `image/png`, so a file served straight from your bucket opens in the browser instead of downloading. Files larger than 8 MiB are uploaded in parts. If such an upload fails or runs out of time, we abort it, so no parts stay in your bucket. On Amazon S3 that needs `s3:AbortMultipartUpload`; without it, the parts stay until a lifecycle rule removes incomplete multipart uploads.

Every request with `delivery` can upload files: `POST /v1/renders`, the convenience endpoints such as `POST /v1/pdf/from-html` (with `delivery` at the top level), [batches](/docs/batches) for every item, [PDF tools](/docs/pdf-tools) and `POST /v1/einvoices`. Naming destinations needs no scope beyond `render:write`. Signed links and editor previews are never uploaded.

Test renders are uploaded like live ones, watermark included. Use `render.test` in a path template to keep them apart.

## Hosted copy [#hosted-copy]

Uploading doesn't replace our hosted copy unless you ask for that. Whether a render keeps one is decided by:

1. `delivery.hosted`, when the request sends it
2. otherwise the render's destinations, those in `delivery.storage` or else the default destination: `false` when every one of them has `keep_hosted_copy: false`, `true` in all other cases, also when the render has no destination

Zero-retention renders never keep one, whatever `delivery.hosted` says.

| Render          | Our copy                                                                                                                                                                                            | File URLs |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| Hosted          | Kept for the render's retention period, like any render                                                                                                                                             | Yes       |
| `hosted: false` | Deleted once every upload of the file has succeeded and every email of the render has been sent. While an upload or an email is still retrying, or has failed, the copy is kept, for at most a day. | No        |
| Zero retention  | A transient copy, see [Zero retention with your own storage](#zero-retention-with-your-own-storage)                                                                                                 | No        |

The hosted copy also sets the default `delivery.type`: `url` when the render keeps one, `none` when it doesn't. Without a hosted copy, a render that sends no `type` answers without file URLs, and its files go to your destinations and email rules only. So with a default destination that has `keep_hosted_copy: false`, requests without `delivery.type` and `delivery.hosted` get no file URLs. Send `binary` or `base64` in sync mode to get the file in the response as well.

> **No hosted copy, no URL delivery**
>
> An explicit `"type": "url"` needs a hosted copy. When the render has none because its destinations have `keep_hosted_copy: false` or the request sent `delivery.hosted: false`, the request fails with `400 validation_error` at `/delivery/hosted`, and the message names that cause. Send `delivery.hosted: true` to keep the copy, or leave out `delivery.type` for a response without file URLs. Without hosted copies, a [batch](/docs/batches#combined-files) can't `combine` either; send `delivery.hosted: true` to keep them.

## Path templates [#path-templates]

The path decides where a file goes below the destination's `prefix`: the object key in a bucket, or the file's path on an SFTP server. It comes from the first of:

1. the render's `delivery.storage[].path`
2. the destination's `path_template`
3. `<render_id>/<filename>`, such as `rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q/invoice-2026-0042.pdf`

Path templates use the [template language](/docs/template-language) with these variables:

| Variable               | Value                                                                                                        |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `render.id`            | The render ID, `rnd_…`                                                                                       |
| `render.created_at`    | When the render was queued, in UTC, such as `2026-09-17T15:06:33Z`                                           |
| `render.template_id`   | The template ID, `tpl_…`; empty for other inputs                                                             |
| `render.reference`     | Your `reference`; empty without one                                                                          |
| `render.test`          | `true` for test renders                                                                                      |
| `render.kind`          | What was rendered, such as `pdf_template`, `image_url` or `pdf_merge`                                        |
| `render.output_format` | The output format, such as `pdf` or `png`                                                                    |
| `file.filename`        | The file name, such as `invoice-2026-0042.pdf`                                                               |
| `file.name`            | The file name without its extension: `invoice-2026-0042`                                                     |
| `file.ext`             | The extension, without the dot: `pdf`                                                                        |
| `file.format`          | The file format, such as `pdf` or `jpeg`                                                                     |
| `file.index`           | The file's position among the render's files, starting at `0`. Splitting a PDF produces several.             |
| `file.sha256`          | The file's SHA-256 checksum                                                                                  |
| `file.bytes`           | The file size in bytes                                                                                       |
| `workspace_id`         | Your workspace ID, `ws_…`                                                                                    |
| `data`                 | The render's data, such as `data.customer_id`. Unlike in documents, data keys aren't variables of their own. |

`now()` returns the time the render was queued, so a path stays the same between retries. For the render above, `rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q`, queued at `2026-09-17T15:06:33Z`:

```jinja
{{ data.customer_id }}/{{ file.filename }}
→ c_981/invoice-2026-0042.pdf

{{ render.created_at | format_date("%Y/%m") }}/{{ render.reference }}.{{ file.ext }}
→ 2026/09/inv_2026_0042.pdf

{% if render.test %}test/{% endif %}{{ render.template_id }}/{{ render.id }}.{{ file.ext }}
→ tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C/rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q.pdf
```

* Missing values and `null` print as empty text, and empty folders are dropped: `invoices/{{ data.missing }}/a.pdf` becomes `invoices/a.pdf`.
* The path is cleaned so it can't leave the prefix: `.` and `..` segments, leading slashes and control characters are removed, and spaces around each folder name are trimmed.
* Paths are stored as they are, in UTF-8 and without percent-encoding: `Rechnungen/Müller & Söhne/Rechnung für Müller.pdf` arrives under that name.
* A file that already exists at the path is replaced. Include `render.id` or another unique value if every file must be kept.
* The template is fixed when the render is submitted: a new `path_template` applies to later renders only.
* PDF tool results have no data, so `data` is empty in their paths.

A template that doesn't parse is refused with `422 template_syntax_error`, at `/path_template` on the destination or at `/delivery/storage/<i>/path` on a render. A template that fails while rendering, renders an empty path or renders a path longer than 1,024 bytes fails that upload at once, without retries, with the `storage_upload_failed` warning and the `storage.upload_failed` event. The render itself still succeeds.

## Upload results [#upload-results]

The render object's `storage` array reports the uploads. Retrieve the render with `GET /v1/renders/{id}`, or open it in the dashboard, where the same results appear under **Storage uploads**.

```json
"storage": [
  {
    "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V",
    "status": "succeeded",
    "location": "s3://example-documents/invoices/c_981/invoice-2026-0042.pdf",
    "error": null
  }
]
```

| Field            | Description                                                                                                                                                                                                             |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `destination_id` | The destination                                                                                                                                                                                                         |
| `status`         | `pending`: not uploaded yet, or waiting for a retry. `succeeded`: uploaded. `failed`: every attempt failed, or the destination was deleted first. `skipped`: the render didn't succeed, so there was nothing to upload. |
| `location`       | Where the file was written, once the upload has succeeded                                                                                                                                                               |
| `error`          | The last error. A `pending` entry with an `error` is waiting for its next attempt.                                                                                                                                      |

Until a render's uploads have been created, `storage` has one entry per destination, all `pending`. That is what the sync response and the `render.succeeded` webhook event show. After that, it has one entry per destination and file. There is no event for a successful upload: retrieve the render to confirm, and subscribe to `storage.upload_failed` for failures.

| Provider        | `location`                                                                                                                                 |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `s3`            | `s3://<bucket>/<prefix>/<path>`                                                                                                            |
| `s3_compatible` | `<endpoint>/<bucket>/<prefix>/<path>`                                                                                                      |
| `azure_blob`    | `https://<account>.blob.core.windows.net/<container>/<prefix>/<path>`                                                                      |
| `gcs`           | `gs://<bucket>/<prefix>/<path>`                                                                                                            |
| `sftp`          | `sftp://<user>@<host>/<prefix>/<path>`, with `:<port>` after the host when it isn't 22, and `/~` before folders inside the login directory |

## Retries and failures [#retries-and-failures]

The first attempt starts as soon as the render has succeeded. A failed attempt is retried up to five times:

| Attempt | Delay before it | Roughly after the first attempt |
| ------- | --------------- | ------------------------------- |
| 1       | none            | 0                               |
| 2       | 1 minute        | 1 min                           |
| 3       | 5 minutes       | 6 min                           |
| 4       | 15 minutes      | 21 min                          |
| 5       | 1 hour          | 1 h 21 min                      |
| 6       | 4 hours         | 5 h 21 min                      |

> **Fix the destination, not the render**
>
> Each attempt uses the destination's current settings and credentials. If uploads fail because of a wrong key or bucket policy, correct the destination and run the connection test: the remaining retries use the new settings. The path doesn't change.

After the sixth failed attempt, the upload is `failed`. The render then gets the warning `storage_upload_failed`, once, however many of its uploads fail. Its message says whether a copy remains:

| Render          | Message                                                           |
| --------------- | ----------------------------------------------------------------- |
| Hosted          | A storage upload failed; the file is still hosted.                |
| `hosted: false` | A storage upload failed; the hosted copy is kept for a day.       |
| Zero retention  | A storage upload failed; zero retention kept no copy of the file. |

Webhook endpoints subscribed to `storage.upload_failed` also receive one event per failed upload. An endpoint's template filter applies, and per-request webhooks don't receive the event.

```json
{
  "id": "evt_01J9ZQ8D2F4H6K8M0P2R4T6V8X",
  "type": "storage.upload_failed",
  "created_at": "2026-09-17T20:27:41Z",
  "workspace_id": "ws_01J9ZK0P2R4T6V8X0Z2B4D6F8H",
  "region": "eu",
  "data": {
    "object": {
      "object": "storage_upload",
      "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q",
      "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V",
      "destination_name": "Invoice archive",
      "status": "failed",
      "attempts": 6,
      "error": "upload failed: … AccessDenied …",
      "location": null
    }
  }
}
```

Deleting a destination stops its uploads. An upload that hasn't succeeded yet, because it waits for a retry or its render finishes after the delete, fails at its next attempt without connecting: it becomes `failed` with the error `the storage destination was deleted; nothing was uploaded`, and the render gets the warning and the event above, as for any failed upload.

To recover the file after a failed upload: with a hosted copy, download it as usual. With `hosted: false`, `GET /v1/renders/{id}/files/{file_id}` still downloads it for up to a day after the render. Under zero retention nothing is kept, so render it again.

## Zero retention with your own storage [#zero-retention-with-your-own-storage]

Zero retention (`delivery.retention: "none"`, or the workspace setting) keeps no hosted copy and logs no payloads. Combined with storage destinations, it delivers files to your storage without leaving them with us:

```bash
curl https://api-eu.dynamicdocumentapi.com/v1/renders \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" },
    "data": { "employee": { "id": "e_4711", "name": "Alex Example" }, "salary": 5400 },
    "mode": "async",
    "delivery": {
      "retention": "none",
      "storage": [
        { "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "path": "payslips/{{ data.employee.id }}/{{ render.id }}.pdf" }
      ]
    }
  }'
```

* Each file is kept as a transient copy in our EU object storage only until every upload of it has succeeded or finally failed: at most about a day while uploads are retried. Then it is deleted, whatever the outcome.
* Nobody can read the copy through the API. The render shows `files: []`, `GET /v1/renders/{id}/files/{file_id}` answers `404`, and replaying a `binary` or `base64` request with its idempotency key answers `410 file_expired`.
* The render keeps only non-personal metadata and its upload results, `location` included, so you can see where each file went. A zero-retention render's `reference` and `metadata` aren't stored, so `render.reference` is empty in its paths (batch items keep their own); use a value from `data` instead.
* Without `delivery.type`, zero-retention renders are delivered as `none`, which needs a destination, in `delivery.storage` or as the default. Sync ones can use `binary` or `base64` instead; async ones always need a destination. `url` delivery isn't available.
* In [batches](/docs/batches), every item is delivered the same way. Combined files aren't available, and `retry-failed` answers `409 zero_retention`.

## Plans [#plans]

Storage destinations are included in every paid plan, from Starter. Uploads don't cost renders: only the render itself is billed. On the Free plan, creating a destination answers `402 plan_feature_unavailable`.

When a workspace moves to the Free plan, its destinations are kept but stop receiving files until it upgrades again:

* Renders skip the default destination and carry the warning `storage_skipped_plan`. Their files stay hosted, even with `hosted: false`.
* Naming a destination in `delivery.storage` answers `402 plan_feature_unavailable`.
* Zero-retention renders keep no copy, so nothing is uploaded. Async ones, and sync ones without `delivery.type` or with `"none"`, answer `402 plan_feature_unavailable` instead; sync `binary` and `base64` still return the file.

## Limits [#limits]

| Limit                                        | Value                                            |
| -------------------------------------------- | ------------------------------------------------ |
| Destinations per workspace                   | 20                                               |
| Default destinations per workspace           | 1                                                |
| Destinations per render (`delivery.storage`) | 10                                               |
| Destination `name`                           | 80 characters                                    |
| `prefix`                                     | 500 characters                                   |
| `path_template`                              | 500 characters                                   |
| `delivery.storage[].path`                    | 1,024 characters                                 |
| Rendered path below the prefix               | 1,024 bytes                                      |
| Upload attempts                              | 6, over about 5 hours 20 minutes                 |
| Transient copy under zero retention          | Until every upload is final, at most about a day |

## Errors and warnings [#errors-and-warnings]

| Status | Code                       | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| ------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `validation_error`         | Invalid `config` or `credentials`; `errors[]` points at the field, such as `/config/endpoint` (code `url_not_allowed`) for an endpoint that isn't public or doesn't use `https`, `/config/storage_class` or `/config/host_key`. On a render: an unknown destination at `/delivery/storage/<i>/destination_id`, more than 10 destinations, an explicit `"type": "url"` without a hosted copy (`/delivery/hosted`), `hosted: false` with nowhere to deliver the files (`/delivery`), or a zero-retention render with no way to deliver its files (`/delivery/type`, `/mode`) |
| 402    | `plan_feature_unavailable` | The plan doesn't include storage destinations: creating one, naming one in `delivery.storage`, or a zero-retention render that needs the skipped default destination, see [Plans](#plans)                                                                                                                                                                                                                                                                                                                                                                                  |
| 403    | `insufficient_scope`       | The key has neither `storage:read` nor `storage:write` (reads), or lacks `storage:write` (everything else)                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 404    | `not_found`                | No destination with this ID in your workspace                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 409    | `conflict`                 | The workspace already has 20 destinations                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 422    | `template_syntax_error`    | A path template doesn't parse: at `/path_template`, or at `/delivery/storage/<i>/path` on a render                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

Render warnings don't fail the render:

| Code                    | Meaning                                                                                                                                                                          | What to do                                                              |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `storage_upload_failed` | An upload failed after every attempt. The message says whether a copy remains.                                                                                                   | Read `storage[].error`, fix the destination and run the connection test |
| `storage_skipped_plan`  | The plan doesn't include storage destinations, so the default destination was skipped. The files stayed hosted; under zero retention, nothing was uploaded and no copy was kept. | Upgrade to Starter or higher, or remove the default destination         |

See [Errors](/docs/errors) for every other code.

## Related pages [#related-pages]

* [Renders](/docs/renders) for delivery types, retention and the render object
* [Webhooks](/docs/webhooks) to receive `storage.upload_failed` events
* [Batches](/docs/batches) to render and upload many documents in one request
* [PDF tools](/docs/pdf-tools), whose results can be uploaded too
* [Template language](/docs/template-language) for filters such as `format_date`
* [Authentication](/docs/authentication) for API key scopes
* [Plans and limits](/docs/plans-and-limits)
* [Errors](/docs/errors)
* [API reference](/docs/api-reference) for the full request and response schemas
