Upload a file to S3 or R2 with the AWS CLI
To upload a thirds.ai file to S3, you download the finished PDF or image, copy it to your bucket with aws s3 cp, and check that the stored copy has the same SHA-256. You use thirds.ai to make branded PDFs and images, such as invoices and reports, from a design you save once. Your thirds.ai account keeps each file for a set time, from 30 days to a year, depending on your plan. Your own S3 or Cloudflare R2 bucket keeps the copy for as long as you want, and R2 needs only two extra options.
Give this prompt to your AI agent. The agent does the task for you.
Read https://thirds.ai/docs/integrations/object-storage and help me store a verified thirds.ai output in my private S3 or R2 bucket and recover upload failures without another render
With thirds.ai, you send new details from your own code and get a finished file with your logo, colours, and fonts. You don't design each file by hand. With a copy of each file in your own bucket, you don't have to go back to thirds.ai for old files, and your app decides who can open them. The upload doesn't make the file again, so it costs you no credits. These steps start once your API workflow or webhook worker has a finished file.
If you move from a service that saved files to your bucket for you, this upload replaces that step. The cloudlayer.io comparison lists what else changes. If your old service hosted each image link for you, the HTML/CSS to Image comparison shows how to add this storage step.
You don't need code to make a file in the editor. Click Export to download it, then add it with the Upload button in the S3 console. How to make a template you can reuse shows the editor way.
Why keep thirds.ai files in your own bucket?
Keep each thirds.ai file in your own bucket because thirds.ai deletes a finished account file when its time runs out, and a new download link can't bring it back. That's up to 365 days on a paid plan and up to 30 days on the Free plan, with credit packs only, or during the $1 Starter trial. If your paid plan ends, a longer-kept file drops to 30 days. Each signed download link works for 15 minutes. A copy in your own private S3 or R2 bucket stays as long as your storage policy says. The download rules name the two deadlines: artifact.expires_at for the file and download.expires_at for the link.
A signed thirds.ai link is a short-lived way to collect the file. It isn't a storage record, so don't save the link in place of the file. Keep your app's access checks in front of any download you offer your customers.
Prepare your bucket
To prepare an S3 or R2 bucket for thirds.ai files, you make a private bucket, give one upload identity access to it, and set up an AWS CLI profile. You need curl and AWS CLI v2 on macOS or Linux. You also need the job ID of a finished output, or a file you already downloaded from it. See file downloads for the response types and download limits.
Create a private bucket in your own account. Give the upload identity access only to the required bucket or prefix. It needs to write and read the output objects for verification. Configure credentials through your existing AWS credential provider or a named CLI profile. Keep storage credentials separate from your thirds.ai key. The upload commands don't need your thirds.ai key.
For S3, use the bucket's region and standard endpoint. AWS documents the upload operation in its PutObject reference. For R2, create S3 API credentials and use your Cloudflare account ID, as shown in the R2 AWS CLI guide.
Upload a file to S3 with the AWS CLI
To upload a thirds.ai file to S3, you read the job, download the file, check it, copy it with aws s3 cp, and check the stored copy. That's five steps. You need the job ID and an AWS CLI profile that can write and read your bucket.
Read the job first, so you have a fresh link and the file's facts. Keep artifact.byte_size and artifact.sha256 from the response:
curl --fail-with-body --silent --show-error \
https://thirds.ai/v1/pdf/pdf_00000000000000000000000000000000 \
-H 'Authorization: Bearer YOUR_API_KEY'const response = await fetch("https://thirds.ai/v1/pdf/pdf_00000000000000000000000000000000", {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_API_KEY"
},
redirect: "error",
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}; Retry-After: ${response.headers.get("retry-after") ?? "none"}`);
console.log(await response.text());import json
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, Request, build_opener
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
request = Request("https://thirds.ai/v1/pdf/pdf_00000000000000000000000000000000",
method="GET",
headers={
"Authorization": "Bearer YOUR_API_KEY"
},
)
try:
with build_opener(NoRedirect()).open(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except HTTPError as error:
retry = error.headers.get("Retry-After", "none")
raise RuntimeError(f"HTTP {error.code}; Retry-After: {retry}") from NoneAdd https://thirds.ai before download.url and save the file:
curl --fail --silent --show-error \
'https://thirds.ai/v1/downloads/YOUR_SIGNED_DOWNLOAD_TOKEN' \
--output report.pdfimport { writeFile } from "node:fs/promises";
const response = await fetch("https://thirds.ai/v1/downloads/YOUR_SIGNED_DOWNLOAD_TOKEN", {
method: "GET",
headers: {},
redirect: "error",
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}; Retry-After: ${response.headers.get("retry-after") ?? "none"}`);
await writeFile("report.pdf", Buffer.from(await response.arrayBuffer()));import json
from urllib.error import HTTPError
from pathlib import Path
from urllib.request import HTTPRedirectHandler, Request, build_opener
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
request = Request("https://thirds.ai/v1/downloads/YOUR_SIGNED_DOWNLOAD_TOKEN",
method="GET",
headers={
},
)
try:
with build_opener(NoRedirect()).open(request, timeout=30) as response:
Path("report.pdf").write_bytes(response.read())
except HTTPError as error:
retry = error.headers.get("Retry-After", "none")
raise RuntimeError(f"HTTP {error.code}; Retry-After: {retry}") from NoneCheck the local file before you upload it. The two results must match artifact.byte_size and artifact.sha256. On macOS, use shasum -a 256 in place of sha256sum:
wc -c report.pdf
sha256sum report.pdf
Use an object key that belongs to this output, such as reports/customer-42/2026-09/report.pdf. Keep the key with your job ID. Replace the sample bucket and profile with your own values:
aws s3 cp report.pdf \
s3://YOUR_PRIVATE_BUCKET/reports/customer-42/2026-09/report.pdf \
--profile thirds-outputs
Then read the stored object back and check its SHA-256 against artifact.sha256:
aws s3 cp \
s3://YOUR_PRIVATE_BUCKET/reports/customer-42/2026-09/report.pdf - \
--profile thirds-outputs | sha256sum
Mark the output stored only after this check passes.
Upload a file to R2
To upload a thirds.ai file to Cloudflare R2, you use the same AWS CLI steps and checks as S3 and add two options. Set --region auto, and set --endpoint-url to the R2 endpoint with your Cloudflare account ID. Use the profile that holds your R2 credentials:
aws s3 cp report.pdf \
s3://YOUR_PRIVATE_BUCKET/reports/customer-42/2026-09/report.pdf \
--profile thirds-r2 --region auto \
--endpoint-url https://YOUR_CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com
Add the same --region and --endpoint-url options to the read-back command. Use the same steps for an image with /v1/image/{id} and a key ending in .png.
Recover a failed upload
When an S3 or R2 upload fails, you fix the cause and upload the same checked local file again. You don't make the file again in thirds.ai, so a storage failure never needs another billed render. Find your failure in the table and follow its fix:
| What happens | What to do |
|---|---|
| Local file does not match the job | Download the same job again. Don't upload the damaged file. |
| Credentials fail or access is denied | Fix the profile or bucket policy, then upload again. |
| Network connection stops | Rerun with the same verified file and object key. |
| Stored bytes do not match | Investigate concurrent writes and retry from the verified local file. |
| Local file is gone | Download the known job again while its file remains available. |
Use one key per intended output. If the key already holds a file, run the read-back check before you upload again. A mismatch needs investigation or a new key for a separately approved output. If you turn on versioning for an S3 bucket, an upload to a key that already exists keeps the old file as an older version. AWS explains this in Uploading objects (checked 25 September 2026). Bucket retention follows your own storage policy.
Keep the local file until the upload check passes. When you need to download the file again, read the job for a fresh link, as download and keep the file shows.