Bring your own storage
Bring your own storage copies every document a workspace produces into a bucket of yours, in Amazon S3, Cloudflare R2 or any S3-compatible object storage. Your archive, document management system or data pipeline finds each file where it expects it, and your code needs no download step.
It is a delivery destination, not a replacement for Formfeed's storage:
- Formfeed keeps its own copy for the workspace's retention, exactly as without a connection, so
download_url, the CDN, the PDF tools and batch zips work as before. To keep as little as possible at Formfeed, set Output retention (days) under Settings to 1. - The copy is made in the background, after the render. It never slows a render down and never makes it fail. A synchronous response therefore reports
storage.status: "pending"; thestorage.deliveredevent tells you when the object is in your bucket. - Formfeed only writes. It sends
PutObjectrequests and nothing else: it never reads, lists or deletes anything in your bucket. Deleting a render's outputs, a workspace or the organisation leaves the objects in your bucket alone.
Bring your own storage is part of the Pro plan and above. Below Pro, setting up, testing or redelivering a connection answers 403 forbidden with feature: "byo_storage". When an organisation moves to a plan without it, the connection stays but reads as paused (paused_reason: "plan"): documents are no longer copied, copies still waiting end with reason plan, and renders go on without errors. Documents rendered in that time are not recorded and cannot be redelivered later. Back on Pro or above, the connection is active again by itself.
Connect a bucket
Owners and admins connect a bucket in the app, under Settings, on the Your own storage card. The card belongs to the current workspace, and a workspace has at most one connection.
| Provider | What you enter | Endpoint |
|---|---|---|
Amazon S3 (s3) | The bucket's region, for example eu-central-1 | Derived: https://s3.<region>.amazonaws.com |
Cloudflare R2 (r2) | The account ID, and the jurisdiction: default, or eu for a bucket in the EU jurisdiction | Derived: https://<account id>.r2.cloudflarestorage.com, for eu https://<account id>.eu.r2.cloudflarestorage.com |
S3-compatible (s3_compatible) | The service's endpoint (https only); optionally the region it signs with (us-east-1 when left empty) and Address the bucket in the path | As entered |
S3-compatible covers Hetzner Object Storage, IONOS, Scaleway, Wasabi, Backblaze B2, MinIO and Google Cloud Storage (with HMAC keys and the endpoint https://storage.googleapis.com). Enter the S3 endpoint your provider documents for the bucket's location, for example https://fsn1.your-objectstorage.com for Hetzner in Falkenstein. Tick Address the bucket in the path when the service expects the bucket name in the path instead of the host name, as many MinIO servers do. Azure Blob Storage is not S3-compatible and not supported.
Besides the provider, a connection has:
| Field | Meaning |
|---|---|
| Bucket | The bucket's name: 3 to 63 letters, digits, dots, hyphens or underscores, starting and ending with a letter or digit |
| Prefix | Optional folder every object is written below, for example formfeed/, at most 200 characters. Leading and doubled slashes are removed and a trailing / is added; . and .. segments are refused |
| Key template | How each object's key is formed below the prefix, see Object keys. Default: {yyyy}/{mm}/{render_id}/{filename}. At most 512 bytes, only the placeholders listed there, no leading or trailing / |
| Environments | Whose documents are copied: live (the default) for renders with live keys; add test to copy test renders too |
| Public base URL | Optional https address under which you serve the bucket, for example your own domain in front of it, at most 500 characters, without credentials, query or fragment. Each copy's url is built from it; Formfeed does not check that it answers |
| Access key ID, Secret access key | The credentials Formfeed writes with, always entered together |
Test and connect (or Test and save, once connected) tests first: Formfeed writes a small text file, <prefix>.formfeed/connection-test.txt (the line Formfeed connection test and the time), with the credentials you entered, and stores the connection only when that works. If the test fails, the card shows the provider's answer, such as AccessDenied or NoSuchBucket, and nothing is saved: a new connection is not created, and an existing one stays as it was. The API answers 422 storage_test_failed. The test file stays in the bucket, because Formfeed never deletes anything there.
The credentials are stored encrypted in Supabase Vault, and only the render-worker decrypts them, to write. The app and the API never show them again, only the last four characters of the access key ID. To change the bucket, prefix, key template, environments or public base URL, save without entering the secret again; the change is tested with the stored key. A change that moves the endpoint (another provider, AWS region, R2 account or jurisdiction, or S3-compatible endpoint) needs the key again, and a new key is always tested before it replaces the old one.
Endpoints of S3-compatible services are the service address only, without a path or the bucket. They must use https, may not carry credentials in the URL and may not point at a private or internal address. The render-worker checks every address the host name resolves to each time it connects, so a DNS change cannot turn your endpoint into an address inside a private network.
Disconnect on the same card removes the connection and its credentials. Copies still waiting end with reason connection_deleted and raise no event; the objects already in your bucket stay.
Minimal permissions
Formfeed needs to write objects below the prefix, nothing more. Give it a key of its own that can do exactly that.
Amazon S3
Create an IAM user for Formfeed with an access key and only this policy. Replace fennlor-docs with your bucket and formfeed/ with your prefix; without a prefix, the resource is arn:aws:s3:::fennlor-docs/*.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FormfeedWritesBelowThePrefix",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::fennlor-docs/formfeed/*"
}
]
}
The connection test writes formfeed/.formfeed/connection-test.txt, which the same statement covers. Objects are written without an ACL header, so the bucket's own settings decide who can read them, and server-side encryption follows the bucket's default encryption. If that default is SSE-KMS with a customer managed key, also allow the user kms:GenerateDataKey on that key, or every write fails with AccessDenied.
Cloudflare R2
In the Cloudflare dashboard, create an R2 API token with the permission Object Read & Write, limited to this one bucket, and enter its access key ID and secret access key. R2 has no write-only permission, so the token could also read; Formfeed still only writes. The account ID is shown on the R2 overview. Choose the jurisdiction eu for a bucket created in the EU jurisdiction: a bucket addressed in the wrong jurisdiction answers NoSuchBucket.
Hetzner and other S3-compatible services
Create an access key for Formfeed in your provider's console, limited to the bucket where the service allows it. Where the service supports bucket or user policies, allow the key nothing but s3:PutObject on arn:aws:s3:::<bucket>/<prefix>*, as in the AWS policy above.
- Hetzner: S3 credentials are valid for every bucket of their project, with read and write access. Keep the bucket in a project of its own, so the key you give Formfeed opens nothing else; a bucket policy can narrow the key further, and Hetzner's documentation on S3 credentials shows how a policy names a single key.
- Google Cloud Storage: create the HMAC key for a service account and give that account
storage.objects.createandstorage.objects.deleteon the bucket, for example through a custom role. Cloud Storage treats replacing an object as deleting it, and the connection test replaces its test file every time, so the Storage Object Creator role alone fails the second test. Formfeed still never deletes anything.
Object keys
Every copy is written to <prefix><key>. The key comes from the connection's key template, unless the request names its own. The default template is {yyyy}/{mm}/{render_id}/{filename}.
| Placeholder | Value |
|---|---|
{workspace} | The workspace's slug |
{template} | The template's slug |
{environment} | live or test |
{render_id} | The render's id, rnd_… |
{job_id} | The batch's id, job_…, for the items of a batch and its zip |
{yyyy}, {mm}, {dd} | Year, month and day the render or batch was created, in UTC |
{filename} | The stored file name: the request's filename, document.pdf (or .png, .docx, …) without one, outputs.zip for a batch's zip. Characters other than letters, digits, ., -, _, (, ) and spaces become _, and the extension is added when the name lacks it |
A placeholder without a value is empty, and an empty segment is left out: an HTML or URL render and a PDF tool result have no {template}, a render outside a batch has no {job_id}, and a batch's zip has no {render_id} of its own (the batch's id takes its place when the template does not use {job_id}).
With the prefix formfeed/, a render of the template invoice in the workspace fennlor on 23 September 2026, with "filename": "invoice-2026-0042.pdf", lands here:
| Key template | Object key |
|---|---|
{yyyy}/{mm}/{render_id}/{filename} (default) | formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf |
{template}/{yyyy}-{mm}-{dd}/{render_id}-{filename} | formfeed/invoice/2026-09-23/rnd_7da09ab6cd9946b798c187c2a9350852-invoice-2026-0042.pdf |
{environment}/{workspace}/{template}/{render_id}/{filename} | formfeed/live/fennlor/invoice/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf |
An object that already exists under a key is overwritten, so keep {render_id} or a unique filename in the template. A batch's zip has no render id of its own: when the template does not use {job_id}, the batch's id takes the place of {render_id}, so with the default template a zip lands at formfeed/2026/09/job_…/outputs.zip. To keep a batch together, add {job_id}, for example {yyyy}/{mm}/{job_id}/{render_id}/{filename}: a single render still lands at formfeed/2026/09/rnd_…/invoice-2026-0042.pdf, a batch item at formfeed/2026/09/job_…/rnd_…/<filename> and the zip at formfeed/2026/09/job_…/outputs.zip.
Every object carries its Content-Type and this metadata:
| Header | Value |
|---|---|
x-amz-meta-formfeed-render-id | The render's id, rnd_… |
x-amz-meta-formfeed-job-id | The batch's id, job_…, on batch items and the zip |
x-amz-meta-sha256 | The SHA-256 of the file, the same as the render's output_sha256 |
No ACL header is sent, so the bucket's policies decide who can read an object, and server-side encryption follows the bucket's default.
Name the object in the request
storage in a request controls the copy of that one document:
{
"template": "invoice",
"data": { "invoice": { "number": "RE-1001" } },
"filename": "RE-1001.pdf",
"storage": { "key": "invoices/2026/RE-1001.pdf" }
}
storage | Effect |
|---|---|
Left out or null | Copied under the key template |
{ "key": "invoices/2026/RE-1001.pdf" } | Copied to exactly this key below the prefix, here formfeed/invoices/2026/RE-1001.pdf |
false | This document is not copied |
- The key is relative: no leading or trailing
/, no.,..or empty segments, no backslashes or control characters, at most 1024 bytes. It always lands below the prefix. A key that breaks a rule answers400 invalid_request. - An object that already exists under the key is overwritten; Formfeed does not check first.
- A request with its own key is never answered from the deduplication cache, so every request writes its key.
- Without a connection, a request with
storage.keyanswers409 storage_not_configuredbefore anything is rendered or charged.storage: falseis accepted either way. - A request the connection does not take (a test render while only
liveis copied, or any render while the plan does not include bring your own storage) is accepted and not copied, with or without a key; itsstorageisnull. While the connection is paused after failures, a request is accepted as well: itsstorageis a failed copy with reasonconnection_paused, which a redelivery sends once the connection works again. - The option works the same on
POST /pdf/merge,/pdf/protect,/pdf/watermarkand/pdf/convert. A multipart upload to/pdf/convertsends it as a form field holding JSON text:storage={"key":"offers/A-17.pdf"}orstorage=false. - In a batch,
storagebelongs to each item. The batch's zip, when the batch asks for one, is always copied under the key template.
- TypeScript
- Python
const render = await client.renders.create({
template: 'invoice',
data: { invoice: { number: 'RE-1001' } },
storage: { key: 'invoices/2026/RE-1001.pdf' },
});
console.log(render.storage?.status); // 'pending'
render = client.renders.create(
template="invoice",
data={"invoice": {"number": "RE-1001"}},
storage={"key": "invoices/2026/RE-1001.pdf"},
)
print(render.storage.status) # pending
The TypeScript SDK types the option as StorageOption and the field as Render.storage (StorageDelivery); in Python the option is the storage= keyword and the field render.storage.
What is copied
- The outputs of template, HTML and URL renders, Word and PowerPoint templates included
- The XML of an e-invoice that asked for
"xml": "both", as a second object beside the PDF: under the key template its{filename}ends in.xml, and with astorage.keyit takes that key with the extension replaced by.xml - The results of the PDF tools,
/pdf/convertincluded - Every item of a batch, and the batch's zip when the batch asked for one
- Test renders only when the connection's environments include
test
Never copied: the HTML Formfeed keeps of a failed render, and documents rendered while the plan does not include bring your own storage. Documents rendered while the connection is paused after failures are recorded as failed copies (connection_paused), so a redelivery sends them once the connection works again.
A deduplicated answer is the earlier render, and it carries that render's storage: no second copy is written, and with the default key template the object is already in place under the earlier render's id. An earlier render whose copy failed, that was made before the connection existed, or that said storage: false, is not reused: the request renders again, so your bucket gets its copy. A copy costs no units.
The storage field
Every render and job carries storage, the copy in your bucket, or null when none applies: no connection, a plan without bring your own storage, storage: false, a test render the connection does not take, a failed render, or a render still in the queue. While the connection is paused after failures, storage is a failed copy with reason connection_paused.
"storage": {
"status": "pending",
"bucket": "fennlor-docs",
"key": "formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"url": "https://files.fennlor.example/formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"reason": null,
"error": null
}
| Field | Meaning |
|---|---|
status | pending, delivered or failed |
bucket, key | Your bucket and the full object key, prefix included |
url | The object under the connection's public base URL; null without one, and for a failed copy |
reason | Why the copy failed, see the reasons; null otherwise |
error | For a failed copy, the provider's answer reduced to code, http_status and the first line of message; null otherwise |
- A synchronous
200reportspending, because the copy starts once the render is stored.GET /renders/{id}shows how far it got. - The
render.completedevent carries the state from when the event was created, usuallypending. Listen forstorage.deliveredto act on the copy. - On a job,
storageis the zip's copy; every item initemscarries its own.
Events
Two webhook events report copies. Both are opt-in: an endpoint receives them only when its events list names them, which no endpoint does until you add them, existing ones included. formfeed listen forwards them when --events names them; without --live only those of test renders, which the connection copies only when its environments include test. A render's own webhook_url does not receive them. The endpoint's environments apply, by the environment of the render that was copied.
| Event | Sent when |
|---|---|
storage.delivered | A copy is in your bucket |
storage.failed | A copy failed for good, with reason permanent, rejected or exhausted |
Attempts that will be retried raise no event, and neither does a copy that ends for one of the other reasons.
{
"id": "evt_9d4b2f7a1c3e4d5f8a6b0c2e4f6a8b1d",
"type": "storage.delivered",
"created_at": "2026-09-23T10:00:03Z",
"workspace_id": "3a6f…",
"data": {
"id": "dlv_4c1e7a9b2d3f4a5b8c6d0e1f2a3b4c5d",
"status": "delivered",
"bucket": "fennlor-docs",
"key": "formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"url": "https://files.fennlor.example/formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"reason": null,
"error": null,
"render_id": "rnd_7da09ab6cd9946b798c187c2a9350852",
"job_id": null,
"output": "pdf",
"content_type": "application/pdf",
"bytes": 48213,
"sha256": "b41e…",
"etag": "\"5d41402abc4b2a76b9719d911017c592\"",
"attempts": 1,
"created_at": "2026-09-23T10:00:01Z",
"delivered_at": "2026-09-23T10:00:02Z"
}
}
A storage.failed event has the same shape, with status: "failed", a reason, the provider's error and delivered_at: null:
"data": {
"id": "dlv_7b2e9c4a1d6f4e8a9c0b3d5f7a2c4e6b",
"status": "failed",
"reason": "permanent",
"error": { "code": "AccessDenied", "http_status": 403, "message": "Access Denied" },
"attempts": 1,
"delivered_at": null,
…
}
data holds the fields of the storage field and:
| Field | Meaning |
|---|---|
id | The copy, dlv_… |
render_id, job_id | The render, and the batch for batch items and zips; the zip has no render_id |
output | pdf, png, jpg, webp, docx, pptx, zip, or xml for an e-invoice's XML |
content_type, bytes, sha256 | The file's type, size and SHA-256 |
etag | The ETag your bucket answered with |
attempts | Attempts made |
created_at, delivered_at | When the copy was queued, and when it arrived |
The SDKs type data as StorageDeliveryEvent:
- TypeScript
- Python
import { parseWebhookEvent, type StorageDeliveryEvent } from '@formfeed/sdk';
const event = await parseWebhookEvent(
process.env.FORMFEED_WEBHOOK_SECRET!,
req.get('webhook-signature'),
req.body,
);
if (event.type === 'storage.failed') {
const copy = event.data as StorageDeliveryEvent;
log.warn(
{ key: copy.key, reason: copy.reason, error: copy.error },
'copy to our bucket failed',
);
}
from formfeed import StorageDeliveryEvent, parse_webhook_event
event = parse_webhook_event(os.environ["FORMFEED_WEBHOOK_SECRET"], request.headers.get("Webhook-Signature"), request.get_data())
if event.type == "storage.failed":
copy = StorageDeliveryEvent.model_validate(event.data)
log.warning("copy to our bucket failed: %s %s", copy.key, copy.reason)
Retries and pausing
Each copy is sent right after the render. What happens when your bucket refuses it depends on the answer:
| Your bucket answers | What happens |
|---|---|
No answer: a timeout (TimeoutError, RequestTimeout), a connection error or a host name that does not resolve right now (NetworkError) | Tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, the same schedule as webhooks. If the sixth attempt fails too, the copy fails with reason exhausted |
A server error (5xx, InternalError, ServiceUnavailable), throttling (SlowDown, Throttling, TooManyRequests, 429), a clock skew (RequestTimeTooSkewed) or a temporary redirect (307, TemporaryRedirect) | Retried the same way |
AccessDenied, InvalidAccessKeyId, SignatureDoesNotMatch, NoSuchBucket, InvalidBucketName, AllAccessDisabled, AccountProblem, expired or invalid credentials (ExpiredToken, InvalidToken), a wrong region (PermanentRedirect, AuthorizationHeaderMalformed, 301), an endpoint Formfeed may not reach (EndpointBlocked: a private or internal address), or any other 401, 403 or 404 | Fails at once with reason permanent, because retrying cannot help, and counts towards pausing |
Any other 4xx for this one object, such as EntityTooLarge | Fails at once with reason rejected, without counting towards pausing |
A copy that fails as exhausted does not count towards pausing either.
Pausing. After five permanent failures in a row, Formfeed pauses the connection (status: "paused", paused_reason: "failures") and sends one email to the organisation's owners and admins. A successful copy in between resets the count. Renders go on as usual while the connection is paused, but nothing is copied: the copies still waiting, and those of documents rendered during the pause, are recorded as failed with reason connection_paused.
To resume, fix the key or the bucket, then press Test again on the Your own storage card (or call POST /workspaces/{id}/storage/test), which tests with the stored key. A passing test resumes it; a failing one only updates the last error shown. When the fix is a new key, enter it and press Test and save: a passing test stores it and resumes the connection too. Then redeliver what failed, which includes every document rendered while the connection was paused.
Reasons
reason | Meaning | Event |
|---|---|---|
permanent | The credentials, the bucket or the endpoint are wrong | storage.failed |
rejected | Your bucket refused this one object | storage.failed |
exhausted | Every retry failed | storage.failed |
source_gone | Formfeed's copy expired or was deleted before it could be sent | none |
connection_deleted | The connection was deleted while the copy waited | none |
connection_paused | The connection was paused while the copy waited, or when the document was rendered | none |
plan | The plan no longer includes bring your own storage | none |
workspace_deleted | The workspace was deleted | none |
Redelivery
POST /v1/workspaces/{id}/storage/redeliver sends failed copies again from the documents Formfeed still keeps, without rendering anything again. In the app, Retry failed deliveries on the Your own storage card does it for every failed copy of the workspace. The body chooses which:
| Body | Sends again |
|---|---|
{ "render_id": "rnd_…" } | The failed copy of one render |
{ "job_id": "job_…" } | The failed copies of one batch, its zip included |
{ "since": "2026-09-22T00:00:00Z", "until": "2026-09-23T00:00:00Z" } | The failed copies queued in that time, which is when their documents finished; either bound may be left out. ISO 8601 with a time zone |
{}, or no body | Every failed copy of the workspace |
The answer is 202 with how many were queued: { "requeued": 42, "truncated": false }. A call queues at most 10,000; with truncated: true, call again. Each copy goes to the connection's current bucket and key (its own storage.key, or the current key template, below the current prefix), starts again at attempt 1 and follows the usual retries.
Only documents Formfeed still keeps can be sent again: a copy whose output is past the workspace's retention, or was deleted early, is skipped, and so is one that failed with source_gone. While the connection is paused, after failures or by the plan, the call answers 409 conflict with paused_reason: test it first, or move back to a plan that includes bring your own storage. A workspace without a connection answers 409 storage_not_configured.
What Formfeed never does
- Read, list or delete in your bucket. The only request it sends is
PutObject, the connection test included. - Remove your copies. Deleting a render's outputs, their expiry at the end of the workspace's retention, and deleting a workspace or the organisation leave every object in your bucket where it is. Delete them yourself when your own retention rules require it.
- Check before overwriting. An object under the same key is replaced.
- Show the secret again. Only the last four characters of the access key ID appear in the app and the API. Deleting a workspace or the organisation destroys the stored credentials at once, not after the 30 days in which other workspace data is removed.
Where the documents go
The copy is made by the render-worker of the region that rendered the document; for the EU region that is Frankfurt. From there the document leaves Formfeed's storage in the EU for your bucket, wherever that bucket is: you choose the destination. Your own bucket is not a Formfeed sub-processor, so the sub-processor list does not name it. To keep documents in the EU, choose a bucket in an EU region, or the EU jurisdiction on R2. See Data residency.
API routes
The app manages a connection through these routes. In this version they take only the session of an owner or admin signed in to the app: no API key scope covers storage, so an API key, or an AI agent connected through OAuth, gets 403 forbidden.
| Route | Does |
|---|---|
GET /workspaces/{id}/storage | Reads the connection without its secret, with access_key_hint (the last four characters of the access key ID), status, paused_reason, last_error, and the copies of the last 24 hours by status in deliveries_24h |
PUT /workspaces/{id}/storage | Tests, then creates (201) or replaces (200) the connection. A failed test answers 422 storage_test_failed with the provider's error and the test object's key |
DELETE /workspaces/{id}/storage | Removes the connection (204) |
POST /workspaces/{id}/storage/test | Tests the stored connection again; a pass resumes a paused one and answers 200 with ok, the test object's key and the connection. A failure answers 422 storage_test_failed, a workspace without a connection 409 storage_not_configured |
POST /workspaces/{id}/storage/redeliver | Redelivery |
{id} is the workspace's id. A workspace of another organisation answers 404 not_found, and so do GET and DELETE when the workspace has no connection.