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
- 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
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
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. |
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 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. |
{
"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
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
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
| 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
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.
{
"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:
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 are write-only. Send them in credentials when you create a destination; responses only say whether they are set:
"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
credentialsobject withPATCH /v1/storage-destinations/{id}. Requests withoutcredentialskeep 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. On an SFTP server, the user must be able to create folders and to write, rename, read and delete files.
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:
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:
{
"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 |
credentials | Required when you create the destination. Write-only. |
path_template | Where files go below the prefix, see Path templates. Up to 500 characters. |
is_default | true makes this 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. |
The response adds the id (dst_…), last_test (see 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. |
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
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
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.
curl -X POST https://api-eu.dynamicdocumentapi.com/v1/storage-destinations/dst_01J9ZN2B4D6F8H0K2M4P6R8T0V/test \
-H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY"{
"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
A render is uploaded to the destinations in delivery.storage when the request sends it, and to the default destination otherwise:
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 |
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 |
Uploads start once the render has succeeded. A sync response arrives before they finish, so its upload results are still pending; see 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 for every item, 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
Uploading doesn't replace our hosted copy unless you ask for that. Whether a render keeps one is decided by:
delivery.hosted, when the request sends it- otherwise the render's destinations, those in
delivery.storageor else the default destination:falsewhen every one of them haskeep_hosted_copy: false,truein 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 | 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 can't combine either; send delivery.hosted: true to keep them.
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:
- the render's
delivery.storage[].path - the destination's
path_template <render_id>/<filename>, such asrnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q/invoice-2026-0042.pdf
Path templates use the 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:
{{ 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
nullprint as empty text, and empty folders are dropped:invoices/{{ data.missing }}/a.pdfbecomesinvoices/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.pdfarrives under that name. - A file that already exists at the path is replaced. Include
render.idor another unique value if every file must be kept. - The template is fixed when the render is submitted: a new
path_templateapplies to later renders only. - PDF tool results have no data, so
datais 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
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.
"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
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.
{
"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 (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:
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}answers404, and replaying abinaryorbase64request with its idempotency key answers410 file_expired. - The render keeps only non-personal metadata and its upload results,
locationincluded, so you can see where each file went. A zero-retention render'sreferenceandmetadataaren't stored, sorender.referenceis empty in its paths (batch items keep their own); use a value fromdatainstead. - Without
delivery.type, zero-retention renders are delivered asnone, which needs a destination, indelivery.storageor as the default. Sync ones can usebinaryorbase64instead; async ones always need a destination.urldelivery isn't available. - In batches, every item is delivered the same way. Combined files aren't available, and
retry-failedanswers409 zero_retention.
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 withhosted: false. - Naming a destination in
delivery.storageanswers402 plan_feature_unavailable. - Zero-retention renders keep no copy, so nothing is uploaded. Async ones, and sync ones without
delivery.typeor with"none", answer402 plan_feature_unavailableinstead; syncbinaryandbase64still return the file.
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
| 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 |
| 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 for every other code.
Related pages
- Renders for delivery types, retention and the render object
- Webhooks to receive
storage.upload_failedevents - Batches to render and upload many documents in one request
- PDF tools, whose results can be uploaded too
- Template language for filters such as
format_date - Authentication for API key scopes
- Plans and limits
- Errors
- API reference for the full request and response schemas
Webhooks
Receive signed render events, manage endpoints and retries, and verify Standard Webhooks signatures in Node.js and Python.
Email delivery
Email rendered files through your own Brevo, SMTP, Postmark, Resend, Amazon SES or Microsoft 365 account, with connections, sender checks, email rules, test renders, send statuses, events, limits, errors and billing.