How to Capture a Webpage Screenshot with an API and Save It to Amazon S3
Capture a webpage as an image, then upload it to Amazon S3. Compare hosted APIs with Playwright and get runnable Python, Node.js, and cURL examples.
To save a webpage screenshot to Amazon S3, first capture the page and obtain its image bytes, then upload those bytes to an S3 object key with an AWS SDK or a presigned PUT URL. The capture service and S3 upload are separate steps: choose what the screenshot should include, choose an output format, generate a collision-safe object key, and decide who can access the stored object.
This guide shows a self-managed Playwright flow in Python and Node.js, a cURL flow using ScreenshotNeo, and S3 uploads using AWS SDKs or a presigned URL. Keep AWS credentials on a trusted backend; for browser uploads, have the backend mint a narrowly scoped, short-lived presigned URL.
1. Choose how to capture the page
You can run a browser yourself, or call a hosted screenshot API. Browser automation gives you control over the browser runtime and capture behavior, but you operate that runtime. An API handles the capture service; check the provider’s own documentation for its options, limits, and pricing.
ScreenshotNeo is a website screenshot API and MCP server for developers. Its API returns an image or PDF from one GET request. It accepts the parameter names other screenshot APIs use, which can make switching easier. The examples below use its documented API endpoint; see the ScreenshotNeo API documentation for request options.
2. Decide what the screenshot should contain
| Capture choice | Use it when | Consider |
|---|---|---|
| Viewport | You need the visible screen at a defined viewport size. | Set the viewport deliberately; page content below the fold will not be included. |
| Full page | You need the scrollable document in one image. | Long pages can produce large images. Lazy-loaded content may require scrolling or waiting. |
| Element | You need a chart, card, or other selected region. | Use a stable selector and handle missing or hidden elements. |
Playwright supports path or buffer output, full-page and element screenshots, format and quality controls, CSS-pixel versus device-pixel scaling, and masking. Select options explicitly so the dimensions and contents match your use case. PNG is lossless; JPEG and WebP can be smaller depending on content and quality settings. There is no universally best format. See the Playwright Page API and Playwright screenshot guide.
3. Capture with Playwright
Python
Install Playwright and its browser, then run this script. It captures the rendered page as a PNG buffer, which can be passed directly to an S3 SDK.
python -m pip install playwright boto3
python -m playwright install chromium
import asyncio
from playwright.async_api import async_playwright
async def capture(url: str) -> bytes:
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
await page.goto(url, wait_until="networkidle", timeout=60_000)
image = await page.screenshot(full_page=True, type="png")
await browser.close()
return image
async def main():
image = await capture("https://example.com")
with open("screenshot.png", "wb") as f:
f.write(image)
asyncio.run(main())
For a selected element, use await page.locator(".report-card").screenshot() after checking that the locator matches the intended element. To save a local file instead of retaining bytes, use await page.screenshot(path="screenshot.png", full_page=True).
Node.js
Install Playwright and its browser:
npm install playwright
npx playwright install chromium
const { chromium } = require('playwright');
const fs = require('node:fs/promises');
async function capture(url) {
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
await page.goto(url, { waitUntil: 'networkidle', timeout: 60_000 });
return await page.screenshot({ fullPage: true, type: 'png' });
} finally {
await browser.close();
}
}
(async () => {
const image = await capture('https://example.com');
await fs.writeFile('screenshot.png', image);
})();
For one element, call page.locator('.report-card').screenshot(). For a local file, use page.screenshot({ path: 'screenshot.png', fullPage: true }).
Capture details that affect reliability
- Use a deliberate viewport and device scale factor. Higher pixel density increases output dimensions and often file size.
- Choose a navigation condition appropriate to the site.
networkidlecan time out on pages with persistent network activity; usedomcontentloadedorloadand wait for a specific selector when that is more reliable. - Wait for fonts, animations, or application data when they affect the result. A screenshot taken before rendering finishes may be incomplete.
- Mask sensitive areas when appropriate. Do not capture secrets merely because they are visible in the browser.
- For a full-page image, check the final dimensions and size. For a long report, consider capturing sections or using PDF output instead.
4. Upload the captured bytes to S3 from a backend
Use an AWS SDK with credentials supplied through the runtime’s standard credential chain, such as an attached role. Grant only the needed write access to the intended bucket and key prefix. Do not put long-lived AWS credentials in client-side code.
Python with Boto3
python -m pip install boto3
import asyncio
import boto3
from playwright.async_api import async_playwright
BUCKET = "your-bucket-name"
KEY = "screenshots/example-com/page.png"
async def capture(url):
async with async_playwright() as p:
browser = await p.chromium.launch()
try:
page = await browser.new_page(viewport={"width": 1440, "height": 900})
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await page.locator("body").wait_for(state="visible")
return await page.screenshot(full_page=True, type="png")
finally:
await browser.close()
async def main():
image = await capture("https://example.com")
s3 = boto3.client("s3", region_name="us-east-1")
s3.put_object(
Bucket=BUCKET,
Key=KEY,
Body=image,
ContentType="image/png",
)
print(f"s3://{BUCKET}/{KEY}")
asyncio.run(main())
Replace the bucket, Region, and key with your values. Boto3 can obtain credentials from its configured provider chain; avoid embedding access keys in source code. Boto3’s presigned URL guidance recommends Signature Version 4, the bucket Region, and virtual-hosted style addressing for its configuration. Those are Boto3 guidance, not universal settings for every SDK. See Boto3 presigned URLs.
Node.js with AWS SDK for JavaScript v3
npm install @aws-sdk/client-s3 playwright
const { chromium } = require('playwright');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function capture(url) {
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
return await page.screenshot({ fullPage: true, type: 'png' });
} finally {
await browser.close();
}
}
(async () => {
const image = await capture('https://example.com');
const key = `screenshots/example-com/${crypto.randomUUID()}.png`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: image,
ContentType: 'image/png',
}));
console.log(`s3://${process.env.S3_BUCKET}/${key}`);
})();
On Node.js versions without a global crypto.randomUUID(), import it with const { randomUUID } = require('node:crypto') and call randomUUID(). Configure AWS credentials using the SDK’s normal credential providers, such as an instance or task role.
5. Upload from a client with a presigned PUT URL
A presigned URL lets a client perform a limited S3 operation for a limited time without receiving AWS credentials. Your backend creates a URL for one intended bucket and key; the client uploads the exact screenshot bytes using the signed method and headers. A presigned URL is a bearer token: anyone who obtains it can use its permitted operation while it remains valid. Protect it and keep its lifetime short. It cannot outlast the creator’s credentials. See the Amazon S3 presigned URL guide.
Create a presigned URL with Python
import boto3
s3 = boto3.client("s3", region_name="us-east-1")
url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": "your-bucket-name",
"Key": "screenshots/upload-unique-id.png",
"ContentType": "image/png",
},
ExpiresIn=300,
)
print(url)
Upload with cURL
Use the URL returned by your backend. If ContentType was included when signing, send the matching header exactly.
curl -X PUT \
-H "Content-Type: image/png" \
--data-binary @screenshot.png \
"PRESIGNED_PUT_URL"
The presigned URL is temporary upload authority, not a permanent public image URL. Decide separately how authorized readers retrieve the object. S3 supports downloads through separate permissions or a separately issued presigned GET URL.
6. ScreenshotNeo API to S3
When you prefer a hosted capture API, ScreenshotNeo can return screenshot bytes directly to your backend. The following cURL command captures a PNG and saves the response body locally:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
To upload the result from Python, capture the response bytes and send them to S3 with Boto3:
import requests
import boto3
response = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
response.raise_for_status()
s3 = boto3.client("s3", region_name="us-east-1")
s3.put_object(
Bucket="your-bucket-name",
Key="screenshots/stripe-com/page.webp",
Body=response.content,
ContentType="image/webp",
)
For Node.js, fetch the image bytes and pass them to the AWS SDK:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: process.env.AWS_REGION });
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: 'screenshots/stripe-com/page.webp',
Body: image,
ContentType: 'image/webp',
}));
Use the output format you request when setting the object extension and content type. Keep the ScreenshotNeo API key on your backend. See the ScreenshotNeo documentation for capture parameters and response details.
7. Object keys, metadata, and access
An S3 object is identified by its bucket and key. Uploading to a key that already exists replaces that object, so use unique keys when each capture should be retained, or intentionally version a stable key when replacement is desired. For example, include an application record ID and a generated capture ID in the path.
- Choose a key format that is stable enough for your application to find objects, but unique enough to avoid unintended overwrites.
- Set an accurate
ContentType, such asimage/pngorimage/webp, so clients interpret the object correctly. - Store the bucket, key, source URL, capture timestamp, dimensions, and format in your application database if the application needs them. Avoid putting sensitive URL query values into keys or logs.
- Keep objects private unless public access is an explicit requirement. A successful upload does not make the object publicly readable.
- Set lifecycle and retention behavior to fit your application’s needs; do not assume a capture should be retained indefinitely.
AWS documents that uploading to a presigned URL’s existing key replaces the object. Its upload guide also explains the required matching content type and common signature troubleshooting: Uploading objects with presigned URLs.
8. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
SignatureDoesNotMatch |
The HTTP method, key, URL, or a signed header differs from what was signed. | Use the exact URL without modification, use PUT, match signed headers such as Content-Type, and check the bucket Region and system clock. |
AccessDenied |
The signing identity lacks permission for the requested bucket/key, or a bucket policy blocks it. | Check the creator’s IAM permissions, bucket policy, key prefix, and requested operation. Presigned URLs cannot grant more access than the creator has. |
| Expired token or URL | The URL expiry passed or temporary signing credentials expired first. | Mint a fresh URL shortly before upload and account for the credential lifetime. |
| Object has wrong media type | Content type was omitted or differs from the signed/uploaded value. | Set the correct type in the S3 SDK call and send the identical header for a presigned PUT. |
| Screenshot is blank or incomplete | The page had not rendered, navigation timed out, or content was lazy-loaded. | Wait for a meaningful selector or application-ready condition, check the page’s accessibility from the capture runtime, and adjust navigation timeout or wait strategy. |
| Playwright navigation timeout | The page never reaches the chosen load condition, often due to ongoing requests. | Use a less restrictive navigation condition such as domcontentloaded and explicitly wait for the content needed in the image. |
| Element locator not found | The selector is wrong, the element appears later, or it is in a frame or shadow tree. | Verify the selector against the rendered page, wait for the element, and use the appropriate frame or locator strategy. |
| Unexpected overwrite | A previous upload used the same bucket and key. | Generate a unique key or use intentional versioned naming. |
| Image cannot be opened | The bytes, file extension, and declared format do not match. | Use the same format for capture type, key extension, and S3 ContentType; confirm the capture response succeeded before upload. |
9. Performance, reliability, and cost
Capture and upload are separate sources of latency and failure. Reuse browser processes where your worker architecture permits, but isolate pages and close resources after each job. Avoid capturing unnecessarily large full-page images; choose viewport or element capture when that satisfies the requirement. Use a sensible timeout, retry transient failures with bounded backoff, and avoid blindly retrying a non-idempotent workflow with a stable key if overwriting is not intended.
For reliability, record the source URL, capture options, object key, and outcome. A capture can succeed while an upload fails, or vice versa, so make retries resume from the failed stage where practical. Use unique job and object identifiers to make retries safe. Do not log API keys, AWS credentials, or full presigned URLs.
Costs depend on your chosen screenshot provider or browser infrastructure, image processing, and S3 storage and requests. The research sources do not establish current S3 or other provider pricing, so check the relevant provider’s current pricing pages before estimating production spend. ScreenshotNeo offers 1,000 screenshots per month free with no card; paid plans are Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free, and every feature is on every plan.
Or skip the browser setup
Make one GET request and upload the returned bytes to S3. This cURL example writes the screenshot response to a file:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. See the API docs for options, then sign up for 1,000 free screenshots a month.
FAQ
Does a presigned PUT URL make the uploaded screenshot public?
No. It grants temporary authority to perform the signed upload. Object read access is configured separately.
Can I send a Playwright screenshot buffer directly to S3?
Yes. The screenshot API can return bytes, and an AWS SDK can accept those bytes as the object body without first writing a local file.
Should each screenshot have a new S3 key?
Use a new key when you need to preserve each capture. Reusing a key replaces the existing object unless your bucket’s versioning setup preserves prior versions.
When should I use PDF instead of a full-page image?
Use PDF when the output is a document intended for pagination, printing, or text-oriented review. Use a full-page image when one raster image is the required artifact.


