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.available returns 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 region auto. Without an endpoint (Amazon S3), an empty region becomes us-east-1.
  • The content type is set. Objects get image/png, image/jpeg, image/webp or application/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

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.

Related in this topic

Was this page helpful?