This guide takes a screenshot request to a file in your own bucket, at a key you chose, behind a URL that works for as long as you keep the file. It covers six S3-compatible providers in one table, explains how to design a path template you can predict, and lists the problems we see most. The field-by-field reference is storage options; this page is the task view. It is part of the guides.
Custom storage is a paid-plan feature (Starter and above). On the Free plan, a request with a storage block fails with feature_not_available.
When your own bucket is the right tool
Every render already has a public CDN link, the X-Cache-URL, which lives for the cache TTL (30 days at most). Use your own bucket when the file must outlive that, when it must sit beside your other assets under your own domain, or when a retention or compliance rule says where captures are kept.
How the copy works
sequenceDiagram participant App as Your app participant API as RenderScreenshot API participant Job as Upload job participant B as Your bucket App->>API: POST /v1/screenshot (storage block optional) API-->>App: image + X-Cache-Key, X-Cache-URL API->>Job: enqueue (fresh renders only) Job->>B: PutObject key = expanded path template Note over Job,B: throttling, 5xx, network errors: up to 5 attempts
Details from the upload code that matter when you configure a provider:
- The response never waits for the upload. Your app gets the image first. A storage problem does not change the API response; it shows up as a missing file.
- Cache hits are not copied again. A request served with
cache.availablereturns the stored render and starts no upload. - Path-style addressing for custom endpoints. When you fill in Endpoint URL, the uploader uses path-style requests (
https://endpoint/bucket/key), so a MinIO server without wildcard DNS works. With an endpoint and an empty Region, it sends the regionauto. Without an endpoint (Amazon S3), an empty region becomesus-east-1. - The content type is set. Objects get
image/png,image/jpeg,image/webporapplication/pdf, so a browser shows them inline from a public URL.
The minimal requests
A GET request has no storage parameters. If storage is set up in the dashboard, every fresh render is copied with the dashboard's path template and ACL:
curl -o shot.png -D - \ "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&api_key=$RS_API_KEY"
POST lets one request choose its own path and ACL. This example also sets cache.key, which gives the file a fixed name (see the next sections):
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -D - -o card.png \ -d '{ "url": "https://example.com", "preset": "og_card", "cache": { "key": "products/sku-123" }, "storage": { "path": "og/{hash}.{ext}", "acl": "private" } }'
The file lands at og/sku-123.png. The -D - flag prints the response headers, including X-Cache-Key.
Provider settings and public URLs
What to type in the dashboard, and where the object can be read afterwards. <key> is the expanded path template. Provider facts come from each provider's documentation, checked on 2026-10-18.
| Provider | Endpoint URL | Region field | Public URL of an object | How to make objects public |
|---|---|---|---|---|
| Amazon S3 | (blank) | Bucket region, for example eu-west-1 |
https://<bucket>.s3.<region>.amazonaws.com/<key> |
Bucket policy allowing s3:GetObject on a prefix, with Block Public Access relaxed for it; or CloudFront in front |
| Cloudflare R2 | https://<account_id>.r2.cloudflarestorage.com |
(blank, sends auto) |
Your custom domain, or the bucket's r2.dev address while testing |
Bucket setting Public access; Cloudflare rate-limits r2.dev, so use a custom domain in production |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com |
nyc3, ams3, ... |
https://<space>.<region>.digitaloceanspaces.com/<key> (or the .cdn. host with the Spaces CDN on) |
acl: public-read per object, or the Space's file listing setting |
| Backblaze B2 | https://s3.<region>.backblazeb2.com |
us-west-004, ... |
https://<bucket>.s3.<region>.backblazeb2.com/<key> |
Make the bucket Public in B2; visibility is a bucket setting |
| Wasabi | https://s3.<region>.wasabisys.com |
us-east-1, eu-central-1, ... |
https://s3.<region>.wasabisys.com/<bucket>/<key> |
Bucket policy; check that public access is allowed on your Wasabi account |
| MinIO | Your server, for example https://minio.example.com |
(blank) or us-east-1 |
https://minio.example.com/<bucket>/<key> |
mc anonymous set download <alias>/<bucket>/<prefix> |
Fill Public URL Base in the dashboard (for example https://cdn.example.com) to record the public address with each upload. Your app can build the same URL itself: base plus key.
Design a path template you can predict
The upload runs after the response, so your app must work out where the file went. That depends on the variables you use:
| Variable | Value | Can your app predict it? |
|---|---|---|
{hash} |
The file name at the end of X-Cache-Key, without extension |
Yes, from the response |
{ext} |
png, jpeg, webp or pdf |
Yes, from your request |
{year} {month} {day} {timestamp} |
When the request was created (UTC) | Nearly; read the Date response header |
{domain} |
Host of the target URL | Yes |
{uuid} |
A new random UUID per upload | No |
Templates for common goals:
| Goal | Template | Why |
|---|---|---|
| One file per product or page, replaced on re-render | og/{hash}.{ext} plus cache.key |
The key names the file, so the URL never changes |
| Archive with one folder per site and day | archive/{domain}/{year}/{month}/{day}/{hash}.{ext} |
Lifecycle rules can expire old days by prefix |
| Write-once evidence store | evidence/{year}/{month}/{uuid}-{hash}.{ext} |
Two renders never share a key; keep the key from a bucket listing |
Every template needs {hash} and {ext}; anything else returns 400 invalid_parameter with param: "storage.path" before the render starts.
SDK tabs
Each listing sends a per-request path and ACL. Method names are on the SDK pages. The SDKs do not set cache.key; use the raw POST above when you need a fixed file name.
Node.js
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.url('https://example.com') .storageEnabled() .storagePath('archive/{domain}/{year}/{month}/{day}/{hash}.{ext}') .storageAcl('private'), );
Python
import os from renderscreenshot import Client, TakeOptions client = Client(os.environ['RS_API_KEY']) image = client.take( TakeOptions.url('https://example.com') .storage_enabled() .storage_path('archive/{domain}/{year}/{month}/{day}/{hash}.{ext}') .storage_acl('private') )
Ruby
require 'renderscreenshot' client = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) image = client.take( RenderScreenshot::TakeOptions .url('https://example.com') .storage_enabled .storage_path('archive/{domain}/{year}/{month}/{day}/{hash}.{ext}') .storage_acl('private') )
PHP
<?php use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; $client = new Client(getenv('RS_API_KEY')); $image = $client->take( TakeOptions::url('https://example.com') ->storageEnabled() ->storagePath('archive/{domain}/{year}/{month}/{day}/{hash}.{ext}') ->storageAcl('private') );
Go
client, err := rs.New(os.Getenv("RS_API_KEY")) if err != nil { log.Fatal(err) } image, err := client.Take(context.Background(), rs.URL("https://example.com"). StorageEnabled(). StoragePath("archive/{domain}/{year}/{month}/{day}/{hash}.{ext}"). StorageACL(rs.ACLPrivate), )
Java
Client client = new Client(System.getenv("RS_API_KEY")); byte[] image = client.take( TakeOptions.url("https://example.com") .storageEnabled() .storagePath("archive/{domain}/{year}/{month}/{day}/{hash}.{ext}") .storageAcl("private") );
Troubleshooting
The storage reference lists every connection-test message. These are the problems that appear after the test passes:
| Symptom | Cause | Fix |
|---|---|---|
Public URL returns 403 or AccessDenied |
Object is private (the default ACL) | Make the prefix public with the provider's setting from the table above |
Files land at html_content/... |
{domain} for an html or markdown request has no host |
Use a template without {domain} for those requests |
| A file is replaced by a later render | Same cache.key, so same {hash} |
Expected; drop cache.key or add {uuid} for write-once storage |
Files appear under <bucket>/<bucket>/... |
The endpoint already contains the bucket name | Use the provider's base endpoint; the uploader adds the bucket |
{year}/{month} folder differs from your local date |
Dates are UTC, taken when the request was created | Convert in your app, or rely on {timestamp} |
| No file for a repeated request | It was a cache hit (cache.available); hits are not uploaded |
Send cache.refresh: true, or leave out cache.available, when you need a new copy |
| Some files missing after a burst | Provider throttling outlasted the 5 attempts | Re-render the missing ones; the verify script checks a single upload |
Related parameters
cache.key,cache.ttl,cache.available: name, lifetime and reuse of the render that is copied.output.format: sets{ext}and the content type.- Cloudflare R2 integration: R2 as both your bucket and your CDN.
Checked on 2026-10-18 against the API spec and the upload code (path expansion, addressing style and region defaults). The six SDK listings use the documented methods and were not run, and no upload was made to a live bucket for this guide.