API Reference
The Screenmint base URL is https://api.screenmint.dev. Every endpoint accepts and returns JSON. Render a page in one call: you get back a hosted image URL, ready to drop into an <img> tag or a meta tag.
curl -X POST https://api.screenmint.dev/v1/screenshot \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'{
"url": "https://cdn.example/screenshots/9f2c….jpeg",
"width": 1280,
"height": 720,
"cached": false,
"render_ms": 1240
}Authentication
Authenticate every request with an X-API-Key header. Keys are created and revoked in the dashboard; the plaintext key is shown exactly once at creation, so store it somewhere safe.
X-API-Key: sk_live_...
Rate limits & caching
Limits apply per account, not per API key: every key on the account (and teammates working in it) shares them, so creating more keys doesn’t raise them. There’s a per-minute request rate (fixed one-minute windows), a cap on renders in flight at once, and a monthly render quota. On the Free plan the quota resets on the 1st of each month (UTC); on paid plans it resets on your billing-period anniversary — subscribe on the 18th and it resets on the 18th.
| Plan | Requests / min | Renders in flight | Renders / month |
|---|---|---|---|
| Free | 10 | 1 | 100 |
| Starter | 60 | 3 | 2,000 |
| Pro | 300 | 6 | 20,000 |
Identical requests are cached — screenshots and videos for 1 hour, OG cards for 24 hours. Cache hits return in milliseconds with "cached": true and the same URL, and do not consume monthly renders (they still count toward the per-minute rate, except repeat views of a signed URL). After that, the same request renders afresh and gets a new URL, so you always get the page as it is now.
Errors
Errors return a JSON envelope with a stable error code, an optional human-readable message, and, on rate-limit and quota 429s, reset_at.
{
"error": "rate_limit_exceeded",
"reset_at": "2026-07-22T18:04:00.000Z"
}| Status | Code | When |
|---|---|---|
| 400 | invalid_params | A body field failed validation — the message field says which one and why. |
| 400 | invalid_url | The URL is not valid http(s), or it resolves to a private / internal address. |
| 401 | invalid_api_key | The X-API-Key header is missing or not recognized. |
| 401 | missing_signature | A signed URL is missing its token or sig parameter. |
| 401 | invalid_signature | A signed URL’s sig doesn’t match its parameters, or its key was revoked. Sign exactly the parameters you send, and re-sign after changing any of them. |
| 403 | plan_upgrade_required | The feature isn’t on your plan — e.g. async smooth video on Free. |
| 403 | domain_blocked | The URL’s host is blocked from being rendered. |
| 403 | account_blocked | The account is suspended. Contact [email protected]. |
| 422 | unrenderable_url | The host does not resolve, or the page failed to load in time: 15 seconds, or 45 for smooth (async) videos. |
| 429 | rate_limit_exceeded | Per-minute request limit reached (per account, across all its keys). reset_at is when the window resets. |
| 429 | concurrency_limit_exceeded | Too many renders in flight at once for your plan (1 on Free, 3 on Starter, 6 on Pro, of which at most 1, 2 or 3 videos), or video capacity is momentarily full. Retry with backoff once a render finishes. This 429 has no reset_at. |
| 429 | monthly_quota_exceeded | Monthly render quota used up. reset_at is when it resets: the 1st of next month (UTC) on Free, your billing-period anniversary on paid plans. |
| 429 | too_many_jobs | Too many smooth videos in progress (2 on Starter, 5 on Pro). Wait for one to finish. |
| 429 | too_many_batches | Two batches are already running on this account. Wait for one to finish. |
| 503 | service_unavailable | All renderers are busy. Retry with exponential backoff. |
| 504 | render_timeout | The page kept the renderer busy past the time limit — e.g. a script that never finishes. Not charged. |
POST/v1/screenshot
Capture a screenshot of any public URL. The page gets a fresh, isolated browser context, waits for the load event (up to 15 seconds), then captures, optimizes, and hosts the image for you.
| Parameter | Type | Default | Description |
|---|---|---|---|
| url required | string | — | Page to capture. http(s) only; private and internal hosts are rejected. |
| width | integer | 1280 | Viewport width in pixels. Max depends on plan: 1280 (Free), 1920 (Starter), 2560 (Pro). |
| height | integer | 720 | Viewport height in pixels. Max depends on plan: 1280 (Free), 1080 (Starter), 1440 (Pro). |
| scale | integer | 1 | Device pixel ratio. 2 = retina/HiDPI (doubles output resolution). Pro plan only. |
| full_page | boolean | false | Capture the full scroll height instead of just the viewport (auto-scrolls to load lazy content). A taller page is captured from the top down to the cap: 20,000px, or 16,383px for WebP and AVIF, and less for very wide or scale: 2 captures. |
| format | string | "jpeg" | Output: "jpeg", "png", "webp", "avif", or "pdf". |
| quality | integer | 85 | Quality 1–100 for jpeg/webp/avif. Ignored for png/pdf. |
| delay | integer | 0 | Extra wait after page load — useful for animations and late JS. Max depends on plan: 5s (Free), 15s (Starter), 25s (Pro). |
| dark_mode | boolean | false | Emulate prefers-color-scheme: dark. |
| wait_for_selector | string | — | Wait until this CSS selector appears before capturing (up to 15s). |
| selector | string | — | Capture only the first visible element matching this CSS selector, instead of the page. An invalid selector, or one with no visible match, is a 400. |
| hide_selectors | string[] | — | CSS selectors to hide before capture (e.g. modals, chat widgets). |
| block_cookies | boolean | false | Hide cookie/consent banners (the common consent tools, plus custom banners recognised by their text) and undo the scroll-lock a banner leaves behind. |
| block_ads | boolean | false | Block requests to known ad/tracker/analytics hosts, and hide marketing pop-ups and chat widgets (HubSpot, Intercom, Drift, Crisp, LiveChat, OptinMonster, Popup Maker, Elementor). Hide anything else with hide_selectors. |
| inject_css | string | — | Custom CSS injected as soon as the page has loaded (it also applies to elements that appear later). |
| inject_js | string | — | Custom JavaScript run in the page once it has loaded, before wait_for_selector and delay, so those can wait for what it does. A syntax error or an exception it throws is a 400 (not charged). |
curl -X POST https://api.screenmint.dev/v1/screenshot \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"width": 1280,
"height": 720,
"full_page": false,
"format": "jpeg",
"quality": 85,
"delay": 0,
"dark_mode": false
}'{
"url": "https://cdn.example/screenshots/9f2c….jpeg",
"width": 1280,
"height": 720,
"cached": false,
"render_ms": 1240
}width and height are the size of the image you get, in pixels. With full_page, selector or scale: 2 they differ from the viewport you asked for.POST/v1/og
Generate a branded 1200×630 Open Graph card from text and colors — no design tools, no templates to host. Output is an optimized JPEG, cached for 24 hours.
| Parameter | Type | Default | Description |
|---|---|---|---|
| title required | string | — | Card headline, up to 80 characters. Clamped to 2 lines. |
| description | string | — | Supporting text, up to 160 characters. Clamped to 2 lines. |
| site_name | string | — | Small accent-colored label under the text, up to 40 characters. |
| logo_url | string | — | A direct, publicly-reachable image URL (png/jpg/svg/webp) or a data: URI — shown above the title at 40 px. Not a webpage, and the host must allow hotlinking. |
| bg_url | string | — | Screenshot this website and use it as the card background, with your text overlaid. |
| bg_image | string | — | Use this image URL (or data: URI) as the card background instead of a color. |
| overlay | number | 0.45 | Darkening scrim over a background image, 0–1, for text legibility. |
| bg_color | string | "#ffffff" | Background color, hex (ignored when a background image is set). |
| accent_color | string | "#000000" | Top bar and site_name color, hex. |
| text_color | string | "#000000" | Title and description color, hex (defaults to white over a background image). |
curl -X POST https://api.screenmint.dev/v1/og \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "How we scaled to 1M renders",
"description": "Lessons from running headless Chromium in production.",
"site_name": "Engineering Blog",
"bg_color": "#0F0F11",
"accent_color": "#8B5CF6",
"text_color": "#ffffff"
}'{
"url": "https://cdn.example/og/def4….jpeg",
"cached": false,
"render_ms": 380
}POST/v1/video
Record a page as a short video — great for capturing animations or a scroll-through of a long page. Output is mp4 (default), webm, or an animated gif.
| Parameter | Type | Default | Description |
|---|---|---|---|
| url required | string | — | Page to record. |
| seconds | integer | 5 | Recording length. Max: 5s (Free), 30s (Starter), 60s (Pro). Over 30s needs "async": true. |
| format | string | "mp4" | Output: "mp4", "webm", or "gif". |
| scroll | boolean | false | Scroll from top to bottom at an even pace over the duration. The page is scrolled through once before recording, so sections that fade in as you scroll are already showing; pages that scroll inside a panel rather than the window are scrolled too. |
| width | integer | 1280 | Frame width (plan-capped, same as screenshots). |
| height | integer | 720 | Frame height (plan-capped). |
| delay | integer | 0 | Wait after load before recording starts. |
| dark_mode | boolean | false | Emulate prefers-color-scheme: dark. |
| block_ads | boolean | false | Block ad/tracker hosts and hide marketing pop-ups and chat widgets, as for screenshots. |
| block_cookies | boolean | false | Hide cookie/consent banners, as for screenshots. |
| hide_selectors | string[] | — | CSS selectors to hide for the whole clip (e.g. an announcement bar or chat widget). |
| async | boolean | false | Starter & Pro. Capture frame by frame in the background for smooth video on heavy pages; returns a job to poll. Required for videos over 30s. See Smooth video. |
POST https://api.screenmint.dev/v1/video
{ "url": "https://stripe.com", "seconds": 6, "scroll": true, "format": "mp4" }
→ { "url": "https://cdn.example/videos/e46e…-3f9a1c2b.mp4",
"width": 1280, "height": 720, "seconds": 6, "format": "mp4",
"cached": false, "render_ms": 7100, "credits": 3 }"async": true). Because video is far more compute-intensive, it costs 1 credit per 2 seconds (rounded up) against your monthly quota — e.g. a 10s video = 5 credits. Screenshots and OG cards are 1 credit each. The response includes a credits field.Smooth video (async)
On the Starter and Pro plans, add "async": true to a /v1/video request and the video is captured frame by frame in the background. Every frame is fully drawn before the next one, so the result stays smooth even on heavy pages — big blur, glass or animated effects — that stutter when recorded in real time, and CSS animations play at their real speed. You get a job_id back immediately (202); poll it, or listen for the render.completed webhook.
POST https://api.screenmint.dev/v1/video
{ "url": "https://stripe.com", "seconds": 15, "scroll": true,
"block_cookies": true, "async": true }
→ 202 { "job_id": "7c1e…", "status": "queued", "credits": 8,
"poll": "/v1/video/jobs/7c1e…" }{
"job_id": "7c1e…", "status": "completed",
"url": "https://cdn.example/videos/9b2f….mp4",
"width": 1280, "height": 720, "seconds": 15, "format": "mp4",
"credits": 8, "render_ms": 96000
}status is queued, processing, completed or failed (with error and message). Most videos are ready within a few minutes. Each job gets 10 minutes, or 30 seconds per second of video for longer clips (30 minutes for 60s); one that can’t finish in time fails with render_timeout. When a job ends, your webhooks receive render.completed (with the job_id) or render.failed. Credits are the same as a regular video and are only charged when the video is delivered. Up to 2 smooth videos can be in progress at once on Starter and 5 on Pro; more returns 429 too_many_jobs.POST/v1/batch
Queue up to 50 renders in one call. Each item is a screenshot (default) or an OG card ("kind": "og") and takes the same params as the single endpoints — including template. You get a job_id back immediately (202); poll it or listen for the batch.completed webhook.
POST https://api.screenmint.dev/v1/batch
{
"items": [
{ "url": "https://a.com", "full_page": true },
{ "url": "https://b.com", "format": "pdf" },
{ "kind": "og", "template": "e7c1…", "title": "Hello" }
]
}
→ 202 { "job_id": "a3d6…", "status": "processing", "total": 3,
"poll": "/v1/batch/a3d6…" }{
"status": "completed", "total": 3, "completed": 3, "failed": 0,
"items": [
{ "idx": 0, "kind": "screenshot", "status": "done", "result_url": "https://…" },
{ "idx": 1, "kind": "screenshot", "status": "done", "result_url": "https://…" },
{ "idx": 2, "kind": "og", "status": "done", "result_url": "https://…" }
]
}429 monthly_quota_exceeded); if the quota runs out mid-batch, the remaining items are marked failed with the reason. Items share your plan’s renders-in-flight limit with your other requests, and wait for a free slot rather than failing. Up to 2 batches run at once per account (429 too_many_batches).Webhooks
Add an HTTPS endpoint under Webhooks in the dashboard and Screenmint POSTs a JSON event to it as renders finish — so you don’t have to poll async jobs. Each endpoint gets its own signing secret (whsec_…), shown once when you add it.
| Event | Sent when |
|---|---|
| render.completed | A screenshot, OG card or video finished — from the API, a signed URL or the dashboard playground, cache hits included. Not sent for repeat views of a signed URL whose image is already rendered, or for batch items (they report through batch.completed). data is the render result (url, cached, render_ms, …) plus endpoint; for an async smooth video it has job_id and credits in place of cached and render_ms. |
| render.failed | An async smooth video failed (async video only). data has the job_id, error and message. |
| batch.completed | Every item of a batch has finished. data has the job_id and the total, completed and failed counts; per-item results are at GET /v1/batch/:id. |
Every delivery has the same envelope — { event, data, created_at } — with the event name repeated in the X-Screenmint-Event header:
POST /webhooks/screenmint HTTP/1.1
Content-Type: application/json
User-Agent: Screenmint-Webhooks/1
X-Screenmint-Event: render.completed
X-Screenmint-Signature: sha256=3b5f0c9e41d7…
{
"event": "render.completed",
"data": {
"endpoint": "video",
"job_id": "7c1e…",
"url": "https://cdn.example/videos/9b2f….mp4",
"width": 1280, "height": 720, "seconds": 15, "format": "mp4",
"credits": 8
},
"created_at": "2026-09-29T14:03:11.482Z"
}X-Screenmint-Signature is sha256=<hex>: the HMAC-SHA256 of the raw request body, keyed with the endpoint’s full signing secret — including the whsec_ prefix. Compute it over the exact bytes you received, before parsing the JSON, and compare in constant time:
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (Buffer or string). Verify it
// before parsing — JSON that has been parsed and re-serialized won't match.
// secret: the endpoint's full signing secret, "whsec_" prefix included.
function verifyScreenmintSignature(rawBody, signatureHeader, secret) {
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const given = Buffer.from(String(signatureHeader || ''));
const want = Buffer.from(expected);
return given.length === want.length && crypto.timingSafeEqual(given, want);
}
// Express: take this route's body raw, verify, then parse.
app.post('/webhooks/screenmint', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyScreenmintSignature(
req.body,
req.get('X-Screenmint-Signature'),
process.env.SCREENMINT_WEBHOOK_SECRET, // whsec_...
);
if (!ok) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body);
// event: 'render.completed' | 'render.failed' | 'batch.completed'
res.sendStatus(200);
});Templates
Save a reusable set of defaults in the dashboard (an OG-card design or a screenshot config), then reference it by id with template. Any fields you also pass in the request override the template — so you design once and vary only what changes per render.
POST https://api.screenmint.dev/v1/og
{
"template": "e7c1…", // your saved OG template
"title": "This week's post" // overrides the template's title
}/v1/screenshot, /v1/og, and signed URLs. A screenshot template can even omit the URL and take it per-call.Signed URLs
Render by hitting a URL directly — perfect for an <img src> or an og:image meta tag, with no API key header and no server round-trip. Pass your render options as query params plus a token (your key’s signing token) and a sig (an HMAC over the params). On success it 302-redirects to the hosted image, so browsers and CDNs cache it normally.
Build the signature server-side (the signing secret must never ship to the browser): take every param except sig, sort by key, join as encodeURIComponent(k)=encodeURIComponent(v) with &, and HMAC-SHA256 it with your signing secret (hex).
import crypto from 'node:crypto';
const TOKEN = '2b9f…'; // key's signing token (public)
const SECRET = 'a1b2c3…'; // signing secret (server-side only)
function signedUrl(params) {
const q = { token: TOKEN, ...params };
const canonical = Object.keys(q).sort()
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(q[k]))
.join('&');
const sig = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
return `https://api.screenmint.dev/v1/screenshot?${canonical}&sig=${sig}`;
}
// Drop straight into HTML:
// <img src={signedUrl({ url: 'https://stripe.com', width: 1200, full_page: true })} />/v1/og. Signed renders count against your quota and rate limit exactly like keyed requests. Repeat views of an image that’s already rendered are free and don’t count against your per-minute limit, so a busy page embedding it can’t slow down your API calls. Get your token and secret from API Keys (secret is shown once at creation).GET/v1/usage
Check your current billing period programmatically — handy for alerting before you hit the quota.
curl https://api.screenmint.dev/v1/usage \
-H "X-API-Key: sk_live_..."{
"plan": "starter",
"renders_used": 487,
"renders_limit": 2000,
"period_start": "2026-07-18T09:12:44.000Z",
"period_end": "2026-08-18T09:12:44.000Z"
}