Getting started
Mint a key, send your first request, and read what comes back — including every way the API can say no.
By LeonidPublished 6 min read
There are four endpoints and one way in. Every one of them takes a key in a header, does one piece of rendering work, and answers with a file or with a JSON body that names the problem. This sheet is the part that is the same for all four: getting a key, sending it, and reading the answer. The endpoint sheets — screenshots, PDFs, HTML to image and OG images — cover what is different.
Mint a key
Keys are created on the dashboard. Sign in, create a key, and copy it: the full value is shown once, at the moment it is created, and is never shown again. What we store is a hash and an eight-character prefix, which is enough to tell you which key a call came from and not enough to make a call with.
If you lose a key, you do not recover it — you create another one and revoke the old one. That is a property of storing a hash rather than a policy decision, and it is the reason the dashboard is insistent about copying it at the time.
The free plan needs no card and answers 100 calls a month. That is deliberately enough to build against and not enough to run a product on; the plans sheet has the rest of the schedule and what each tier costs.
Send it in a header
The key goes in an authorization header, as a bearer token:
authorization: Bearer sk_your_key_here
There is deliberately no ?api_key= fallback. Three of the four endpoints are POSTs, but
/v1/og is a GET, and a GET's query string is written to our access log in full — so a key that
could travel in a query string would eventually sit in a log file. Keeping it in a header is the one
thing that stops that, and the only way to do it wrong is to invent a query parameter that does not
exist.
A missing, unknown or revoked key all answer the same way:
{ "error": "unauthorized" }
401, and identical in all three cases. A revoked key is indistinguishable from a key that never existed, which is what you want from a suspension: nothing about the response tells an attacker whether they guessed a real prefix.
Send a request
Three endpoints are POST with a JSON body; /v1/og is a GET with a query string. All four answer
with the file itself on success — a PNG, a JPEG or a PDF — not with a JSON envelope containing a URL
you then have to fetch. There is one round trip, and the bytes are in it.
The request body ceiling is 4 MB. That number is larger than it looks like it needs to be, and the reason is on the HTML to image sheet: the document limits are counted in UTF-16 code units while the body limit is counted in bytes, and a character outside Latin costs three bytes and one code unit. A body over the ceiling is refused before anything parses it, before a key is looked up, and before a render slot is taken:
{ "error": "payload_too_large" }
Read the answer
On success you get the file, a content-type that matches it, and an x-cache header saying
hit or miss. On anything else you get JSON. Every refusal the API decides for itself carries a
short error code; the one exception is a body that failed validation, which answers with the
validator's report so it can name the field. The complete list, and what each one means for you:
| Status | error | What happened |
|---|---|---|
| 400 | (a validation report) | A parameter is missing, too long, or wrong for this endpoint. The body is the validator's own report — success: false and an error object whose message lists every failing field by path and says what was expected. It is the only error body on this list without a short error code, because it is the only one that has to name a field. |
| 401 | unauthorized | Missing, unknown or revoked key. |
| 413 | payload_too_large | The request body is over 4 MB. |
| 422 | url_rejected | The address you asked us to render is one we will not visit. reason says which rule. |
| 429 | rate_limited | Too many calls this minute. retryAfterSec says how long to wait. |
| 429 | quota_exceeded | This month's calls are used up. resetsAt is when the counter turns over. |
| 429 | concurrent_limit | This key already has its plan's renders in flight. |
| 502 | render_failed | The page or document could not be rendered. |
| 502 | render_too_large | The result exceeded the byte budget for one render. |
| 503 | busy | Every render slot on the box is taken. Retry. |
| 503 | guard_unverified | This instance will not render; its boot-time safety check has not passed. |
| 504 | render_timeout | The document did not settle inside the budget. |
Two of those are ours rather than yours. busy and guard_unverified are the API saying it cannot
serve right now, and neither writes a usage row or spends a call from your quota. Neither does any
refusal above them: the quota, the per-minute limit and the concurrency limit are all checked
before the work is done, so being told no is never billed. That is the invariant, and it is why a
month of nothing but 429s costs you nothing.
Three limits, and which one you hit
Every plan carries three separate numbers, and they refuse for different reasons.
Calls a month is the thing you are buying. It is counted per UTC calendar month, hard, with no
overage: when it is gone the API answers quota_exceeded until the first of the next month rather
than billing you for more. Unused calls do not carry over.
Calls a minute is a burst guard on a fixed 60-second window, not a product promise. It exists so
one client looping without a sleep cannot make the box unresponsive for everyone else. If you are
hitting it, add a small delay; the retryAfterSec field tells you how small.
Renders in flight is how many of your calls may be rendering at the same moment. This is the one
that surprises people, because it is a limit on concurrency rather than on volume: a job that fires
twenty screenshots in parallel on a plan allowing one will get concurrent_limit nineteen times and
one image. Send them in sequence, or with a small worker pool sized to your plan.
The plans sheet publishes all three numbers per tier, read from the API's own specification at the moment you load the page — so what it shows is what the process is enforcing, not what a page once said.
The cache
Identical requests inside 24 hours come back from cache. "Identical" means every parameter: change
the width, the format, a single character of a title, and it is a different image and a fresh render.
The response carries x-cache: hit or x-cache: miss so you can tell which you got.
A cache hit is still a metered call. That is worth saying plainly, because the alternative — free repeats — would make your monthly figure depend on our storage rather than on your usage, and would make a cache eviction look like a billing change. What the cache buys you is latency and load, not quota.
What we will not render
Three of the four endpoints fetch an address you give them, and there are addresses we refuse: your
own private network, a cloud metadata endpoint, anything using a scheme other than http or https,
and our own infrastructure. The refusal is url_rejected with a reason naming the rule. This is
enforced in code, in an in-process proxy that resolves the name and opens the socket itself, so the
address we check is the address we dial — and redirects and sub-resources are new connections, so
they are checked again. The screenshot sheet explains the mechanism properly.
There are also things you may not do with a key regardless of what the code enforces. Those are on the acceptable use sheet, and they are short.
Where to go next
Pick the endpoint that matches the job. Every endpoint sheet carries the same commissioning test at its foot: the exact request, and the status it returns. Those requests are run against the API in CI before the page ships, so a stale example fails a build rather than sitting here misleading you. On the OG images sheet the test is live — you can press it and get a real PNG back.
Sources
- RFC 6750 — The OAuth 2.0 Authorization Framework, Bearer Token Usage (www.rfc-editor.org)
- MDN — Retry-After (developer.mozilla.org)
- MDN — HTTP conditional requests and caching (developer.mozilla.org)