On this page
Get started
Quickstart
#Create an API key
Create one on the API keys page of your dashboard.
Send your first request
cURLcurl -G "https://api.urlshot.io/v1/screenshot" \ -H "Authorization: Bearer $URLSHOT_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "viewport_width=1280" \ --data-urlencode "full_page=true" \ --output screenshot.pngThis saves the page as
screenshot.png. Every option excepturlis optional.Use your language
Quickstart code examplesNode.js
const params = new URLSearchParams({ url: 'https://example.com', format: 'webp', full_page: 'true', }); const response = await fetch(`https://api.urlshot.io/v1/screenshot?${params.toString()}`, { headers: { Authorization: `Bearer ${process.env.URLSHOT_API_KEY}` }, }); if (!response.ok) { // Every non-2xx response is the JSON envelope, never image bytes. const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`); } const image = Buffer.from(await response.arrayBuffer());Python
import os, requests response = requests.get( "https://api.urlshot.io/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['URLSHOT_API_KEY']}"}, params={"url": "https://example.com", "format": "jpeg", "quality": 90}, timeout=60, ) if response.status_code != 200: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']} ({error['requestId']})") open("screenshot.jpg", "wb").write(response.content)PHP
<?php // Requires the PHP cURL extension. $params = http_build_query([ 'url' => 'https://example.com', 'viewport_width' => 1280, 'full_page' => 'true', ]); $request = curl_init('https://api.urlshot.io/v1/screenshot?' . $params); curl_setopt_array($request, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('URLSHOT_API_KEY')], CURLOPT_TIMEOUT => 60, ]); $body = curl_exec($request); if ($body === false) { throw new RuntimeException('Screenshot request failed: ' . curl_error($request)); } if (curl_getinfo($request, CURLINFO_RESPONSE_CODE) !== 200) { $error = json_decode($body, true, 512, JSON_THROW_ON_ERROR)['error']; throw new RuntimeException( $error['code'] . ': ' . $error['message'] . ' (request ' . $error['requestId'] . ')' ); } if (file_put_contents('screenshot.png', $body) === false) { throw new RuntimeException('Could not write screenshot.png'); }Ruby
require "json" require "net/http" uri = URI("https://api.urlshot.io/v1/screenshot") uri.query = URI.encode_www_form( url: "https://example.com", viewport_width: 1280, full_page: true, ) request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{ENV.fetch("URLSHOT_API_KEY")}" response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) do |http| http.request(request) end unless response.is_a?(Net::HTTPSuccess) # Every non-2xx response is the JSON envelope, never image bytes. error = JSON.parse(response.body).fetch("error") raise "#{error["code"]}: #{error["message"]} (request #{error["requestId"]})" end File.binwrite("screenshot.png", response.body)Java
// Java 11 or later. No dependencies. import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; import java.util.stream.Collectors; public class Screenshot { public static void main(String[] args) throws Exception { Map<String, String> params = new LinkedHashMap<>(); params.put("url", "https://example.com"); params.put("viewport_width", "1280"); params.put("full_page", "true"); String query = params.entrySet().stream() .map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) .collect(Collectors.joining("&")); HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.urlshot.io/v1/screenshot?" + query)) .header("Authorization", "Bearer " + System.getenv("URLSHOT_API_KEY")) .timeout(Duration.ofSeconds(60)) .build(); HttpResponse<byte[]> response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { // Every non-2xx response is the JSON envelope, never image bytes. Java has no JSON // parser built in, so this reports it whole: code, message and requestId are all in it. throw new IllegalStateException(new String(response.body(), StandardCharsets.UTF_8)); } Files.write(Path.of("screenshot.png"), response.body()); } }C# / .NET
// .NET 6 or later: a `dotnet new console` project. No packages. using System.Net.Http.Headers; using System.Text.Json; var query = await new FormUrlEncodedContent(new Dictionary<string, string> { ["url"] = "https://example.com", ["viewport_width"] = "1280", ["full_page"] = "true", }).ReadAsStringAsync(); using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", Environment.GetEnvironmentVariable("URLSHOT_API_KEY")); using var response = await client.GetAsync($"https://api.urlshot.io/v1/screenshot?{query}"); if (!response.IsSuccessStatusCode) { // Every non-2xx response is the JSON envelope, never image bytes. using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var error = body.RootElement.GetProperty("error"); throw new InvalidOperationException( $"{error.GetProperty("code")}: {error.GetProperty("message")} (request {error.GetProperty("requestId")})"); } await File.WriteAllBytesAsync("screenshot.png", await response.Content.ReadAsByteArrayAsync());Go
package main import ( "encoding/json" "io" "log" "net/http" "net/url" "os" "time" ) func main() { query := url.Values{} query.Set("url", "https://example.com") query.Set("viewport_width", "1280") query.Set("full_page", "true") request, err := http.NewRequest(http.MethodGet, "https://api.urlshot.io/v1/screenshot?"+query.Encode(), nil) if err != nil { log.Fatal(err) } request.Header.Set("Authorization", "Bearer "+os.Getenv("URLSHOT_API_KEY")) client := &http.Client{Timeout: 60 * time.Second} response, err := client.Do(request) if err != nil { log.Fatal(err) } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { log.Fatal(err) } if response.StatusCode != http.StatusOK { // Every non-2xx response is the JSON envelope, never image bytes. var envelope struct { Error struct { Code string `json:"code"` Message string `json:"message"` RequestID string `json:"requestId"` } `json:"error"` } if err := json.Unmarshal(body, &envelope); err != nil { log.Fatal(err) } log.Fatalf("%s: %s (request %s)", envelope.Error.Code, envelope.Error.Message, envelope.Error.RequestID) } if err := os.WriteFile("screenshot.png", body, 0o644); err != nil { log.Fatal(err) } }Want every step explained? Follow the Node.js walkthrough.
These examples use GET with query parameters. You can send the same options as JSON with POST, which is easier once you add custom CSS or JavaScript. See POST with a JSON body.
Get started
Authentication
#There are two ways to authenticate:
- From your server: send your secret key in the
Authorizationheader, asBearer sk_…. - In a web page: use a signed URL, which works without exposing your secret key.
About keys
- Each key belongs to one workspace and uses that workspace’s plan and credits.
- Keys can’t be recovered. If you lose one, create a new key.
- A revoked key returns
revoked_api_key. It can take up to a minute to stop working everywhere.
Make requests
Parameters
#Send parameters in the query string with GET, or as a JSON body with POST. Only url is required. Unknown parameters are rejected, so a typo fails instead of being ignored.
| Parameter | Default | Description |
|---|---|---|
| Target and timing | ||
urlrequiredstring | — | Absolute HTTP or HTTPS URL of the page to capture. |
wait_untilstring | load | Navigation lifecycle event to wait for before capturing.Accepts:loaddomcontentloadednetworkidle0networkidle2 |
delay_msinteger | 0 | Additional wait after the navigation condition is met. Also bounded by the plan.Accepts:0–10000 |
timeout_msinteger | 15000 | Maximum navigation time. Also bounded by the plan.Accepts:1000–30000 |
| Output | ||
formatstring | png | Output image format.Accepts:pngjpegwebp |
qualityinteger | — | Encoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png.Accepts:1–100 |
full_pageboolean | false | Capture the full scrollable page instead of only the viewport. |
viewport_widthinteger | 1280 | Viewport width in CSS pixels.Accepts:200–3840 |
viewport_heightinteger | 720 | Viewport height in CSS pixels. Ignored for the captured height when full_page is true.Accepts:200–4320 |
device_scale_factornumber | 1 | Device pixel ratio to emulate.Accepts:0.5–3 |
| Shaping the page | ||
dark_modeboolean | false | Emulate prefers-color-scheme: dark before navigation. |
hide_selectorsstring[] | — | CSS selector whose matching elements are hidden after navigation. Repeatable. |
block_cookie_bannersboolean | false | Hide known consent dialogs and restore page scrolling before capturing. Available on every plan. |
custom_cssstring | — | CSS injected into the page after it loads, as the last stylesheet. |
custom_jsstring | — | JavaScript evaluated in the page before the capture. Paid plans only. |
| Caching | ||
cache_ttlinteger | 0 | Seconds an identical request may be served from cache. 0 bypasses the cache.Accepts:0–86400 |
hide_selectors takes several values: repeat it in a query string, or send an array in JSON. Some limits also depend on your plan; see Plan limits.
Make requests
POST with a JSON body
#Send the same options as a JSON object in a POST to the same endpoint. Parameter names, limits, defaults, price and cache are all the same as for GET.
curl "https://api.urlshot.io/v1/screenshot" \
-H "Authorization: Bearer $URLSHOT_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
--output screenshot.png <<'JSON'
{
"url": "https://example.com",
"viewport_width": 1280,
"full_page": true,
"hide_selectors": [".newsletter", "#chat-widget"]
}
JSONWhen to use it
- Your request includes custom CSS or JavaScript. Code in JSON needs no URL encoding.
- Your values are long or use non-Latin characters. URLs longer than 64 KB are rejected before they reach the API.
- You want to keep values out of URLs, which servers and proxies often log.
Rules for the body
- Send
Content-Type: application/json. Anything else returns415. - Use JSON types:
true, not"true", and1280, not"1280".hide_selectorsis an array of strings. nullmeans “not set”. Unknown properties are rejected.- Put every option in the body. A POST that also has query parameters is rejected.
- The body can be up to 71,536 bytes, enough for every option at its limit. A larger body returns
413. - Authenticate with your secret key, as for
GET. Signed URLs work withGETonly.
Handle responses
Responses
#A successful request returns the image itself, as PNG, JPEG or WebP bytes to match format. There’s no JSON wrapper or base64, so you can write the body straight to a file.
Response headers
| Header | Sent on | Meaning |
|---|---|---|
X-Request-ID | Every response | Opaque identifier for this request. Quote it in any support conversation. |
X-Urlshot-Cache | Every response | HIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0. |
X-Urlshot-Credits-Used | Every response | Render credits consumed by this request. A cache hit consumes zero. |
X-RateLimit-Limit | Success only | Renders your plan may run at once. |
X-Urlshot-Renderer | Success only | The renderer build that produced the image, such as v4:4572cafff9ea; on a cache hit, the build that made the cached image. For support conversations, not for branching on. |
X-RateLimit-Remaining | Success only | Render slots still free after this request. Absent on a cache hit, where it is unknown rather than zero. |
Cache-Control | Success only | How long a browser may reuse the image: private, max-age=<seconds> when cache_ttl is set, and on a cache hit no longer than the cached copy has left; no-store when cache_ttl is 0. |
Handle responses
Errors
#When there’s no image to return, you get JSON in the same shape every time. Check error.code: it never changes. The message is written for people and may be reworded.
{
"error": {
"code": "monthly_limit_exceeded",
"message": "The workspace has used all credits for the current period.",
"requestId": "req_0123456789abcdef0123456789abcdef"
}
}Messages name the parameter at fault but never repeat a value you sent, so they’re safe to log and to show to your users.
| Status | Code | Retry | Meaning |
|---|---|---|---|
| 400 | invalid_request | — | The request could not be processed because one or more parameters are invalid. |
| 400 | script_failed | — | The supplied custom_js did not run to completion. |
| 400 | target_not_allowed | — | The requested target address is not allowed. |
| 401 | invalid_api_key | — | The provided API key is not valid. |
| 401 | invalid_signature | — | The request signature is missing or does not match. |
| 401 | revoked_api_key | — | The provided API key has been revoked. |
| 429 | concurrency_limit_exceeded | After Retry-After | The workspace is already running the maximum number of concurrent renders. |
| 429 | monthly_limit_exceeded | — | The workspace has used all credits for the current period. |
| 429 | rate_limit_exceeded | After Retry-After | The request rate limit for this API key has been exceeded. |
| 502 | navigation_failed | — | The target page could not be loaded. |
| 502 | render_failed | — | The screenshot could not be produced. |
| 503 | service_unavailable | After Retry-After | The screenshot service is temporarily unavailable. |
| 504 | render_timeout | — | The target page did not finish rendering before the timeout. |
Retrying
Retry only the codes marked above. They come with a Retry-After header, in seconds. The others fail the same way every time, however often you send them.
async function capture(params, attempt = 0) {
const response = await fetch(`https://api.urlshot.io/v1/screenshot?${params}`, { headers });
if (response.ok) return response.arrayBuffer();
const retryAfter = response.headers.get('Retry-After');
// Retry only when the API says to. A 400 or 401 will fail identically forever.
if (retryAfter === null || attempt >= 3) {
throw new Error((await response.json()).error.code);
}
await new Promise((r) => setTimeout(r, Number(retryAfter) * 1000));
return capture(params, attempt + 1);
}navigation_failed, render_timeout and render_failed usually mean the target page is slow or broken. Try a higher timeout_ms before you retry.
Costs and limits
Caching and credits
#Caching is off by default, so every request renders a fresh screenshot. Set cache_ttl to a number of seconds to reuse the screenshot for identical requests during that time.
- “Identical” means every option that changes the image, such as the URL, viewport and format.
cache_ttlitself doesn’t count. - Your cache belongs to your workspace and is never shared.
- The response’s
Cache-Controlheader lets the browser keep the image for the same time, so a page that embeds it doesn’t fetch it again on every view.
What a request costs
| Request | Credits |
|---|---|
Served from cache (X-Urlshot-Cache: HIT) | 0 |
Rendered, including a render that fails after the browser started, such as a timeout or script_failed | 1 |
| Rejected before rendering, such as an invalid parameter, a bad key or a limit reached | 0 |
Every response reports what it cost in X-Urlshot-Credits-Used.
Costs and limits
Plan limits
#Your plan sets your monthly credits, how many renders can run at once, the maximum timeout_ms, delay_ms and cache_ttl, and whether you can use custom_js.
| Plan | Credits / month | Renders at once | Max timeout | Max delay | Max cache | custom_js |
|---|---|---|---|---|---|---|
| Free | 100 | 1 | 15 s | 3 s | 1 hour | —No |
| Startup | 1,000 | 5 | 30 s | 10 s | 24 hours | Yes |
| Growth | 10,000 | 20 | 30 s | 10 s | 24 hours | Yes |
| Scale | 100,000 | 50 | 30 s | 10 s | 24 hours | Yes |
Renders at once
If all your render slots are busy, you get concurrency_limit_exceeded with a Retry-After header. Check X-RateLimit-Remaining to see how many slots are left, and pace your requests to stay under the limit.
There’s no per-second limit to plan around. Very large bursts can hit an abuse limit, which returns rate_limit_exceeded with a Retry-After header.
Features
Custom CSS and JavaScript
#Use custom_css and custom_js to change the page before the screenshot: hide an element, open a tab, or wait until your content is ready. Each can be up to 4,096 characters.
Send them in a JSON body, as below, so the code needs no URL encoding. For cookie dialogs, try block_cookie_banners first.
curl "https://api.urlshot.io/v1/screenshot" \
-H "Authorization: Bearer $URLSHOT_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
--output screenshot.png <<'JSON'
{
"url": "https://example.com",
"custom_css": ".cookie-consent, .newsletter { display: none !important }",
"custom_js": "document.querySelector('#tab-2')?.click()"
}
JSONHow they run
- The page loads and waits for
wait_untilanddelay_ms. - Your CSS is added, then your JavaScript runs. If the script returns a promise, the screenshot waits for it, up to
timeout_ms. hide_selectorsis applied, then the screenshot is taken.
Both work even on pages with a strict Content-Security-Policy.
If something fails
- A script that throws, or never finishes, fails the request with
script_failedand your own error message. No image is returned, and the render still costs one credit. - Invalid CSS rules are skipped, as in any stylesheet, and
@importis ignored.
Safety
- Your script can do what the page can do, and no more. It can’t reach private networks or cloud metadata addresses.
- We never store your CSS or JavaScript. Request logs only record whether you used them.
Features
Signed URLs
#A signed URL lets you put a screenshot straight into a web page, in an <img> tag, without exposing your secret key. Your server signs the URL. Anyone can load it, but no one can change it.
- Available on every plan, at the same price and with the same cache as other requests.
GETonly, because the signature covers the query string.
Your two values
When you create a publishable key, you get two values:
| Value | Where it goes | Good to know |
|---|---|---|
Publishable key pk_… | In the URL, as key | Safe to show. You can see it again in your dashboard. |
| Signing secret | Your server only | Shown once. A 64-character hex string: use it exactly as issued, don’t decode it. |
The two kinds of key can’t replace each other. A pk_… key is rejected as a bearer token, and an sk_… key is rejected in key.
How to sign a URL
- Build the query string you’ll send, including
key. - Compute HMAC-SHA-256 of that exact string, using your signing secret.
- Add the lowercase hex result as
signature.
payload = "key=pk_9f2c…&url=https%3A%2F%2Fexample.com"
signature = hex(HMAC_SHA256(signingSecret, payload))Node.js
import { createHmac } from 'node:crypto';
function signedUrl(options) {
// The key goes in the query alongside the options. Order is yours -- sign the string you are
// about to send, and send exactly what you signed.
const query = new URLSearchParams({ key: process.env.URLSHOT_KEY, ...options }).toString();
const signature = createHmac('sha256', process.env.URLSHOT_SIGNING_SECRET)
.update(query)
.digest('hex');
return `https://api.urlshot.io/v1/screenshot?${query}&signature=${signature}`;
}
// Safe to put in a page: altering any parameter invalidates the signature.
const src = signedUrl({ url: 'https://example.com', viewport_width: '1200' });Python
import hmac, os
from hashlib import sha256
from urllib.parse import urlencode
def signed_url(**options: str) -> str:
query = urlencode({"key": os.environ["URLSHOT_KEY"], **options})
signature = hmac.new(
os.environ["URLSHOT_SIGNING_SECRET"].encode(),
query.encode(),
sha256,
).hexdigest()
return f"https://api.urlshot.io/v1/screenshot?{query}&signature={signature}"
src = signed_url(url="https://example.com", viewport_width="1200")PHP
<?php
function signedUrl(array $options): string
{
$query = http_build_query(['key' => getenv('URLSHOT_KEY')] + $options);
$signature = hash_hmac('sha256', $query, getenv('URLSHOT_SIGNING_SECRET'));
return 'https://api.urlshot.io/v1/screenshot?' . $query . '&signature=' . $signature;
}
$src = signedUrl(['url' => 'https://example.com', 'viewport_width' => '1200']);Ruby
require "openssl"
require "uri"
def signed_url(**options)
query = URI.encode_www_form({ key: ENV.fetch("URLSHOT_KEY") }.merge(options))
signature = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("URLSHOT_SIGNING_SECRET"), query)
"https://api.urlshot.io/v1/screenshot?#{query}&signature=#{signature}"
end
src = signed_url(url: "https://example.com", viewport_width: "1200")Java
// Java 17 or later, for HexFormat. No dependencies.
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class SignedUrl {
static String signedUrl(Map<String, String> options) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("key", System.getenv("URLSHOT_KEY"));
params.putAll(options);
String query = params.entrySet().stream()
.map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
System.getenv("URLSHOT_SIGNING_SECRET").getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(mac.doFinal(query.getBytes(StandardCharsets.UTF_8)));
return "https://api.urlshot.io/v1/screenshot?" + query + "&signature=" + signature;
}
public static void main(String[] args) throws Exception {
Map<String, String> options = new LinkedHashMap<>();
options.put("url", "https://example.com");
options.put("viewport_width", "1200");
System.out.println(signedUrl(options));
}
}C# / .NET
// .NET 6 or later: a `dotnet new console` project. No packages.
using System.Security.Cryptography;
using System.Text;
static string SignedUrl(IDictionary<string, string> options)
{
var parameters = new Dictionary<string, string>
{
["key"] = Environment.GetEnvironmentVariable("URLSHOT_KEY")!,
};
foreach (var (name, value) in options) parameters[name] = value;
var query = string.Join("&", parameters.Select(p =>
$"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("URLSHOT_SIGNING_SECRET")!);
var hash = HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(query));
// Lowercase: the signature is compared exactly, and Convert.ToHexString returns uppercase.
var signature = Convert.ToHexString(hash).ToLowerInvariant();
return $"https://api.urlshot.io/v1/screenshot?{query}&signature={signature}";
}
var src = SignedUrl(new Dictionary<string, string>
{
["url"] = "https://example.com",
["viewport_width"] = "1200",
});Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"os"
)
func signedURL(options map[string]string) string {
query := url.Values{}
query.Set("key", os.Getenv("URLSHOT_KEY"))
for name, value := range options {
query.Set(name, value)
}
// Encode sorts by key. That is fine: what matters is signing exactly the string you send.
encoded := query.Encode()
mac := hmac.New(sha256.New, []byte(os.Getenv("URLSHOT_SIGNING_SECRET")))
mac.Write([]byte(encoded))
signature := hex.EncodeToString(mac.Sum(nil))
return "https://api.urlshot.io/v1/screenshot?" + encoded + "&signature=" + signature
}
func main() {
fmt.Println(signedURL(map[string]string{"url": "https://example.com", "viewport_width": "1200"}))
}Use it in a page
<img
src="https://api.urlshot.io/v1/screenshot?key=pk_9f2c…&url=https%3A%2F%2Fexample.com&signature=9f2c4d…"
alt="Screenshot of example.com"
width="1200"
height="800"
/>A changed or unsigned URL returns invalid_signature. Sign URLs on your server and reuse them; the signing secret must never reach the browser.
Include cache_ttl in a URL you embed. Without it, every page view renders a new screenshot and uses a credit. With it, repeat views are served from your cache or the visitor’s browser for free.
Reference
Rendering environment
#Screenshots are taken with headless Chromium on machines without a GPU. Two things can make a screenshot look different from your browser, without any error:
No WebGL or GPU canvas
Pages that need them show their fallback or an empty area. Charts and maps that fall back to 2D canvas or SVG work fine.
A fixed set of fonts
Liberation (matches Arial, Times New Roman and Courier New), Noto Sans, Noto CJK and Noto Color Emoji. Other fonts are replaced, so load web fonts to control typography.
- Every render is isolated. Each one starts with a fresh browser: no cookies, storage or cache carry over.
- Public pages only. The URL must be a public
httporhttpsaddress. Private and local addresses returntarget_not_allowed, so pages behind a VPN can’t be captured.
Reference
OpenAPI
#The full specification is available as OpenAPI 3.1 at /openapi.json. It’s generated from the same rules the API enforces, so you can point a client generator at it.
curl -s https://urlshot.io/openapi.json -o urlshot-v1.json