Developers
API reference
Everything the Shotwisp API does, on one page: authenticated image uploads, website screenshots, PDFs, errors, rate limits, and quotas. Plain HTTPS in, JSON out.
Overview
Introduction
The Shotwisp API stores images and renders public web pages as screenshots or PDFs. Every request is plain HTTPS; every response is JSON unless you ask for raw bytes.
https://shotwisp.comAll paths on this page are relative to the base URL. Uploads support single and batch routes; screenshots and PDFs each have a render route. Every endpoint requires an API key, which you create in the dashboard under API keys.
Each endpoint also has a dedicated page with Node.js and Python examples: /upload, /upload/batch, /screenshot, and /pdf.
/openapi.json, a concise llms.txt index, and the full text docs at /llms-full.txt. Building with an AI agent? Start at the AI agent guide.Start here
Quick start
From zero to a captured screenshot in three steps.
- Create an account (free, no card) and generate a key under API keys.
- Send it on every request as
Authorization: Bearer YOUR_API_KEY. - Make your first screenshot request:
curl -X POST https://shotwisp.com/api/v1/screenshot \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "response": "json"}'The response is capture metadata plus a hosted image URL:
{
"id": "s3Nv8qLp5TkX2wYc",
"url": "https://shotwisp.com/s/s3Nv8qLp5TkX2wYc",
"format": "png",
"width": 1440,
"height": 900,
"full_page": false,
"duration_ms": 2140,
"size_bytes": 208431
}Drop "response": "json" to receive the PNG bytes directly instead.
Security
Authentication
Every API request is authenticated with a bearer key. Keys are scoped to your account and can be revoked at any time.
Pass the key in the Authorization header of every request. Keys start with sw_ and are shown once, at creation — keep the value in an environment variable rather than in source control.
Authorization: Bearer sw_4f8a09c2e7b1d6a35f90c48e21b7d3aa64c1e8f2# store the key once, e.g. in your shell profile or .env
export SHOTWISP_API_KEY="sw_4f8a09c2e7b1d6a35f90c48e21b7d3aa64c1e8f2"
# every request carries the same header
curl https://shotwisp.com/api/v1/screenshot \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "response": "json"}'401 unauthorized. Revoking a key in the dashboard takes effect immediately.Endpoint · 01
Upload an image
Send an image as multipart form data, get back a share link that renders a clean viewer page, plus direct file and delete URLs.
/api/v1/uploadRequest
Send multipart/form-data with a file part. Uploads count against the monthly quota of your plan.
| Parameter | Type | Default | Description |
|---|---|---|---|
filerequired | file (binary) | — | The image, sent as a multipart form part named "file". Allowed formats: PNG, JPEG, GIF, WebP, SVG, AVIF — detected from the file's bytes, not its filename or declared content type. Maximum size 10 MB. |
expires_in | integer | — | Lifetime of the link in seconds, from 60 (one minute) to 31,536,000 (one year). Defaults when omitted: no scheduled expiry on an active paid plan; the 30-day plan maximum on Free. Free-plan links are capped at 30 days regardless of the value sent. Range: 60–31,536,000. |
curl -X POST https://shotwisp.com/api/v1/upload \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-F "file=@./screenshot.png" \
-F "expires_in=86400"const form = new FormData();
form.append("file", file); // File or Blob
form.append("expires_in", "86400"); // optional
const res = await fetch("https://shotwisp.com/api/v1/upload", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SHOTWISP_API_KEY}` },
body: form,
});
const upload = await res.json();
console.log(upload.url); // https://shotwisp.com/i/k7mwq2axResponse
Success returns 201 Created. url is the share page, file_url serves the raw image, and delete_url removes the upload without further authentication. content_type is the type we detected in the bytes, which is not necessarily the one you sent.
{
"id": "u7Kd2mXq9RfW4bZn",
"slug": "k7mwq2ax",
"url": "https://shotwisp.com/i/k7mwq2ax",
"file_url": "https://shotwisp.com/f/k7mwq2ax",
"delete_url": "https://shotwisp.com/api/uploads/u7Kd2mXq9RfW4bZn/delete?token=Fj3kW9sLq2Xv7Rp4Tz8mNc5d",
"expires_at": "2026-08-11T14:05:00.000Z",
"size_bytes": 481290,
"content_type": "image/png",
"filename": "screenshot.png"
}delete_url as a secret — anyone holding it can delete the upload. expires_at is null when an active paid plan has no scheduled link expiry. If paid access ends, those links receive a 30-day grace period.Batch uploads
/api/v1/upload/batchRepeat the file part to upload up to 10 images in one request, with at most 50 MB of combined file data. expires_in applies to every file in the batch, while the normal per-file plan limit and monthly upload quota still apply individually.
| Parameter | Type | Default | Description |
|---|---|---|---|
filerequired | file (binary, repeated) | — | One part per image, all named "file". Up to 10 files and 50 MB of combined file data per request; each file also has the individual 10 MB limit and byte-signature validation of the single-upload endpoint. |
expires_in | integer | — | Lifetime of the link in seconds, from 60 (one minute) to 31,536,000 (one year). Defaults when omitted: no scheduled expiry on an active paid plan; the 30-day plan maximum on Free. Free-plan links are capped at 30 days regardless of the value sent. Applies to every file in the batch. Range: 60–31,536,000. |
curl -X POST https://shotwisp.com/api/v1/upload/batch \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-F "file=@./first.png" \
-F "file=@./second.jpg" \
-F "expires_in=86400"Results stay in request order. The endpoint returns 201 Created when every file succeeds, or 207 Multi-Status when some files succeed and others fail. Request-level authentication, parsing, size, and rate-limit failures retain their normal 4xx response.
{
"items": [
{
"ok": true,
"index": 0,
"source_filename": "first.png",
"upload": {
"id": "u7Kd2mXq9RfW4bZn",
"slug": "k7mwq2ax",
"url": "https://shotwisp.com/i/k7mwq2ax",
"file_url": "https://shotwisp.com/f/k7mwq2ax",
"delete_url": "https://shotwisp.com/api/uploads/u7Kd2mXq9RfW4bZn/delete?token=Fj3kW9sLq2Xv7Rp4Tz8mNc5d",
"expires_at": "2026-08-11T14:05:00.000Z",
"size_bytes": 481290,
"content_type": "image/png",
"filename": "first.png"
}
},
{
"ok": false,
"index": 1,
"source_filename": "second.jpg",
"error": {
"code": "unsupported_type",
"message": "Unsupported image type. The file's contents do not match a supported format."
}
}
],
"summary": { "total": 2, "succeeded": 1, "failed": 1 }
}Validation and serving
Files are validated by signature, not by filename or declared content type: a .png that does not carry PNG bytes is rejected with 415 unsupported_type. SVG uploads are sanitized before storage — scripts, event handlers, embedded objects, external entities, and off-document references are stripped, and the sanitized bytes are what we keep.
file_url hands off to the CDN origin, so image bytes never come from the app origin. Every file is served with X-Content-Type-Options: nosniff, and SVG additionally with Content-Security-Policy: sandbox.Endpoint · 02
Take a screenshot
Describe a capture in JSON and receive the rendered image back — or a JSON summary of the capture, if you only need metadata.
/api/v1/screenshotRequest
Send a JSON body. Only url is required; everything else has a sensible default.
| Parameter | Type | Default | Description |
|---|---|---|---|
urlrequired | string | — | Absolute URL of the page to capture. Public http(s) addresses only — localhost, private hosts, and internal IP ranges are rejected. |
full_page | boolean | false | Capture the full scrollable height of the page instead of just the viewport. width and height still set the viewport the page lays out in. |
format | string | "png" | Output image encoding. One of: png, jpeg, webp. |
width | integer | 1440 | Viewport width in CSS pixels. Range: 320–3,840. |
height | integer | 900 | Viewport height in CSS pixels. Range: 320–2,160. |
response | string | "image" | "image" streams the encoded image bytes back; "json" returns capture metadata and a hosted image URL instead. One of: image, json. |
curl -X POST https://shotwisp.com/api/v1/screenshot \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "full_page": true, "format": "png"}' \
-o shot.pngResponse
By default the response streams the encoded image with the matching Content-Type. If the target page cannot be loaded, the request fails with 502 capture_failed — see Errors. Failed captures are not metered.
x-shotwisp-id and the render time in x-shotwisp-duration-ms, alongside the usual quota and rate-limit headers.Set response to json to receive capture metadata instead of bytes. The url field is the hosted image — a link to the stored capture, not the page you captured.
curl -X POST https://shotwisp.com/api/v1/screenshot \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "width": 1280, "height": 800, "response": "json"}'{
"id": "s3Nv8qLp5TkX2wYc",
"url": "https://shotwisp.com/s/s3Nv8qLp5TkX2wYc",
"format": "png",
"width": 1280,
"height": 800,
"full_page": false,
"duration_ms": 2140,
"size_bytes": 208431
}Endpoint · 03
Render a PDF
Turn a public web page into a print-ready PDF with focused controls for paper, layout, responsive viewport, and page readiness.
/api/v1/pdfRequest
Send a JSON body. Only url is required. A successful PDF consumes one screenshot unit because it uses the same managed-browser render capacity.
| Parameter | Type | Default | Description |
|---|---|---|---|
urlrequired | string | — | Absolute URL of the page to render. Public http(s) addresses only — localhost, private hosts, and internal IP ranges are rejected. |
paper_format | string | "letter" | Paper size of the generated PDF. One of: letter, a4, legal. |
landscape | boolean | false | Rotate the selected paper size to landscape orientation. |
print_background | boolean | true | Include CSS background colors and images in the PDF. |
prefer_css_page_size | boolean | false | Let the page's CSS @page size override paper_format when the page defines one. |
scale | number | 1 | Render scale applied to the page content. Range: 0.1–2. |
margin | object | {"top":0,"right":0,"bottom":0,"left":0} | Page margins. Each side accepts a non-negative number (CSS pixels) or a CSS length string with px, in, cm, or mm units. |
margin.top | number | string | 0 | Margin length: a non-negative number (CSS pixels, up to 2000) or a CSS length string using px, in, cm, or mm. |
margin.right | number | string | 0 | Margin length: a non-negative number (CSS pixels, up to 2000) or a CSS length string using px, in, cm, or mm. |
margin.bottom | number | string | 0 | Margin length: a non-negative number (CSS pixels, up to 2000) or a CSS length string using px, in, cm, or mm. |
margin.left | number | string | 0 | Margin length: a non-negative number (CSS pixels, up to 2000) or a CSS length string using px, in, cm, or mm. |
viewport | object | {"width":1440,"height":900} | Browser viewport applied before printing. Controls the page's responsive layout, not the paper dimensions. |
viewport.width | integer | 1440 | Viewport width in CSS pixels. Range: 320–3,840. |
viewport.height | integer | 900 | Viewport height in CSS pixels. Range: 320–2,160. |
wait_until | string | "networkidle2" | Page readiness event to wait for before rendering. One of: load, domcontentloaded, networkidle0, networkidle2. |
delay_ms | integer | 0 | Extra wait after page readiness, in milliseconds. Useful for animations or late client-side rendering. Range: 0–10,000. |
response | string | "pdf" | "pdf" streams application/pdf bytes back; "json" returns render metadata and a hosted PDF URL instead. One of: pdf, json. |
curl -X POST https://shotwisp.com/api/v1/pdf \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"paper_format": "a4",
"print_background": true,
"margin": { "top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in" }
}' \
-o page.pdfResponse
By default, the response streams application/pdf bytes. It carries x-shotwisp-id, render duration, quota, and rate-limit headers. Failed renders are not metered.
Set response to json for metadata and a hosted url that opens the stored PDF.
curl -X POST https://shotwisp.com/api/v1/pdf \
-H "Authorization: Bearer $SHOTWISP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "landscape": true, "response": "json"}'{
"id": "p8Fm3qRt7VwK2xNc",
"url": "https://shotwisp.com/p/p8Fm3qRt7VwK2xNc",
"paper_format": "letter",
"landscape": true,
"print_background": true,
"prefer_css_page_size": false,
"scale": 1,
"margin": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
"viewport": { "width": 1440, "height": 900 },
"wait_until": "networkidle2",
"delay_ms": 0,
"duration_ms": 1840,
"size_bytes": 42718
}viewport chooses the page’s responsive breakpoint. paper_format chooses the printed sheet. They are intentionally separate.Reference
Errors
Errors are boring on purpose. Every failure returns the same JSON envelope with a stable, machine-readable code — and every code has fixed retry semantics.
| Code | HTTP status | Retryable | Meaning |
|---|---|---|---|
unauthorized | 401 | no | The API key is missing, malformed, revoked, or the account is suspended or unverified. Do not retry with the same key. Fix the Authorization header or create a new API key. |
quota_exceeded | 402 | no | The monthly upload or render quota is used up and overages are off or unavailable on the plan. Do not retry until the monthly quota resets, the plan is upgraded, or overages are enabled. |
method_not_allowed | 405 | no | The endpoint only accepts POST requests. Do not retry. Use POST. |
not_found | 404 | no | The requested path does not exist under /api/. Likely a typo in the endpoint URL. Do not retry. Check the endpoint path against /openapi.json. |
file_too_large | 413 | no | An uploaded file exceeds the 10 MB per-file limit. Do not retry unchanged. Reduce the file below the limit. |
batch_too_large | 413 | no | Combined file data in a batch exceeds 50 MB. Do not retry unchanged. Split the batch or reduce combined size. |
unsupported_type | 415 | no | The file's bytes do not match a supported image format (PNG, JPEG, GIF, WebP, SVG, AVIF). Do not retry unchanged. Convert the file to a supported image format. |
validation_error | 422 | no | A request field failed validation. The message states which field and why. Do not retry unchanged. The message names the failing field; fix it first. |
too_many_files | 422 | no | A batch contains more than 10 file parts. Do not retry unchanged. Send fewer files per batch. |
rate_limited | 429 | yes | The plan's per-minute request budget is spent. Rejected requests are not metered. Retry after waiting the number of seconds in the Retry-After header, then back off exponentially with jitter. |
concurrency_limited | 429 | yes | The plan's concurrent-capture cap is fully in use by renders still in progress. Retry after the Retry-After delay once an in-flight capture finishes. Reduce parallelism to the plan's concurrency cap. |
internal | 500 | yes | Unexpected error on Shotwisp's side. The request was not metered. Safe to retry with exponential backoff. |
capture_failed | 502 | yes | The target page could not be loaded or captured (unreachable, timed out, or blocked the renderer). Failed captures are not metered. Retry once or twice with backoff. If it persists, the target page cannot be captured — do not keep retrying. |
render_failed | 502 | yes | The target page could not be loaded or rendered as a PDF. Failed renders are not metered. Retry once or twice with backoff. If it persists, the target page cannot be rendered — do not keep retrying. |
{
"error": {
"code": "quota_exceeded",
"message": "Monthly screenshot quota exceeded (250/250). Upgrade your plan to continue."
}
}Retry-After; 500 and 502 are retryable with exponential backoff. The full mapping is also machine-readable in the OpenAPI spec and expanded in the AI agent guide.Limits
Rate limits
Each plan carries a sustained per-minute request budget and a ceiling on captures running at once. The budget is one counter per account, shared by every key and both endpoints.
| Plan | Requests / minute | Concurrent captures |
|---|---|---|
| Free | 5 | 1 |
| Starter | 40 | 3 |
| Pro | 80 | 6 |
| Business | 150 | 12 |
Successful calls carry your position in the current window, as do the 402 and 429 responses.
| Header | Meaning |
|---|---|
x-shotwisp-ratelimit-limit | Requests allowed in the current minute on your plan. |
x-shotwisp-ratelimit-remaining | Requests left in the current window. |
x-shotwisp-ratelimit-reset | Seconds until the window rolls over and the budget refills. |
Once the budget is spent the API answers 429 rate_limited with Retry-After in seconds. Rejected requests are not metered and do not count against your monthly quota.
HTTP/1.1 429 Too Many Requests
Retry-After: 27
x-shotwisp-ratelimit-limit: 80
x-shotwisp-ratelimit-remaining: 0
x-shotwisp-ratelimit-reset: 27
{"error":{"code":"rate_limited","message":"Rate limit exceeded. Retry in 27s."}}Wait at least Retry-After seconds, then back off exponentially with jitter. Retrying immediately on a fixed interval keeps you pinned at the limit.
async function callWithBackoff(send, attempts = 5) {
for (let attempt = 0; ; attempt++) {
const res = await send();
if (res.status !== 429 || attempt === attempts - 1) return res;
// Honour Retry-After, then back off exponentially with jitter.
const retryAfter = Number(res.headers.get("retry-after")) || 1;
const waitMs =
Math.max(retryAfter, 2 ** attempt) * 1000 + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}Limits
Quotas and overages
Usage is metered per calendar month against your plan. Past quota, requests either keep succeeding and bill per unit or stop with 402 — that choice is the overages setting on your account.
| Plan | Uploads / month | Screenshots / month | Overage / screenshot | Overage / upload |
|---|---|---|---|---|
| Free | 50 | 250 | — | — |
| Starter | 500 | 2,000 | $0.008 | $0.004 |
| Pro | 5,000 | 10,000 | $0.005 | $0.0025 |
| Business | 25,000 | 50,000 | $0.003 | $0.0015 |
Quotas reset on the first day of each calendar month at 00:00 UTC. Every plan accepts files up to 10 MB. Full plan details are on the pricing page.
Overages
With overages enabled, requests past quota succeed as normal and each extra unit is billed at your plan’s per-unit rate; the response carries x-shotwisp-overage: true so you can count billable units yourself. With overages disabled — the default — the API returns 402 quota_exceeded until the month rolls over or you upgrade. Free has no overage rate, so it always stops at the quota.
Usage headers
Every metered call — upload or screenshot, image bytes or JSON — reports your quota position as of that request, so you can react before the limit rather than after it.
| Header | Meaning |
|---|---|
x-shotwisp-quota-limit | Monthly allowance for this unit type on your plan. |
x-shotwisp-quota-remaining | Units left before the quota is spent. Reaches 0 and stays there. |
x-shotwisp-quota-usage-percent | Usage so far this month, as a whole-number percentage of quota. |
x-shotwisp-quota-warning | 80, 90, or 100. Present only once usage has crossed that threshold. |
x-shotwisp-overage | true when the unit you just used was past quota and billed as an overage. |
HTTP/1.1 200 OK
Content-Type: image/png
x-shotwisp-id: s3Nv8qLp5TkX2wYc
x-shotwisp-ratelimit-limit: 80
x-shotwisp-ratelimit-remaining: 78
x-shotwisp-ratelimit-reset: 41
x-shotwisp-quota-limit: 10000
x-shotwisp-quota-remaining: 1180
x-shotwisp-quota-usage-percent: 88
x-shotwisp-quota-warning: 80x-shotwisp-quota-warning appears at 80, 90, and 100 percent of quota; the same thresholds drive the dashboard warnings. A 402 quota_exceeded response still carries x-shotwisp-quota-limit and x-shotwisp-quota-remaining.
Reference
All limits
Every fixed limit in one place. Per-plan numbers (rate limits, quotas, concurrency) are in the two sections above and on the pricing page — the values here apply to every plan.
| Limit | Value |
|---|---|
| Max upload file size | 10 MB (every plan) |
| Supported upload formats | PNG, JPEG, GIF, WebP, SVG, AVIF (detected from file bytes) |
| Batch upload | 10 files and 50 MB combined per request |
| Upload link lifetime (expires_in) | 60 s to 31,536,000 s (one year); omit on a paid plan for no expiry |
| Screenshot width | 320–3840 px (default 1440) |
| Screenshot height | 320–2160 px (default 900) |
| Screenshot formats | png (default), jpeg, webp |
| PDF paper formats | letter (default), a4, legal |
| PDF scale | 0.1–2 (default 1) |
| PDF delay_ms | 0–10,000 ms (default 0) |
| Target URL | Public http(s) URLs only; localhost, private networks, and cloud metadata hosts are rejected |
| Page navigation timeout | 25 s, plus up to 3 s network settle |
| Total render budget | 60 s per screenshot, 75 s per PDF |
| API request cap | 90 s end to end |
| Rate limit window | Fixed one-minute window per account, shared across all v1 endpoints and API keys |
| Quota reset | First day of each calendar month, 00:00 UTC |
| Stored captures | Screenshots and PDFs stay available at their hosted URL until you delete them from the dashboard |
| Anonymous quick-share uploads | Always expire within 24 hours (web upload page, not the API) |
Reference
Capture capabilities
What the screenshot and PDF renderers do and — just as important — what they deliberately don't. Only features listed as supported exist in production.
| Feature | Supported | Notes |
|---|---|---|
| Full-page screenshots | yes | Set full_page: true. |
| Custom viewport size | yes | width 320–3840, height 320–2160 (both endpoints; viewport object on PDF). |
| PNG / JPEG / WebP output | yes | Screenshot format parameter. |
| JavaScript execution | yes | Always on; pages render in a real headless browser. |
| Redirect following | yes | Up to 5 redirect hops; every hop is re-validated against the private-network rules. |
| Wait strategy / extra delay | yes | PDF only: wait_until and delay_ms. Screenshots always wait for load plus a short network settle. |
| Hosted result URLs | yes | Set "response": "json" to receive a hosted URL instead of bytes. |
| PDF paper size, orientation, margins, scale | yes | paper_format, landscape, margin, scale, print_background, prefer_css_page_size. |
| Mobile device emulation / device scale factor | no | Not supported. Use a narrow viewport width for responsive layouts. |
| Custom HTTP headers or cookies on the target request | no | Not supported; pages requiring authentication cannot be captured. |
| CSS selector / element capture | no | Not supported. Capture the viewport or full page. |
| Dark mode forcing | no | Not supported. Pages render with their default color scheme. |
| Ad or cookie-banner blocking | no | Not supported. |
| Transparent background | no | Not supported. Captures include the page background. |
| JPEG/WebP quality tuning | no | Not supported. Encoder defaults are used. |
| Disabling JavaScript | no | Not supported. |
| Idempotency keys | no | Not supported. A client-side timeout may still have completed server-side; check quota headers on the next call or the dashboard before re-issuing expensive work. |
Reference
Security behavior
Guardrails that apply to every capture and upload, designed for callers that submit arbitrary URLs — including autonomous agents.
- Capture targets must be public http(s) URLs. Localhost, private and link-local IP ranges, cloud metadata endpoints, and other schemes are rejected with
validation_error, and DNS is re-resolved and re-checked immediately before the browser dials out. - During rendering, every request the page makes — redirect hops (maximum 5), iframes, and subresources — is re-validated against the same private-network rules, so a page cannot pull internal hosts into a capture.
- Your API key is never forwarded to the target page; the renderer sends no authentication of yours.
- Uploads are validated by byte signature rather than filename or declared type, and SVG files are sanitized (scripts, event handlers, and external references stripped) before storage.
- Stored files are served from an isolated origin with
X-Content-Type-Options: nosniff; SVGs additionally carry a sandboxingContent-Security-Policy. - The
delete_urlon uploads works without further authentication — treat it as a secret.
Reference
Versioning policy
Integrations — human-written or agent-generated — stay deployed for years. The API is versioned so they keep working.
- The current version is
v1; every path starts with/api/v1/. v1 URLs remain stable. - Non-breaking changes — new optional parameters, new response fields, new endpoints — may ship at any time. Clients must tolerate unknown response fields.
- Breaking changes — removing or renaming fields, changing defaults, types, enums, error codes, or status codes — would ship under a new version path, with v1 kept working through a published migration window.
- Deprecations and migrations are announced in the changelog before they take effect.