DynamicDocumentAPI

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.

View as Markdown

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.

providerStorageCredentials
s3Amazon S3Access keys
s3_compatibleServices with an S3 API, such as Cloudflare R2, Backblaze B2, Wasabi, Scaleway, OVHcloud, Hetzner Object Storage or MinIOAccess keys
azure_blobAzure Blob StorageSAS URL or connection string
gcsGoogle Cloud StorageService account key
sftpYour own server over SFTPPassword 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 fieldDescription
bucketRequired. The bucket name, 3 to 63 characters
regionRequired. The bucket's region, such as eu-central-1
prefixOptional folder for every file, up to 500 characters
storage_classOptional. 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.
sseOptional. 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_idThe KMS key for aws:kms
force_path_styleOptional, 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 fieldDescription
access_key_idRequired
secret_access_keyRequired
session_tokenOptional: 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 fieldDescription
endpointRequired. The service's URL, with https:// and a public host name or address. Any port works.
regionOptional, default auto. The region name your service expects.
force_path_styleOptional, 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_styleAmazon S3S3-compatible
falsehttps://<bucket>.s3.<region>.amazonaws.com/<key>, the defaulthttps://<bucket>.<endpoint host>/<key>
truehttps://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 fieldDescription
accountRequired. The storage account name, not its URL: 3 to 24 lowercase letters and digits
containerRequired. 3 to 63 lowercase letters, digits and -, starting with a letter or digit
prefixOptional folder for every file

Send one of these credentials:

credentials fieldDescription
sas_urlA shared access signature URL, such as https://exampledocs.blob.core.windows.net/invoices?sv=…&sig=…. We use its token with account and container.
connection_stringA connection string that contains the account key (AccountKey=…)

Google Cloud Storage

FieldDescription
config.bucketRequired. The bucket name, 3 to 222 characters
config.prefixOptional folder for every file
credentials.service_account_jsonRequired. The JSON key file of a service account, as Google Cloud created it, sent as a string

SFTP

config fieldDescription
hostRequired. The server's public host name or IP address, without sftp://, a user or a port
portOptional, default 22
usernameRequired
host_keyRequired. The SHA-256 fingerprint of the server's host key, as ssh-keygen -lf prints it: SHA256: followed by 43 characters
prefixOptional 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 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. 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"
}
FieldDescription
nameRequired. Up to 80 characters, shown in the dashboard and in storage.upload_failed events
providerRequired. s3, s3_compatible, azure_blob, gcs or sftp. It can't be changed later; create a new destination instead.
configRequired. The provider's settings, see Destinations
credentialsRequired when you create the destination. Write-only.
path_templateWhere files go below the prefix, see Path templates. Up to 500 characters.
is_defaulttrue makes this the default destination. Default false.
keep_hosted_copyDefault 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.

EndpointDescription
GET /v1/storage-destinationsList all destinations, sorted by name, in one page
POST /v1/storage-destinationsCreate 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}/testRun 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:

StepWhat it checks
connectSFTP: the server can be reached
host_keySFTP: the server shows the pinned host key
credentialsThe settings and credentials can be used. On SFTP, the server accepted the login.
sftpSFTP: the SFTP service started after the login (only listed when it didn't)
write, read, deleteThe probe was written, read back and deleted
delivererOnly 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 fieldDescription
storageUp 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.
hostedWhether we keep a hosted copy too, see Hosted copy
typeurl 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:

  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.

RenderOur copyFile URLs
HostedKept for the render's retention period, like any renderYes
hosted: falseDeleted 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 retentionA transient copy, see Zero retention with your own storageNo

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:

  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 with these variables:

VariableValue
render.idThe render ID, rnd_…
render.created_atWhen the render was queued, in UTC, such as 2026-09-17T15:06:33Z
render.template_idThe template ID, tpl_…; empty for other inputs
render.referenceYour reference; empty without one
render.testtrue for test renders
render.kindWhat was rendered, such as pdf_template, image_url or pdf_merge
render.output_formatThe output format, such as pdf or png
file.filenameThe file name, such as invoice-2026-0042.pdf
file.nameThe file name without its extension: invoice-2026-0042
file.extThe extension, without the dot: pdf
file.formatThe file format, such as pdf or jpeg
file.indexThe file's position among the render's files, starting at 0. Splitting a PDF produces several.
file.sha256The file's SHA-256 checksum
file.bytesThe file size in bytes
workspace_idYour workspace ID, ws_…
dataThe 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 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

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
  }
]
FieldDescription
destination_idThe destination
statuspending: 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.
locationWhere the file was written, once the upload has succeeded
errorThe 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.

Providerlocation
s3s3://<bucket>/<prefix>/<path>
s3_compatible<endpoint>/<bucket>/<prefix>/<path>
azure_blobhttps://<account>.blob.core.windows.net/<container>/<prefix>/<path>
gcsgs://<bucket>/<prefix>/<path>
sftpsftp://<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:

AttemptDelay before itRoughly after the first attempt
1none0
21 minute1 min
35 minutes6 min
415 minutes21 min
51 hour1 h 21 min
64 hours5 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:

RenderMessage
HostedA storage upload failed; the file is still hosted.
hosted: falseA storage upload failed; the hosted copy is kept for a day.
Zero retentionA 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} 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, every item is delivered the same way. Combined files aren't available, and retry-failed answers 409 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 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

LimitValue
Destinations per workspace20
Default destinations per workspace1
Destinations per render (delivery.storage)10
Destination name80 characters
prefix500 characters
path_template500 characters
delivery.storage[].path1,024 characters
Rendered path below the prefix1,024 bytes
Upload attempts6, over about 5 hours 20 minutes
Transient copy under zero retentionUntil every upload is final, at most about a day

Errors and warnings

StatusCodeWhen
400validation_errorInvalid 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)
402plan_feature_unavailableThe 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
403insufficient_scopeThe key has neither storage:read nor storage:write (reads), or lacks storage:write (everything else)
404not_foundNo destination with this ID in your workspace
409conflictThe workspace already has 20 destinations
422template_syntax_errorA path template doesn't parse: at /path_template, or at /delivery/storage/<i>/path on a render

Render warnings don't fail the render:

CodeMeaningWhat to do
storage_upload_failedAn 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_planThe 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.

On this page