Keep output files in S3 or R2
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
Keep each finished PDF or image in your own private bucket so your app controls access and retention. This recipe uploads a file that the API workflow or webhook worker already collects. It does not start a render.
Prepare your bucket
Use curl and AWS CLI v2 on macOS or Linux. You need the job ID of a finished output, or a file you already downloaded from it.
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 to S3
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. Keep your app's access checks in front of any download you offer customers.
Upload to R2
Use the same file and the same checks. Add the R2 endpoint with your Cloudflare account ID, and 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 an upload
| 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. Bucket retention follows your own storage policy.
Keep the local file until the upload check passes. A signed thirds.ai link is temporary and is not a storage record. Follow file and link expiry when you recover a missing download. A storage failure does not require another billed render.