Page Capture
A JPEG of one page from the X account's own browser: a public X post cropped to the post, a status page, the account's own profile, or a news page.
Early access. Tell us what you're building and we'll set you up. Contact us
POST /accounts/{account_id}/capture (accounts.write): a picture of one
page, opened in the X account's own browser and proxy. Read-only: the browser
opens the page in a new tab, hides cookie banners and X's own overlays with CSS
(it never clicks them, so nothing is accepted or dismissed), takes a JPEG and
closes the tab. Nothing is clicked or typed. It isn't a write: no
Idempotency-Key, no receipt, not paced.
What It Opens#
Only these addresses, written exactly so. Anything else answers
400 errors/invalid_parameters (listing them) before any browser opens.
| Page | The address | The picture |
|---|---|---|
| A public X post | https://x.com/<handle>/status/<digits>: a handle of 1 to 15 letters, digits or _ (not i), 1 to 25 digits, and nothing after: no query (?s=20), no fragment, no trailing slash, no /photo/1. x.com only: not twitter.com, www.x.com or mobile.x.com. |
The post alone, read from the account's own signed-in X. |
| A status page | https://<host>, https://<host>/ or https://<host>/incidents/<id> (8 to 40 letters or digits), no query or fragment, on www.vercel-status.com, www.githubstatus.com, status.supabase.com, www.cloudflarestatus.com, status.openai.com, status.claude.com, www.netlifystatus.com, status.render.com, status.flyio.net, status.clerk.com or resend-status.com. |
The page from its top, or clip.selector. |
| The account's own X | https://x.com/<its handle> and https://x.com/i/account_analytics. |
From the top. |
| News pages | Any page on blog.google, developers.google.com, blogs.bing.com, www.seroundtable.com or techcrunch.com; on Hacker News, https://news.ycombinator.com/item?id=<digits> only. |
From the top, or clip.selector. |
| Sites added for your workspace | Any page on a site Lempile added to your workspace's list. | From the top, or clip.selector. |
Hosts are matched exactly: x.com.evil.com, status.claude.com.evil.com or
another subdomain is refused. status.anthropic.com and the bare
githubstatus.com, vercel-status.com, cloudflarestatus.com and
netlifystatus.com redirect elsewhere: use the address above. The browser
checks the address again before it opens it and after the page loads, so a
page that goes to another address is never captured (left_allowed_hosts).
X Posts#
The post is opened from the account's own signed-in X (never a signed-out
view) and the picture is the post's own article on its page, found by X's
markup and the post's own link, never by its words: no other post of the
conversation, and the reply box and X's header, bars, sheets and floating
buttons hidden. It ends at the post's action bar (reply, repost, like,
bookmark, share), without X's sort row under it, and the author's Subscribe
and Follow buttons are hidden, like a plain screenshot of the post. With
x_post: { counts: false }, the post's time and views row and its action bar
are hidden too, and the picture ends 16 px under its last content (its text,
media or quoted post): no views, replies, reposts, likes or bookmarks. The post
is captured only when X shows it under the handle in the address; otherwise
nothing is captured:
code |
What X showed |
|---|---|
x_post_author_mismatch |
The post under another handle (X's own address for it, or the author on the post), or no author it could read. |
x_post_protected |
A protected account's post, whether the account can see it or not. |
x_post_deleted |
X's "this page doesn't exist": the post was deleted, or never posted. |
x_post_age_restricted |
The post only behind an age or sensitive-content warning. Lempile never clicks through one. |
x_post_unavailable |
X's "This post is unavailable" notice (a suspended author, X's rules, withheld…). |
x_post_not_loaded |
X ran but didn't show the post within 25 seconds; detail quotes what X showed. Try again later. |
Request#
| Field | |
|---|---|
url |
Required. One of the addresses above. |
width |
CSS pixels, a whole number from 320 to 1280. Default 520 (under 768, X and most sites draw their phone layout). |
dpr |
Device pixel ratio, 1 to 3. Default 2: the JPEG is twice the CSS size. |
clip.maxHeight |
CSS pixels, 100 to 4000. Default 1600: the picture stops there. clip.max_height works too. |
clip.selector |
A CSS selector of at most 200 characters: the picture is that element. Not on an X post (400): a post is always cropped to the post. |
x_post.counts |
X posts only. false leaves out the post's time and views row and its action bar, so the picture shows no counts. Default true. x_post on another address answers 400. |
curl -sS -X POST "https://api.lempile.com/accounts/janedemo/capture" \
-H "X-API-KEY: $LEMPILE_API_KEY" \
-H "content-type: application/json" \
-d '{ "url": "https://x.com/OpenAI/status/1986000000000000001", "width": 520, "dpr": 2, "x_post": { "counts": false } }'Answer#
The picture: 200, with the JPEG as base64 and its size in pixels (CSS size
times dpr), the address the page ended on and its title.
{ "object": "PageCapture", "ok": true, "account_id": "11111111-1111-4111-8111-111111111111",
"url": "https://x.com/OpenAI/status/1986000000000000001",
"final_url": "https://x.com/OpenAI/status/1986000000000000001",
"title": "OpenAI on X: \"…\" / X", "width": 1040, "height": 1224,
"jpeg_base64": "/9j/4AAQSkZJRg…", "captured_at": "2026-11-07T18:20:00.000Z" }No picture: still 200, with ok: false, the browser's code and detail.
Nothing was clicked or typed; the code says whether trying again can help.
{ "object": "PageCapture", "ok": false, "account_id": "11111111-1111-4111-8111-111111111111",
"url": "https://x.com/fakeopenai/status/1986000000000000001",
"code": "x_post_author_mismatch",
"detail": "X shows this post under @OpenAI, not @fakeopenai: nothing was captured.",
"captured_at": "2026-11-07T18:20:00.000Z" }Besides the X post codes above:
code |
Meaning |
|---|---|
x_signed_out |
X is signed out in the account's browser: sign it in again in the dashboard. |
x_app_not_loaded |
X's app didn't load in the account's browser. |
left_allowed_hosts |
The page went to another address after it loaded: another post, another page of the host, another host. |
url_not_allowed |
The browser's own check refused the address. |
element_not_found |
No element for clip.selector within 10 seconds. |
browser_busy |
The account's browser was on another job for 15 seconds. Try again in a minute. |
capture_blank |
The picture came out blank (almost all of it one colour), or X drew the post's box empty: it isn't returned. Try again later. |
counts_not_hidden |
Asked with x_post: { counts: false }, but X's post wasn't built as expected around its views row and action bar, or the browser can't hide them yet: no picture, never one with counts. |
no_picture, capture_failed |
The browser gave no picture, or stopped. |
The account itself answers as on other routes: 423 errors/x_account_locked,
422 errors/proxy_auth_failed or errors/proxy_unreachable,
401 errors/disconnected_account. 400 errors/invalid_parameters is a field
out of range or an address it doesn't open, 403 a key without
accounts.write, 404 no such X account, 503 errors/service_unavailable
the browser runner out of reach, and 504 errors/timeout no picture within 90
seconds. See Errors And Limits.
Was this page helpful?