Screenshot API responses come back as binary image data or a hosted URL. Both work fine for immediate display, but if you need those screenshots in six months (compliance audit, legal dispute, historical comparison), you need your own storage. Amazon S3 is where most teams put them. This page is a complete screenshot API S3 integration guide: capture screenshots with the ScreenshotRun API and save screenshots to S3, with working code in Node.js, Python, PHP, plus tips on CloudFront delivery and lifecycle archiving.
Why save screenshots to S3 instead of using the API URL
The API returns a CDN-hosted URL for every screenshot. That URL works immediately and stays available for a reasonable period. So why bother with S3 at all?
Long-term archival is the most common reason. Financial services firms operating under SEC Rule 17a-4 need immutable records stored for three or more years. Healthcare companies under HIPAA need BAA-covered storage with audit logging. In both cases, the data must live in infrastructure you control, and compliance monitoring demands it. An API vendor's CDN doesn't satisfy that requirement.
Cost is the second reason. S3 Standard costs $0.023 per GB per month in us-east-1. With lifecycle policies that transition old screenshots to Glacier Deep Archive, that drops to $0.00099/GB. At scale, storing a million screenshots in your own bucket is dramatically cheaper than keeping them on any vendor CDN.
And the third: serving screenshots from a custom domain. Put CloudFront in front of your S3 bucket, point screenshots.yourdomain.com at it, your users never see a third-party URL. Enterprise customers care about this more than you'd expect.
Capture and upload to S3 with Node.js
Call the screenshot API, get the image bytes, upload them to S3 with the AWS SDK. Two logical steps. See the full Node.js integration guide for more API options.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
async function captureAndStore(targetUrl, s3Key) {
// 1. Capture the screenshot
const apiUrl = new URL('https://api.screenshotrun.com/v1/screenshots/capture');
apiUrl.searchParams.set('url', targetUrl);
apiUrl.searchParams.set('format', 'png');
apiUrl.searchParams.set('width', '1280');
apiUrl.searchParams.set('full_page', 'true');
const response = await fetch(apiUrl, {
headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
});
if (!response.ok) {
throw new Error(`Capture failed: ${response.status}`);
}
const imageBuffer = Buffer.from(await response.arrayBuffer());
// 2. Upload to S3
await s3.send(new PutObjectCommand({
Bucket: 'my-screenshots-bucket',
Key: s3Key,
Body: imageBuffer,
ContentType: 'image/png',
CacheControl: 'public, max-age=31536000, immutable',
}));
return `https://my-screenshots-bucket.s3.us-east-1.amazonaws.com/${s3Key}`;
}
Two things trip people up here. First, ContentType must be set explicitly. S3 defaults to application/octet-stream if you skip it, which makes browsers download the image instead of displaying it inline. Second, CacheControl tells CloudFront (or any CDN in front of the bucket) to cache aggressively. Screenshots are immutable; once captured, the pixels don't change.
Save screenshots to S3 with Python
Python uses requests for the API call and boto3 for S3. Same two-step pattern. Check the Python integration guide for additional API parameters.
import boto3
import requests
s3 = boto3.client('s3')
def capture_and_store(target_url, s3_key):
response = requests.get(
'https://api.screenshotrun.com/v1/screenshots/capture',
params={
'url': target_url,
'format': 'webp',
'width': 1280,
'full_page': True,
'block_cookies': True,
},
headers={'Authorization': 'Bearer YOUR_API_KEY'},
timeout=60,
)
response.raise_for_status()
s3.put_object(
Bucket='my-screenshots-bucket',
Key=s3_key,
Body=response.content,
ContentType='image/webp',
)
return f'https://my-screenshots-bucket.s3.us-east-1.amazonaws.com/{s3_key}'
response.content gives you raw bytes in requests. No encoding step, no intermediate file. Pass those bytes directly to put_object and they land in S3 as-is. For screenshots over 5 MB (common with full-page captures of long pages), switch to s3.upload_fileobj() with a BytesIO wrapper. That triggers multipart upload automatically.
Upload screenshots to Amazon S3 from PHP
Here is how to upload screenshots to Amazon S3 using the AWS SDK for PHP. See the PHP integration page for more capture options.
use Aws\S3\S3Client;
$s3 = new S3Client([
'region' => 'us-east-1',
'version' => 'latest',
]);
function captureAndStore(string $targetUrl, string $s3Key): string
{
$apiUrl = 'https://api.screenshotrun.com/v1/screenshots/capture?' . http_build_query([
'url' => $targetUrl,
'format' => 'png',
'width' => 1280,
'full_page' => 'true',
]);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer YOUR_API_KEY'],
CURLOPT_TIMEOUT => 60,
]);
$imageData = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new \RuntimeException("Capture failed: {$httpCode}");
}
$s3->putObject([
'Bucket' => 'my-screenshots-bucket',
'Key' => $s3Key,
'Body' => $imageData,
'ContentType' => 'image/png',
]);
return "https://my-screenshots-bucket.s3.us-east-1.amazonaws.com/{$s3Key}";
}
PHP's cURL returns the response body as a string, which the AWS SDK accepts directly as the Body parameter. No stream conversion needed for typical screenshots (under a few MB).
Organize your S3 screenshot storage
S3 doesn't have real folders. What looks like a directory structure is just key prefixes separated by slashes. But when you save screenshots to S3 at scale, a consistent naming scheme prevents chaos.
A pattern that works well for most teams:
screenshots/{domain}/{YYYY}/{MM}/{DD}/{uuid}.png
Example:
screenshots/example.com/2026/11/11/a1b2c3d4-e5f6.png
Date prefixes make lifecycle policies easy to configure. Need to archive everything older than 90 days? Set a rule on the screenshots/ prefix. Having the domain in the path lets you filter by site when you're hunting for a specific capture. And the UUID at the end prevents filename collisions when you capture the same URL multiple times a day.
If you're storing screenshots for multiple customers, add a customer ID prefix: screenshots/{customer_id}/{domain}/{date}/{uuid}.png. That way you can set bucket policies per customer path.
Serve screenshots through CloudFront
Pulling images directly from S3 works but is slower than it needs to be (especially cross-region) and costs more in egress fees. CloudFront caches your screenshots at edge locations worldwide.
Setup in broad strokes: create a CloudFront distribution with your S3 bucket as origin, enable Origin Access Control so the bucket stays private, add a bucket policy that grants CloudFront read access scoped to your distribution ARN. Point your custom domain's CNAME at the CloudFront distribution. Your screenshots are now served from screenshots.yourdomain.com instead of an S3 URL.
Since screenshots don't change after capture, set a long cache TTL. CacheControl: public, max-age=31536000, immutable from the upload step tells CloudFront to cache for a year without revalidation. One origin request per screenshot, then edge-only forever.
Generate presigned URLs for private screenshots
Not every screenshot should be publicly accessible. Internal dashboards, competitor analysis captures, compliance snapshots, customer-specific reports: these need restricted access. Presigned URLs give you temporary links that expire after a set period.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
async function getTemporaryUrl(s3Key) {
const command = new GetObjectCommand({
Bucket: 'my-screenshots-bucket',
Key: s3Key,
});
// URL expires in 1 hour
return getSignedUrl(s3, command, { expiresIn: 3600 });
}
Anyone with the link can view the screenshot until it expires. After that, a 403. No bucket policy changes, no public access required. How long should the expiration be? For internal dashboards, an hour is usually enough. For links shared via email, 24 hours (86400 seconds) gives recipients time to open them.
Lifecycle policies for screenshot archiving
Screenshots pile up. A monitoring job that captures 100 URLs daily produces 36,500 screenshots per year. At 500 KB average, that's about 18 GB. Not huge, but costs add up when you're storing years of data at S3 Standard rates.
A lifecycle policy automates the transition to cheaper storage tiers:
{
"Rules": [
{
"ID": "screenshot-archival",
"Filter": { "Prefix": "screenshots/" },
"Status": "Enabled",
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_INSTANT_RETRIEVAL" },
{ "Days": 365, "StorageClass": "DEEP_ARCHIVE" }
]
}
]
}
After 30 days, screenshots move to Standard-IA (same retrieval speed, lower storage cost). After 90 days, Glacier Instant Retrieval (millisecond access, much cheaper). After a year, Deep Archive at $0.00099/GB in us-east-1. That's a 95% cost reduction compared to Standard for old screenshots you rarely need but can't delete.
One catch: each tier has a minimum storage duration. Standard-IA charges for at least 30 days even if you delete sooner. Glacier Instant Retrieval has a 90-day minimum. Deep Archive charges for 180 days minimum. Don't transition screenshots you plan to delete within a week; the early deletion charges will eat any savings.
S3-compatible alternatives for screenshot storage
Every code example on this page works with any S3-compatible storage provider. Change the endpoint URL and credentials; the SDK calls stay identical.
| Provider | Storage cost | Egress | Best for |
|---|---|---|---|
| AWS S3 | $0.023/GB | $0.09/GB after 100 GB free | Full AWS ecosystem, compliance certifications |
| Cloudflare R2 | $0.015/GB | Free | High-read workloads where egress cost matters |
| Backblaze B2 | $0.006/GB | $0.01/GB | Cheapest raw storage, free egress via Cloudflare |
| DigitalOcean Spaces | $5/mo flat (250 GB) | Included (1 TB) | Simple flat pricing, built-in CDN |
| MinIO | Self-hosted | N/A | Full data sovereignty, on-premises |
Switching from S3 to R2 takes one config change in Node.js:
const s3 = new S3Client({
region: 'auto',
endpoint: 'https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com',
credentials: {
accessKeyId: 'R2_ACCESS_KEY',
secretAccessKey: 'R2_SECRET_KEY',
},
});
Every PutObjectCommand and GetObjectCommand call from earlier works unchanged. R2 is worth considering if egress costs are a concern. Serving screenshots from R2 costs nothing in bandwidth; S3 charges $0.09 per GB after the first 100 GB each month.
Handle failures between capture and upload
Your screenshot API S3 storage pipeline has two network calls. Either one can fail independently, which creates a few failure modes worth handling.
If the API call fails (timeout, 5xx error), nothing was stored. Retry the capture. If the capture succeeds but the S3 upload fails, you've consumed an API credit and lost the image. Fix: wrap the upload in a retry loop, or write the image bytes to a local temp file before uploading so you can retry without re-capturing.
async function captureAndStoreWithRetry(targetUrl, s3Key, maxRetries = 3) {
const imageBuffer = await captureScreenshot(targetUrl);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await s3.send(new PutObjectCommand({
Bucket: 'my-screenshots-bucket',
Key: s3Key,
Body: imageBuffer,
ContentType: 'image/png',
}));
return;
} catch (err) {
if (attempt === maxRetries) throw err;
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
}
For high-volume pipelines, consider using the webhook parameter. Send the capture request with webhook_url, and when the screenshot is ready the webhook delivers the result. Your handler downloads the image and uploads to S3, decoupling capture from storage. The serverless screenshots guide covers the orchestration side of that pattern.
Screenshot API to S3 parameter reference
These ScreenshotRun parameters are especially relevant for your S3 screenshot storage workflow:
| Parameter | Why it matters for S3 storage |
|---|---|
format | Determines ContentType for S3. PNG for lossless, WebP for smaller files, PDF for documents |
full_page | Full-page PNGs of long pages can exceed 5 MB. Use multipart upload for these |
quality | Lower quality = smaller file = lower S3 storage cost. 80 is a good balance for archival |
resize_width | Shrink before storing. 800px thumbnails use 70% less storage than full 1280px captures |
block_cookies | Remove consent banners before archiving. You want clean screenshots in your permanent record |
certified | Adds timestamp and metadata hash. Useful when S3 screenshots serve as legal evidence |
webhook_url | Async capture with webhook delivery. Your handler uploads to S3 without holding a connection open |
cache_ttl | Set to 0 when archiving. You want a fresh capture every time, not a cached version |
Start saving screenshots to Amazon S3
Pick the code snippet in your language from above, swap in your API key and bucket name, run it. The free tier gives you 200 captures per month with no restrictions on what you do with the output. Store it in S3, serve it from CloudFront, transition it to Glacier after a year. Once the bytes leave the API, they're yours.
If you're building a larger pipeline (scheduled captures, batch processing, webhook-driven archival), those guides cover the orchestration side. This page handles the storage side. Combine both for a complete capture-and-archive workflow.
FAQ#
S3 defaults the Content-Type to application/octet-stream when you do not set it explicitly during upload. Browsers treat that MIME type as a file download. Add ContentType: image/png (or image/webp, image/jpeg) to your PutObject call, and the browser will render the image inline.
Use S3 lifecycle policies to transition older screenshots to cheaper tiers automatically. Standard-IA after 30 days, Glacier Instant Retrieval after 90 days, Deep Archive after a year. Also consider using WebP format instead of PNG to reduce file sizes by 30-50 percent, and set resize_width to generate smaller thumbnails when full resolution is not needed.
Yes. Every code example on the page works with any S3-compatible provider. Change the endpoint URL and credentials in the S3Client constructor. The SDK calls (PutObjectCommand, GetObjectCommand) stay identical. R2 is especially attractive because it charges zero egress fees.
You lose the image and consume an API credit. To prevent this, save the image bytes to a local variable or temp file before attempting the S3 upload. That way you can retry the upload without re-capturing. The retry example on this page shows exponential backoff for S3 upload failures.
Never make the bucket public unless the screenshots are truly meant for anyone on the internet. Presigned URLs give temporary access that expires after a set period. For internal dashboards or compliance screenshots, presigned URLs are the right choice. For public-facing thumbnails, put CloudFront with Origin Access Control in front of the bucket instead.
Use a structured key prefix like screenshots/{domain}/{YYYY}/{MM}/{DD}/{uuid}.png. The date component makes lifecycle policies easy to apply. The domain lets you filter by site. The UUID prevents collisions when capturing the same URL multiple times. For multi-tenant apps, add a customer ID prefix before the domain.