Save PDFs to your own bucket
A storage destination is a bucket of yours that Typefold uploads finished PDFs to. Save it once in your workspace, then name it in a render request. The PDF is in your bucket before the render counts as succeeded.
Any S3-compatible service works, such as Amazon S3, Cloudflare R2, Backblaze B2, Hetzner Object Storage and MinIO.
Before you start
Section titled “Before you start”- A bucket and an access key for it from your storage provider. The key needs permission
to write objects (
PutObject); permission to delete them is optional. How to create one: Amazon S3, Cloudflare R2, Backblaze B2, Hetzner Object Storage, MinIO. - A workspace owner or admin to save the destination in Studio, or an
API key with
destination:manage. Rendering to a saved destination needs onlyrender:create. PDF_URLandAPI_KEYset in your terminal as in the quickstart.
1. Save the destination
Section titled “1. Save the destination”Typefold makes a test upload before it saves the destination: it writes an object named
.typefold-connection-test under the prefix, then deletes it. If the key may not delete, the
object stays. If the test fails, nothing is saved and the answer says why, so every saved
destination is known to work.
curl -X POST "$PDF_URL/v1/storage-destinations" \ -H "x-api-key: $API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Invoices", "endpoint": "https://s3.example.com", "region": "eu-central-1", "bucket": "example-invoices", "prefix": "pdf", "accessKeyId": "<access key ID>", "secretAccessKey": "<secret access key>" }'The response holds the destination, without its secret access key. A failed test upload answers
400 BAD_REQUEST with the reason.
The client cannot save destinations. In Studio, open Manage → Storage destinations, add a destination and fill in the fields. Studio saves it once the test upload succeeds.
The client cannot save destinations. In Studio, open Manage → Storage destinations, add a destination and fill in the fields. Studio saves it once the test upload succeeds.
The client cannot save destinations. In Studio, open Manage → Storage destinations, add a destination and fill in the fields. Studio saves it once the test upload succeeds.
The client cannot save destinations. In Studio, open Manage → Storage destinations, add a destination and fill in the fields. Studio saves it once the test upload succeeds.
Only owners and admins see Manage → Storage destinations.
Fields
Section titled “Fields”| Field | Default | Description |
|---|---|---|
name |
required | What render requests call it, up to 120 characters. Unique in the workspace, ignoring case. |
endpoint |
none | The service’s S3 endpoint; see endpoints. Leave it out for Amazon S3. |
region |
us-east-1 |
The bucket’s region. auto for Cloudflare R2. |
bucket |
required | The bucket’s name. |
prefix |
none | A folder put in front of every object key, such as pdf. Up to 512 characters. |
forcePathStyle |
false |
true puts the bucket in the URL’s path instead of its host name. MinIO and some other services need it. |
accessKeyId |
required | The access key’s ID. |
secretAccessKey |
required | Stored encrypted and never shown or returned again. |
Endpoints
Section titled “Endpoints”| Service | endpoint |
region |
|---|---|---|
| Amazon S3 | Leave it out | The bucket’s region |
| Cloudflare R2 | https://<account-id>.r2.cloudflarestorage.com |
auto |
| Backblaze B2 | https://s3.<region>.backblazeb2.com, such as us-west-004 |
The same region |
| Hetzner Object Storage | https://<location>.your-objectstorage.com, such as fsn1 |
The same location |
| MinIO and others | Your server’s S3 address, with "forcePathStyle": true |
As the service documents |
On the hosted service the endpoint must be an https:// address on the public internet. A
self-hosted installation can also allow private and http:// addresses; see
on your own network.
2. Render to it
Section titled “2. Render to it”Add destination to a render request with the destination’s name:
curl "$PDF_URL/v1/renders" \ -H "x-api-key: $API_KEY" \ -H "content-type: application/json" \ -d '{ "templateVersionId": "c7e0b9a4-1f2d-4c3b-9a8e-5d6f7a8b9c0d", "data": { "invoice": { "number": "INV-2041" } }, "destination": { "name": "Invoices", "path": "2026/{renderId}.pdf" }, "waitMs": 10000 }'using Typefold.Client;
var client = new TypefoldClient( new Uri(Environment.GetEnvironmentVariable("PDF_URL")!), Environment.GetEnvironmentVariable("API_KEY")!);
Render render = await client.CreateRenderAsync(new TemplateVersionRenderRequest{ TemplateVersionId = "c7e0b9a4-1f2d-4c3b-9a8e-5d6f7a8b9c0d", Data = new { invoice = new { number = "INV-2041" } }, Destination = new RenderDestination { Name = "Invoices", Path = "2026/{renderId}.pdf" },});render = await client.WaitForRenderAsync(render);
if (render.State == RenderState.Succeeded){ Console.WriteLine(render.Result!.Destination!.Key);}import { RenderState, TypefoldClient } from "@typefold/client";
const client = new TypefoldClient({ baseUrl: process.env.PDF_URL!, apiKey: process.env.API_KEY!,});
let render = await client.createRender({ templateVersionId: "c7e0b9a4-1f2d-4c3b-9a8e-5d6f7a8b9c0d", data: { invoice: { number: "INV-2041" } }, destination: { name: "Invoices", path: "2026/{renderId}.pdf" },});render = await client.waitForRender(render);
if (render.state === RenderState.Succeeded) { console.log(render.result?.destination?.key);}import { RenderState, TypefoldClient } from "@typefold/client";
const client = new TypefoldClient({ baseUrl: process.env.PDF_URL, apiKey: process.env.API_KEY,});
let render = await client.createRender({ templateVersionId: "c7e0b9a4-1f2d-4c3b-9a8e-5d6f7a8b9c0d", data: { invoice: { number: "INV-2041" } }, destination: { name: "Invoices", path: "2026/{renderId}.pdf" },});render = await client.waitForRender(render);
if (render.state === RenderState.Succeeded) { console.log(render.result.destination.key);}import os
from typefold import ( RenderDestination, RenderState, TemplateVersionRenderRequest, TypefoldClient,)
client = TypefoldClient(os.environ["PDF_URL"], os.environ["API_KEY"])
render = client.create_render( TemplateVersionRenderRequest( template_version_id="c7e0b9a4-1f2d-4c3b-9a8e-5d6f7a8b9c0d", data={"invoice": {"number": "INV-2041"}}, destination=RenderDestination(name="Invoices", path="2026/{renderId}.pdf"), ))render = client.wait_for_render(render)
if render.state == RenderState.SUCCEEDED: print(render.result.destination.key)destination field |
Default | Description |
|---|---|---|
name |
required | A saved destination’s name. An unknown name answers 400 BAD_REQUEST. |
path |
{renderId}.pdf |
The object’s key after the prefix. {renderId} is replaced with the render’s ID. An image defaults to its format’s extension, such as {renderId}.png. Up to 512 characters; the whole key may be at most 1,024 bytes, or the request answers 400 BAD_REQUEST. |
keepCopy |
true |
false keeps no copy in Typefold; see keep no copy. |
- The object key is the prefix, a slash, then
path. Both are slash-separated names without a leading or trailing slash or.and..parts. With the destination above, the PDF lands atpdf/2026/<render ID>.pdf. Without a prefix it ispathalone. - An object already at that key is replaced.
destinationworks on everyPOST /v1/rendersrequest that renders a template version or your own HTML, and on batch items. Previews, in Studio or with apreviewbody, and the Reports API, which returns the file directly, don’t take it.- Webhooks are sent as usual. A
render.succeededevent means the PDF is in your bucket.
3. Find the PDF
Section titled “3. Find the PDF”A succeeded render’s result.destination says where the PDF went:
{ "id": "0b6f0f8e-3c55-4c1e-a3a7-8d1a6f2c9e41", "state": "succeeded", "result": { "downloadUrl": "https://pdf.example.com/v1/storage/...", "expiresAt": "2026-10-08T09:25:13.480Z", "destination": { "name": "Invoices", "key": "pdf/2026/0b6f0f8e-3c55-4c1e-a3a7-8d1a6f2c9e41.pdf" }, "pageCount": 2, "outputBytes": 48213, "durationMs": 1840 }}key is the object’s full key in the bucket. Typefold still keeps its own copy for
30 days, which downloadUrl and the clients’ download methods
fetch.
Keep no copy
Section titled “Keep no copy”With "keepCopy": false, the PDF goes only to your bucket:
- The succeeded render has no
downloadUrlorexpiresAt, so the clients’ download methods raise an error for it, and their render methods that return the PDF refusekeepCopy: falsebefore creating a render. Create the render and wait for it instead, as in step 2. - A batch with such items cannot be downloaded as one combined PDF:
that request answers
409 CONFLICT. - The render itself, with its
resultanddestination, is kept as usual.
If the upload fails
Section titled “If the upload fails”The render then fails, with a destination_upload_failed diagnostic whose message names the
cause, such as
the keys may not write to this bucket or prefix (AccessDenied, HTTP 403).
- Timeouts, dropped connections, throttling (
429,SlowDown) and5xxanswers from the service are retried with the render’s other retries. Wrong keys, permissions, bucket, region or an unknown host are final: fix the destination or the key’s permissions, then render again. - A render still queued when its destination is deleted fails the same way. Renaming the destination does not affect renders already created.
- New keys, a new bucket and other changes apply to renders still queued, because the destination is read when the PDF is uploaded. Only a new prefix applies to new renders alone: the object key is fixed when the render is created.
- A render cancelled, or failed by its time limit, after its upload has started may still leave its PDF in your bucket.
Manage destinations
Section titled “Manage destinations”In Studio, open Manage → Storage destinations to change or delete destinations.
- A workspace can have up to 10 destinations. An eleventh answers
409 CONFLICT. - Saving a change runs the test upload again, except when you only rename the destination.
- The secret access key is never shown again. Leave the keys out of a change to keep the saved
ones. Changing the endpoint, region, bucket or
forcePathStyleneeds the secret access key again. - Deleting a destination deletes its saved keys. Objects already in your bucket stay.
With the API, every operation needs the destination:manage scope. Request and response fields
are in the API reference.
| Operation | What it does |
|---|---|
POST /v1/storage-destinations |
Test and save a destination. Answers 201 Created. |
GET /v1/storage-destinations |
List all destinations, without secret access keys. |
GET /v1/storage-destinations/{id} |
Read one destination, without its secret access key. |
PATCH /v1/storage-destinations/{id} |
Change only the fields you send. null removes endpoint or prefix. Changing endpoint, region, bucket or forcePathStyle needs secretAccessKey. |
DELETE /v1/storage-destinations/{id} |
Delete the destination and its saved keys. |
On your own network
Section titled “On your own network”A self-hosted installation can upload to services on private addresses or over plain http://,
such as a MinIO server next to it, with DESTINATION_ALLOW_PRIVATE_NETWORKS=true; see
configuration.