Storage Options

Upload screenshots to your own S3-compatible storage bucket. Screenshots are always stored in RenderScreenshot's CDN first, then copied to your bucket asynchronously via a background job. This page is the parameter reference, part of the API documentation. For a step-by-step setup with a worked example, read upload screenshots to S3.

Step by step: Save screenshots to your own bucket walks through this task with screenshots of every step.

Paid Plans Only

Custom storage is available on Starter, Growth, and Scale plans. Upgrade your plan to enable this feature.

Supported Providers

Works with any S3-compatible storage:

  • Amazon S3
  • Cloudflare R2
  • DigitalOcean Spaces
  • Backblaze B2
  • MinIO
  • Wasabi
  • Any other S3-compatible storage

Prerequisites

  1. Configure your storage credentials in the Storage Dashboard
  2. Test the connection to verify credentials work
  3. Ensure your bucket is accessible with the provided credentials

Full Configuration

{
  "url": "https://example.com",
  "storage": {
    "enabled": true,
    "path": "screenshots/{year}/{month}/{day}/{hash}.{ext}",
    "acl": "public-read"
  }
}

Parameters

Parameter Type Default Description
enabled boolean true Upload to configured storage (set false to skip for this request)
path string Workspace default Storage path template for this request. Without it, the upload uses the template from your storage dashboard, else {hash}.{ext}
acl string Workspace default Access control: private or public-read

When the API key's workspace has storage configured and enabled, screenshots are automatically uploaded. Each workspace has its own storage configuration, so two projects can upload to two buckets. Use storage.enabled: false to skip storage for a specific request.

Path Templates

Customize where screenshots are stored using template variables. Set a default template in the storage dashboard, or send storage.path to override it for one request:

{
  "storage": {
    "path": "screenshots/{year}/{month}/{day}/{hash}.{ext}"
  }
}

Available Variables

Variable Description Example
{hash} Content hash (required) a1b2c3d4
{ext} File extension (required) png, jpeg, pdf
{year} Year (4 digits) 2026
{month} Month (01-12) 01
{day} Day (01-31) 27
{timestamp} Unix timestamp 1706400000
{domain} Target URL domain example.com
{uuid} Random UUID 550e8400-e29b-41d4-a716-446655440000

Required: Path templates must include both {hash} and {ext} variables.

Path Examples

Organized by date: screenshots/{year}/{month}/{day}/{hash}.{ext} → screenshots/2026/01/27/a1b2c3d4.png

Organized by domain: captures/{domain}/{hash}.{ext} → captures/example.com/a1b2c3d4.png

With UUID for uniqueness: shots/{uuid}-{hash}.{ext} → shots/550e8400-e29b-41d4-a716-446655440000-a1b2c3d4.png

Simple flat structure: {hash}.{ext} → a1b2c3d4.png

Access Control

Private (Default)

Files are private and require authentication to access:

{
  "storage": {
    "acl": "private"
  }
}

Public Read

Files are publicly accessible via direct URL:

{
  "storage": {
    "acl": "public-read"
  }
}

Async Upload Behavior

Storage uploads happen asynchronously after the screenshot response is returned:

  1. You receive the screenshot immediately
  2. A background job copies the screenshot to your bucket
  3. The upload result is stored in the screenshot request record

This means you get fast response times without waiting for the upload to complete.

If the upload fails with a temporary error (throttling such as SlowDown, a 5xx from the provider, or a network error), the job tries again with backoff, up to 5 attempts. A permanent error, such as AccessDenied or NoSuchBucket, is not retried: the job records the error and stops, so fix the bucket or the keys and render again.

Dashboard Configuration

Configure your storage settings at /dashboard/storage:

Field Required Description
Bucket Name Yes Your storage bucket name
Endpoint URL For non-AWS Full endpoint URL (leave blank for AWS S3)
Region For AWS S3 AWS region (e.g., us-east-1)
Access Key ID Yes Your storage access key
Secret Access Key Yes Your storage secret key

Endpoint URLs by Provider

Provider Endpoint URL Format
Amazon S3 Leave blank (uses region)
Cloudflare R2 https://<account_id>.r2.cloudflarestorage.com
DigitalOcean Spaces https://<region>.digitaloceanspaces.com
Backblaze B2 https://s3.<region>.backblazeb2.com
Wasabi https://s3.<region>.wasabisys.com
MinIO Your MinIO server URL

Provider Setup Guides

Cloudflare R2

Cloudflare R2 is S3-compatible object storage with zero egress fees.

Step 1: Create an R2 bucket

  1. Log in to your Cloudflare Dashboard
  2. Navigate to R2 Object Storage in the sidebar
  3. Click Create bucket
  4. Enter a bucket name (e.g., my-screenshots)
  5. Click Create bucket

Step 2: Create API credentials

  1. In the R2 section, click Manage R2 API Tokens
  2. Click Create API token
  3. Give it a name (e.g., RenderScreenshot)
  4. Under Permissions, select Object Read & Write
  5. Under Specify bucket(s), select your bucket or choose Apply to all buckets
  6. Click Create API Token
  7. Copy the Access Key ID and Secret Access Key (you won't see the secret again)

Step 3: Find your Account ID

Your Account ID is in the R2 overview page URL or in the sidebar. It's a 32-character hexadecimal string.

Step 4: Configure in RenderScreenshot

Field Value
Bucket Name my-screenshots
Endpoint URL https://<account_id>.r2.cloudflarestorage.com
Region Leave blank
Access Key ID Your R2 API token Access Key ID
Secret Access Key Your R2 API token Secret Access Key

R2 Note: Leave the Region field blank. R2 automatically determines the region. The endpoint URL format is https://<account_id>.r2.cloudflarestorage.com where <account_id> is your 32-character Cloudflare account ID.

Optional: Set up a public bucket with custom domain

If you want screenshots to be publicly accessible:

  1. In your bucket settings, go to Settings → Public access
  2. Click Allow Access to enable public access
  3. Optionally, connect a custom domain under Custom Domains
  4. Add the public URL base to your RenderScreenshot storage settings

Amazon S3

Step 1: Create an S3 bucket

  1. Log in to the AWS Console
  2. Click Create bucket
  3. Enter a bucket name and select a region
  4. Configure public access settings based on your needs
  5. Click Create bucket

Step 2: Create IAM credentials

  1. Go to IAM → Users → Create user
  2. Create a user with programmatic access
  3. Attach the following inline policy:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectAcl"],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::your-bucket-name"
    }
  ]
}

The connection test calls HeadBucket, which needs s3:ListBucket on the bucket itself (note: no /*). Without the second statement, uploads would be allowed but the test fails with Forbidden, and storage stays off.

  1. Save the Access Key ID and Secret Access Key

Step 3: Configure in RenderScreenshot

Field Value
Bucket Name Your bucket name
Endpoint URL Leave blank
Region Your bucket region (e.g., us-east-1)
Access Key ID Your IAM Access Key ID
Secret Access Key Your IAM Secret Access Key

DigitalOcean Spaces

Step 1: Create a Space

  1. Log in to DigitalOcean
  2. Go to Spaces Object Storage
  3. Click Create a Space
  4. Select a datacenter region and name your Space

Step 2: Create API credentials

  1. Go to API → Spaces Keys
  2. Click Generate New Key
  3. Copy the Key and Secret

Step 3: Configure in RenderScreenshot

Field Value
Bucket Name Your Space name
Endpoint URL https://<region>.digitaloceanspaces.com
Region Your Space region (e.g., nyc3, sfo3, ams3)
Access Key ID Your Spaces Key
Secret Access Key Your Spaces Secret

Backblaze B2

Step 1: Create a B2 bucket

  1. Log in to Backblaze
  2. Click Create a Bucket
  3. Enter a bucket name and set file visibility

Step 2: Create application key

  1. Go to App Keys → Add a New Application Key
  2. Give it a name and select your bucket
  3. Copy the keyID and applicationKey

Step 3: Configure in RenderScreenshot

Field Value
Bucket Name Your B2 bucket name
Endpoint URL https://s3.<region>.backblazeb2.com
Region Your bucket region (e.g., us-west-004)
Access Key ID Your B2 keyID
Secret Access Key Your B2 applicationKey

B2 Note: Find your bucket's region in the bucket details. The endpoint format is s3.<region>.backblazeb2.com.


Wasabi

Step 1: Create a bucket

  1. Log in to Wasabi Console
  2. Click Create Bucket
  3. Enter a name and select a region

Step 2: Create access keys

  1. Go to Access Keys → Create New Access Key
  2. Copy the Access Key and Secret Key

Step 3: Configure in RenderScreenshot

Field Value
Bucket Name Your Wasabi bucket name
Endpoint URL https://s3.<region>.wasabisys.com
Region Your bucket region (e.g., us-east-1, eu-central-1)
Access Key ID Your Wasabi Access Key
Secret Access Key Your Wasabi Secret Key

MinIO (Self-Hosted)

For self-hosted MinIO instances:

Field Value
Bucket Name Your MinIO bucket name
Endpoint URL Your MinIO server URL (e.g., https://minio.example.com)
Region Usually us-east-1 (MinIO default)
Access Key ID Your MinIO Access Key
Secret Access Key Your MinIO Secret Key

Examples

Basic Storage Upload

curl

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer rs_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "preset": "og_card"
  }'

Java

import com.renderscreenshot.sdk.Client;
import com.renderscreenshot.sdk.TakeOptions;

Client client = new Client("rs_live_xxxxx");

// When storage is configured for your account, uploads happen automatically
byte[] image = client.take(
    TakeOptions.url("https://example.com").preset("og_card")
);

Node.js

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client('rs_live_xxxxx');

// When storage is configured for your account, uploads happen automatically
const image = await client.take(
  TakeOptions.url('https://example.com').preset('og_card')
);

PHP

use RenderScreenshot\Client;
use RenderScreenshot\TakeOptions;

$client = new Client('rs_live_xxxxx');

// When storage is configured for your account, uploads happen automatically
$image = $client->take(
    TakeOptions::url('https://example.com')->preset('og_card')
);

Python

from renderscreenshot import Client, TakeOptions

client = Client('rs_live_xxxxx')

# When storage is configured for your account, uploads happen automatically
image = client.take(
    TakeOptions.url('https://example.com').preset('og_card')
)

Ruby

require 'renderscreenshot'

client = RenderScreenshot.client('rs_live_xxxxx')

# When storage is configured for your account, uploads happen automatically
image = client.take(
  RenderScreenshot::TakeOptions
    .url('https://example.com')
    .preset('og_card')
)

Go

client, _ := rs.New("rs_live_xxxxx")

// When storage is configured for your account, uploads happen automatically
image, _ := client.Take(context.Background(),
    rs.URL("https://example.com").Preset("og_card"),
)

When storage is configured for your account, uploads happen automatically.

Custom Path Template

A per-request storage.path (the SDK storagePath / storage_path methods) overrides the path template saved in your storage dashboard for that request, and acl overrides the default ACL. The path follows the same rules as the dashboard template: only the variables above, and both {hash} and {ext}. Any other path returns HTTP 400 invalid_parameter with param: "storage.path", before the render starts.

curl

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer rs_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "storage": {
      "path": "og-images/{domain}/{hash}.{ext}",
      "acl": "public-read"
    }
  }'

Java

import com.renderscreenshot.sdk.Client;
import com.renderscreenshot.sdk.TakeOptions;

Client client = new Client("rs_live_xxxxx");

byte[] image = client.take(
    TakeOptions.url("https://example.com")
        .storageEnabled()
        .storagePath("og-images/{domain}/{hash}.{ext}")
        .storageAcl("public-read")
);

Node.js

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client('rs_live_xxxxx');

const image = await client.take(
  TakeOptions.url('https://example.com')
    .storageEnabled()
    .storagePath('og-images/{domain}/{hash}.{ext}')
    .storageAcl('public-read')
);

PHP

use RenderScreenshot\Client;
use RenderScreenshot\TakeOptions;

$client = new Client('rs_live_xxxxx');

$image = $client->take(
    TakeOptions::url('https://example.com')
        ->storageEnabled()
        ->storagePath('og-images/{domain}/{hash}.{ext}')
        ->storageAcl('public-read')
);

Python

from renderscreenshot import Client, TakeOptions

client = Client('rs_live_xxxxx')

image = client.take(
    TakeOptions.url('https://example.com')
    .storage_enabled()
    .storage_path('og-images/{domain}/{hash}.{ext}')
    .storage_acl('public-read')
)

Ruby

require 'renderscreenshot'

client = RenderScreenshot.client('rs_live_xxxxx')

image = client.take(
  RenderScreenshot::TakeOptions
    .url('https://example.com')
    .storage_enabled
    .storage_path('og-images/{domain}/{hash}.{ext}')
    .storage_acl('public-read')
)

Go

client, _ := rs.New("rs_live_xxxxx")

image, _ := client.Take(context.Background(),
    rs.URL("https://example.com").
        StorageEnabled().
        StoragePath("og-images/{domain}/{hash}.{ext}").
        StorageACL(rs.ACLPublicRead),
)

Skip Storage for One Request

curl

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer rs_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "storage": {
      "enabled": false
    }
  }'

Node.js

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client('rs_live_xxxxx');

// Explicitly disable storage for this request
const image = await client.take(
  TakeOptions.url('https://example.com').storageEnabled(false)
);

PHP

use RenderScreenshot\Client;
use RenderScreenshot\TakeOptions;

$client = new Client('rs_live_xxxxx');

// Explicitly disable storage for this request
$image = $client->take(
    TakeOptions::url('https://example.com')->storageEnabled(false)
);

Python

from renderscreenshot import Client, TakeOptions

client = Client('rs_live_xxxxx')

# Explicitly disable storage for this request
image = client.take(
    TakeOptions.url('https://example.com').storage_enabled(False)
)

Ruby

require 'renderscreenshot'

client = RenderScreenshot.client('rs_live_xxxxx')

# Explicitly disable storage for this request
image = client.take(
  RenderScreenshot::TakeOptions
    .url('https://example.com')
    .storage_enabled(false)
)

Go

client, _ := rs.New("rs_live_xxxxx")

// Explicitly disable storage for this request
image, _ := client.Take(context.Background(),
    rs.URL("https://example.com").StorageEnabled(false),
)

Storage vs Cache URL

Feature Cache URL Custom Storage
Provider RenderScreenshot CDN Your S3-compatible bucket
Lifetime TTL-based (max 30 days) Permanent (you control)
Control Managed by us Full control
Cost Included in plan Your storage costs
Response time Immediate Async (background job)

Use cache URL for temporary/dynamic content where you don't need permanent storage. Use custom storage when you need permanent archives or want screenshots in your own infrastructure.

Troubleshooting

How storage failures show up

Storage problems never appear in the API response, because the response is sent before the upload starts. They show up in two places:

  • The connection test on the storage dashboard. It runs when you save the settings and when you click Test. While the test fails, storage is off for the account and no uploads are attempted.
  • Your bucket. If the test passed but a file is missing, the upload itself failed. Use the script below to check.

Uploads also run only for fresh renders on a paid plan, and not for requests with storage.enabled: false.

Symptom to fix

The messages in this table are the exact text the dashboard shows. We produced them by sending the dashboard's own S3 calls, with the same AWS SDK for Ruby version, to a local S3 stub that returned each status.

You see Cause Fix
Connection failed: S3 error: Aws::S3::Errors::Forbidden Wrong access key or secret, or the key cannot list the bucket. The test is a HeadBucket call, which gets a bare 403 with no detail Check both keys. On AWS, add s3:ListBucket on the bucket ARN (see the policy above). On R2, use a token with Object Read & Write for this bucket
Connection failed: Bucket not found. Verify the bucket name exists. The bucket name is wrong, or the endpoint points at another account Bucket names are case-sensitive. For R2, check the account ID in the endpoint
Connection failed: S3 error: Aws::S3::Errors::Http301Error Amazon S3 bucket in a different region from the one you entered Set Region to the bucket's region
Connection failed: S3 error: Aws::S3::Errors::BadRequest Malformed request, often a region value the provider does not accept Use the provider's region name, or leave it blank for R2
Connection failed: Failed to open TCP connection to ... The endpoint host is wrong or unreachable Use the full URL with https://, from the endpoint table above
Connection failed: S3 error: Aws::S3::Errors::Http503Error (or 500) The provider had a temporary failure Run the test again in a few minutes
Test passes, files never arrive, acl is public-read The bucket refuses ACLs. Amazon S3 buckets with Object Ownership set to "Bucket owner enforced" (the default for new buckets) reject public-read with AccessControlListNotSupported, and some providers do not support ACLs at all Use private and make the bucket or a path public with a bucket policy, or with the provider's public access setting
Test passes, files never arrive, acl is private The key can list the bucket but not write to it Allow s3:PutObject (and s3:PutObjectAcl if you set an ACL) on bucket/*
Some files missing during heavy traffic The provider throttled the uploads (SlowDown, 503). The upload job retries these with backoff, up to 5 attempts; an upload that still fails after that is recorded as failed Re-render the missing captures. Throttling is rare below the provider's request rate limits
Invalid path template when you save, or HTTP 400 invalid_parameter for storage.path A variable other than the eight listed, or {hash} or {ext} missing Use only the variables in the table above

Storage settings are on paid plans only. If your plan changes to Free, uploads stop, and a request that still sends a storage block (other than "enabled": false) fails with HTTP 403 feature_not_available.

Check that uploads arrive

This script takes one screenshot, works out the object key, and polls your bucket with HeadObject until the copy appears. Run it after you change any storage setting. It reads its own bucket credentials from the standard AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY variables, so give it a key that can read the bucket.

// verify-upload.mjs (Node 22+, npm i @aws-sdk/client-s3)
// Takes one screenshot, then checks that its copy arrived in your bucket.
import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const BUCKET = process.env.S3_BUCKET;
const PREFIX = process.env.STORAGE_PREFIX ?? ''; // the part of your path template before {hash}
const s3 = new S3Client({
  region: process.env.S3_REGION ?? 'auto',
  endpoint: process.env.S3_ENDPOINT, // leave unset for Amazon S3
  forcePathStyle: Boolean(process.env.S3_ENDPOINT),
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const res = await fetch(`${API}/v1/screenshot`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ url: 'https://example.com' }),
});
if (!res.ok) throw new Error(`Render failed with HTTP ${res.status}: ${await res.text()}`);
if (res.headers.get('x-cache-hit') === 'true') throw new Error('Cache hit: cache hits are not uploaded again');

// {hash}.{ext} is the file name at the end of X-Cache-Key.
const key = PREFIX + res.headers.get('x-cache-key').split('/').pop();

for (let attempt = 1; attempt <= 10; attempt++) {
  try {
    const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
    console.log(`Uploaded: s3://${BUCKET}/${key} (${head.ContentLength} bytes, attempt ${attempt})`);
    process.exit(0);
  } catch (err) {
    // 404 means "not there yet". Anything else (403, wrong endpoint) is a real problem.
    if (err.$metadata?.httpStatusCode !== 404) throw err;
  }
  await sleep(3000);
}
console.error(`Not in the bucket after 30 seconds: ${key}`);
process.exitCode = 1;

The key logic works for templates of the form prefix/{hash}.{ext}, such as the default {hash}.{ext}. Templates with date, {domain}, {timestamp} or {uuid} parts need the same values the upload used, so list the bucket for those instead.

Tested on 2026-10-15 with Node 22.18 and @aws-sdk/client-s3 3.1152.0 against a local API stub that sends X-Cache-Key and an S3 stub. The script found an object that appeared on the third check, exited 1 for an object that never appeared, and stopped at once on a 403. The dashboard messages in the table came from the same run of aws-sdk-s3 1.233.1, the version the API uses.

See Also

Related in this topic

Was this page helpful?