Screenshot API documentation API reference.

One endpoint. Send a URL, get the screenshot back as an image.

On this page

Get started

Quickstart

#
  1. Create an API key

    Create one on the API keys page of your dashboard.

  2. Send your first request

    cURL
    curl -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.png

    This saves the page as screenshot.png. Every option except url is optional.

  3. Use your language

    Quickstart code examples

    Node.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 Authorization header, as Bearer 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.

Screenshot parameters, grouped by purpose, with their defaults and accepted values.
ParameterDefaultDescription
Target and timing
urlrequiredstring—Absolute HTTP or HTTPS URL of the page to capture.
wait_untilstringloadNavigation lifecycle event to wait for before capturing.Accepts:loaddomcontentloadednetworkidle0networkidle2
delay_msinteger0Additional wait after the navigation condition is met. Also bounded by the plan.Accepts:0–10000
timeout_msinteger15000Maximum navigation time. Also bounded by the plan.Accepts:1000–30000
Output
formatstringpngOutput 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_pagebooleanfalseCapture the full scrollable page instead of only the viewport.
viewport_widthinteger1280Viewport width in CSS pixels.Accepts:200–3840
viewport_heightinteger720Viewport height in CSS pixels. Ignored for the captured height when full_page is true.Accepts:200–4320
device_scale_factornumber1Device pixel ratio to emulate.Accepts:0.5–3
Shaping the page
dark_modebooleanfalseEmulate prefers-color-scheme: dark before navigation.
hide_selectorsstring[]—CSS selector whose matching elements are hidden after navigation. Repeatable.
block_cookie_bannersbooleanfalseHide 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_ttlinteger0Seconds 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
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"]
}
JSON

When 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 returns 415.
  • Use JSON types: true, not "true", and 1280, not "1280". hide_selectors is an array of strings.
  • null means “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 with GET only.

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

Response headers and when each is sent.
HeaderSent onMeaning
X-Request-IDEvery responseOpaque identifier for this request. Quote it in any support conversation.
X-Urlshot-CacheEvery responseHIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0.
X-Urlshot-Credits-UsedEvery responseRender credits consumed by this request. A cache hit consumes zero.
X-RateLimit-LimitSuccess onlyRenders your plan may run at once.
X-Urlshot-RendererSuccess onlyThe 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-RemainingSuccess onlyRender slots still free after this request. Absent on a cache hit, where it is unknown rather than zero.
Cache-ControlSuccess onlyHow 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.

JSON
{
  "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.

Error codes with their HTTP status and whether to retry.
StatusCodeRetryMeaning
400invalid_request—The request could not be processed because one or more parameters are invalid.
400script_failed—The supplied custom_js did not run to completion.
400target_not_allowed—The requested target address is not allowed.
401invalid_api_key—The provided API key is not valid.
401invalid_signature—The request signature is missing or does not match.
401revoked_api_key—The provided API key has been revoked.
429concurrency_limit_exceededAfter Retry-AfterThe workspace is already running the maximum number of concurrent renders.
429monthly_limit_exceeded—The workspace has used all credits for the current period.
429rate_limit_exceededAfter Retry-AfterThe request rate limit for this API key has been exceeded.
502navigation_failed—The target page could not be loaded.
502render_failed—The screenshot could not be produced.
503service_unavailableAfter Retry-AfterThe screenshot service is temporarily unavailable.
504render_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.

JavaScript
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_ttl itself doesn’t count.
  • Your cache belongs to your workspace and is never shared.
  • The response’s Cache-Control header 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

Credits charged for each kind of request.
RequestCredits
Served from cache (X-Urlshot-Cache: HIT)0
Rendered, including a render that fails after the browser started, such as a timeout or script_failed1
Rejected before rendering, such as an invalid parameter, a bad key or a limit reached0

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.

Limits for each plan.
PlanCredits / monthRenders at onceMax timeoutMax delayMax cachecustom_js
Free100115 s3 s1 hour—No
Startup1,000530 s10 s24 hoursYes
Growth10,0002030 s10 s24 hoursYes
Scale100,0005030 s10 s24 hoursYes

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
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()"
}
JSON

How they run

  1. The page loads and waits for wait_until and delay_ms.
  2. Your CSS is added, then your JavaScript runs. If the script returns a promise, the screenshot waits for it, up to timeout_ms.
  3. hide_selectors is 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_failed and 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 @import is 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.
  • GET only, because the signature covers the query string.

Your two values

When you create a publishable key, you get two values:

The publishable key and the signing secret compared.
ValueWhere it goesGood to know
Publishable key pk_…In the URL, as keySafe to show. You can see it again in your dashboard.
Signing secretYour server onlyShown 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

  1. Build the query string you’ll send, including key.
  2. Compute HMAC-SHA-256 of that exact string, using your signing secret.
  3. Add the lowercase hex result as signature.
Pseudocode
payload   = "key=pk_9f2c…&url=https%3A%2F%2Fexample.com"
signature = hex(HMAC_SHA256(signingSecret, payload))

Signed URL code examples

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

HTML
<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 http or https address. Private and local addresses return target_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
curl -s https://urlshot.io/openapi.json -o urlshot-v1.json

Ready for your first request?

Create a key in the dashboard and run the quickstart. No card needed.