Use the API without an SDK

RenderScreenshot is an HTTPS API. One POST with a Bearer token returns an image, and one HMAC lets you publish an image URL without your API key. You do not need an SDK for either. This guide is part of the guides hub and covers the exact wire format, so you can call the API from a shell script, a language without an official SDK, or a runtime where you do not want another dependency.

Use raw HTTP when:

  • your language has no official SDK (Elixir, Rust, Swift, Kotlin scripts, Deno, a CI shell step);
  • you need a parameter that your SDK version does not expose, such as cache.available or output.resize;
  • you want to see exactly what goes over the wire while you debug.

Every example on this page was run on 2026-10-15 against a local stub that returns the documented response shapes (Node 22.18, Python 3.14, Ruby 4.0, PHP 8.5, Go 1.27). The signing code was checked byte for byte against the server's own verification logic. The Java listings were not run.

The two requests

GET: query parameters

A GET request takes flat query parameters. It is the shortest way to test a capture:

curl -sS "https://api.renderscreenshot.com/v1/screenshot?url=https://example.com&preset=og_card&format=webp" \
  -H "Authorization: Bearer $RS_API_KEY" \
  -o example.webp -D headers.txt

GET supports a fixed list of flat parameters: url, html, preset, width, height, device, scale, full_page, selector, format, quality, delay, timeout, block_ads, block_cookies, dark_mode, cache and cache_ttl. Everything else needs POST.

POST: a nested JSON body

POST takes the full parameter tree. Options are grouped in objects such as viewport, output, wait and block:

curl -sS https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "preset": "og_card",
    "output": { "format": "webp" },
    "wait": { "until": "networkidle", "timeout": 45 }
  }' \
  -o example.webp

The nesting matters. A flat field such as "format": "webp" at the top of a POST body is not read; it must be "output": { "format": "webp" }. If an option seems to have no effect, check the shape first. The POST endpoint reference lists every group.

What comes back

By default the body is the image and the metadata is in the headers:

Header Meaning
Content-Type image/png, image/jpeg, image/webp or application/pdf
X-Cache-URL Public CDN URL of the same file. No key needed
X-Cache-Hit true when the result came from cache
X-Credits-Used Credits this request used
X-RateLimit-Remaining Requests left in the current one-minute window

Send Accept: application/json to get a JSON body with image.url, image.width, image.height and cache details instead of the bytes. That is useful when you only need the CDN link.

The same POST in six languages

Each listing sends the POST above, saves the file, prints the CDN URL, and exits with a non-zero status on an error response. None of them need a package install.

Node.js

// post.mjs - Node 20+ (built-in fetch), no dependencies
import { writeFile } from 'node:fs/promises';

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://example.com',
    preset: 'og_card',
    output: { format: 'webp' },
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  console.error(`${res.status} ${error.code}: ${error.message}`);
  process.exit(1);
}

await writeFile('example.webp', Buffer.from(await res.arrayBuffer()));
console.log('CDN copy:', res.headers.get('x-cache-url'));

Python

# post.py - Python 3.9+, standard library only
import json
import os
import sys
import urllib.error
import urllib.request

req = urllib.request.Request(
    "https://api.renderscreenshot.com/v1/screenshot",
    data=json.dumps({
        "url": "https://example.com",
        "preset": "og_card",
        "output": {"format": "webp"},
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['RS_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)

try:
    with urllib.request.urlopen(req, timeout=60) as res:
        with open("example.webp", "wb") as f:
            f.write(res.read())
        print("CDN copy:", res.headers.get("X-Cache-URL"))
except urllib.error.HTTPError as e:
    err = json.load(e)["error"]
    sys.exit(f"{e.code} {err['code']}: {err['message']}")

Ruby

# post.rb - Ruby 3.0+, standard library only
require 'json'
require 'net/http'

uri = URI('https://api.renderscreenshot.com/v1/screenshot')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('RS_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = { url: 'https://example.com', preset: 'og_card', output: { format: 'webp' } }.to_json

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 60) do |http|
  http.request(req)
end

unless res.is_a?(Net::HTTPSuccess)
  error = JSON.parse(res.body)['error']
  abort "#{res.code} #{error['code']}: #{error['message']}"
end

File.binwrite('example.webp', res.body)
puts "CDN copy: #{res['X-Cache-URL']}"

PHP

<?php
// post.php - PHP 8.0+ with the curl extension
$ch = curl_init('https://api.renderscreenshot.com/v1/screenshot');
$headers = [];
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 60,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('RS_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://example.com',
        'preset' => 'og_card',
        'output' => ['format' => 'webp'],
    ]),
    CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
        $parts = explode(':', $line, 2);
        if (count($parts) === 2) {
            $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
        }
        return strlen($line);
    },
]);

$body = curl_exec($ch);
if ($body === false) {
    fwrite(STDERR, curl_error($ch) . PHP_EOL);
    exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    $error = json_decode($body, true)['error'];
    fwrite(STDERR, "{$status} {$error['code']}: {$error['message']}" . PHP_EOL);
    exit(1);
}

file_put_contents('example.webp', $body);
echo 'CDN copy: ' . ($headers['x-cache-url'] ?? '') . PHP_EOL;

Go

// main.go - Go 1.21+, standard library only
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "time"
)

func main() {
    payload, _ := json.Marshal(map[string]any{
        "url":    "https://example.com",
        "preset": "og_card",
        "output": map[string]any{"format": "webp"},
    })

    req, err := http.NewRequest("POST", "https://api.renderscreenshot.com/v1/screenshot", bytes.NewReader(payload))
    if err != nil {
        log.Fatal(err)
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    res, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()
    body, err := io.ReadAll(res.Body)
    if err != nil {
        log.Fatal(err)
    }

    if res.StatusCode >= 400 {
        var e struct {
            Error struct{ Code, Message string } `json:"error"`
        }
        json.Unmarshal(body, &e)
        log.Fatalf("%d %s: %s", res.StatusCode, e.Error.Code, e.Error.Message)
    }

    if err := os.WriteFile("example.webp", body, 0o644); err != nil {
        log.Fatal(err)
    }
    fmt.Println("CDN copy:", res.Header.Get("X-Cache-URL"))
}

Java

// Post.java - Java 11+, java.net.http only (not run for this guide)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

public class Post {
    public static void main(String[] args) throws Exception {
        String body = "{\"url\":\"https://example.com\",\"preset\":\"og_card\",\"output\":{\"format\":\"webp\"}}";

        HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.renderscreenshot.com/v1/screenshot"))
            .timeout(Duration.ofSeconds(60))
            .header("Authorization", "Bearer " + System.getenv("RS_API_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<byte[]> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofByteArray());

        if (res.statusCode() >= 400) {
            System.err.println(res.statusCode() + " " + new String(res.body()));
            System.exit(1);
        }

        Files.write(Path.of("example.webp"), res.body());
        System.out.println("CDN copy: " + res.headers().firstValue("X-Cache-URL").orElse(""));
    }
}

Signed URLs without an SDK

A signed URL lets a browser, an og:image tag or a no-code tool load a screenshot without your API key. You sign it on your server with the secret key (rs_secret_...), and the URL carries only the public key ID (rs_pub_...), an expiry and the signature. Background on the method is in signed URLs.

The server verifies a signature in four steps. Your code must build the same string:

  1. Take every query parameter except signature, including key_id and expires.
  2. Form-encode each key and value: keep A-Z a-z 0-9 - . _ ~, write a space as +, and percent-encode every other byte in UTF-8 with uppercase hex.
  3. Join each pair as key=value, sort the pairs as plain byte strings, and join them with &.
  4. Compute HMAC-SHA256 of that string with the secret key and write it as lowercase hex.

Then send the sorted string as the query string and append &signature=.... Because the URL you send is the canonical string, nothing can reorder or re-encode it on the way.

expires is a Unix timestamp in seconds. It is required, and the maximum lifetime is 30 days.

In the tests for this guide, two common mistakes both failed verification: signing the raw URL without encoding, and keeping the parameters in the order you wrote them.

Node.js

// sign.mjs - Node 20+, no dependencies
import { createHmac } from 'node:crypto';

// Form-encode like the server: keep A-Z a-z 0-9 - . _ ~, space becomes +
const enc = (s) =>
  encodeURIComponent(String(s))
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');

export function signedScreenshotUrl(params, { keyId, secretKey, ttlSeconds }) {
  const all = { ...params, key_id: keyId, expires: Math.floor(Date.now() / 1000) + ttlSeconds };
  const canonical = Object.entries(all)
    .map(([k, v]) => `${enc(k)}=${enc(v)}`)
    .sort()
    .join('&');
  const signature = createHmac('sha256', secretKey).update(canonical).digest('hex');
  return `https://api.renderscreenshot.com/v1/screenshot?${canonical}&signature=${signature}`;
}

console.log(
  signedScreenshotUrl(
    { url: 'https://example.com/pricing?plan=pro', preset: 'og_card', format: 'webp' },
    { keyId: process.env.RS_KEY_ID, secretKey: process.env.RS_SECRET_KEY, ttlSeconds: 7 * 24 * 3600 },
  ),
);

Python

# sign.py - Python 3.9+, standard library only
import hashlib
import hmac
import os
import time
from urllib.parse import quote_plus


def signed_screenshot_url(params, key_id, secret_key, ttl_seconds):
    all_params = {**params, "key_id": key_id, "expires": int(time.time()) + ttl_seconds}
    # quote_plus keeps A-Z a-z 0-9 - . _ ~ and turns spaces into +
    canonical = "&".join(sorted(
        f"{quote_plus(str(k))}={quote_plus(str(v))}" for k, v in all_params.items()
    ))
    signature = hmac.new(secret_key.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return f"https://api.renderscreenshot.com/v1/screenshot?{canonical}&signature={signature}"


print(signed_screenshot_url(
    {"url": "https://example.com/pricing?plan=pro", "preset": "og_card", "format": "webp"},
    os.environ["RS_KEY_ID"], os.environ["RS_SECRET_KEY"], 7 * 24 * 3600,
))

Ruby

# sign.rb - Ruby 3.0+, standard library only
require 'cgi'
require 'openssl'

def signed_screenshot_url(params, key_id:, secret_key:, ttl_seconds:)
  all = params.merge(key_id: key_id, expires: Time.now.to_i + ttl_seconds)
  canonical = all.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.sort.join('&')
  signature = OpenSSL::HMAC.hexdigest('SHA256', secret_key, canonical)
  "https://api.renderscreenshot.com/v1/screenshot?#{canonical}&signature=#{signature}"
end

puts signed_screenshot_url(
  { url: 'https://example.com/pricing?plan=pro', preset: 'og_card', format: 'webp' },
  key_id: ENV.fetch('RS_KEY_ID'), secret_key: ENV.fetch('RS_SECRET_KEY'), ttl_seconds: 7 * 24 * 3600
)

PHP

<?php
// sign.php - PHP 8.0+, no dependencies

function signedScreenshotUrl(array $params, string $keyId, string $secretKey, int $ttlSeconds): string
{
    $all = $params + ['key_id' => $keyId, 'expires' => time() + $ttlSeconds];
    $pairs = [];
    foreach ($all as $k => $v) {
        // rawurlencode keeps A-Z a-z 0-9 - . _ ~; turn %20 into + to match form encoding
        $pairs[] = str_replace('%20', '+', rawurlencode((string) $k)) . '='
                 . str_replace('%20', '+', rawurlencode((string) $v));
    }
    sort($pairs, SORT_STRING);
    $canonical = implode('&', $pairs);
    $signature = hash_hmac('sha256', $canonical, $secretKey);
    return "https://api.renderscreenshot.com/v1/screenshot?{$canonical}&signature={$signature}";
}

echo signedScreenshotUrl(
    ['url' => 'https://example.com/pricing?plan=pro', 'preset' => 'og_card', 'format' => 'webp'],
    getenv('RS_KEY_ID'), getenv('RS_SECRET_KEY'), 7 * 24 * 3600
), PHP_EOL;

Go

// main.go - Go 1.21+, standard library only
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/url"
    "os"
    "sort"
    "strconv"
    "strings"
    "time"
)

func signedScreenshotURL(params map[string]string, keyID, secretKey string, ttl time.Duration) string {
    all := map[string]string{"key_id": keyID, "expires": strconv.FormatInt(time.Now().Add(ttl).Unix(), 10)}
    for k, v := range params {
        all[k] = v
    }
    pairs := make([]string, 0, len(all))
    for k, v := range all {
        // QueryEscape keeps A-Z a-z 0-9 - . _ ~ and turns spaces into +
        pairs = append(pairs, url.QueryEscape(k)+"="+url.QueryEscape(v))
    }
    sort.Strings(pairs)
    canonical := strings.Join(pairs, "&")
    mac := hmac.New(sha256.New, []byte(secretKey))
    mac.Write([]byte(canonical))
    return "https://api.renderscreenshot.com/v1/screenshot?" + canonical + "&signature=" + hex.EncodeToString(mac.Sum(nil))
}

func main() {
    fmt.Println(signedScreenshotURL(
        map[string]string{"url": "https://example.com/pricing?plan=pro", "preset": "og_card", "format": "webp"},
        os.Getenv("RS_KEY_ID"), os.Getenv("RS_SECRET_KEY"), 7*24*time.Hour,
    ))
}

Java

// Sign.java - Java 11+, standard library only (not run for this guide)
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HexFormat; // Java 17+; on Java 11 format the bytes yourself
import java.util.List;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Sign {
    // URLEncoder keeps * and encodes ~; adjust both to match the server
    static String enc(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("*", "%2A").replace("%7E", "~");
    }

    static String signedScreenshotUrl(Map<String, String> params, String keyId, String secretKey, long ttlSeconds)
            throws Exception {
        Map<String, String> all = new HashMap<>(params);
        all.put("key_id", keyId);
        all.put("expires", String.valueOf(Instant.now().getEpochSecond() + ttlSeconds));

        List<String> pairs = new ArrayList<>();
        all.forEach((k, v) -> pairs.add(enc(k) + "=" + enc(v)));
        Collections.sort(pairs);
        String canonical = String.join("&", pairs);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
        return "https://api.renderscreenshot.com/v1/screenshot?" + canonical + "&signature=" + signature;
    }

    public static void main(String[] args) throws Exception {
        System.out.println(signedScreenshotUrl(
            Map.of("url", "https://example.com/pricing?plan=pro", "preset", "og_card", "format", "webp"),
            System.getenv("RS_KEY_ID"), System.getenv("RS_SECRET_KEY"), 7 * 24 * 3600));
    }
}

Shell

#!/usr/bin/env bash
# sign.sh - bash, openssl and python3 (used only for URL encoding)
set -euo pipefail
enc() { python3 -c 'import sys, urllib.parse; print(urllib.parse.quote_plus(sys.argv[1]))' "$1"; }

expires=$(( $(date +%s) + 7*24*3600 ))
canonical=$(printf '%s\n' \
  "expires=$expires" \
  "format=webp" \
  "key_id=$(enc "$RS_KEY_ID")" \
  "preset=og_card" \
  "url=$(enc 'https://example.com/pricing?plan=pro')" | LC_ALL=C sort | paste -sd'&' -)
signature=$(printf '%s' "$canonical" | openssl dgst -sha256 -hmac "$RS_SECRET_KEY" -r | cut -d' ' -f1)
echo "https://api.renderscreenshot.com/v1/screenshot?${canonical}&signature=${signature}"

With the same keys, parameters and expiry, all of these produce the same URL. A good unit test for your own port is to fix expires to a constant and compare your output with one of the listings above.

A signed URL uses the GET parameter list, so it can only carry the flat parameters from the GET table. For options that need POST, render on your server and publish the X-Cache-URL instead.

Limits to design around

Limit Value
Page wait (wait.timeout) Default 30 s, maximum 60 s
Extra delay (wait.delay) Capped at 10,000 ms
Viewport Width 1 to 7680, height 1 to 4320, scale 1 to 3
Full-page height (capture.scroll.max_height) Default 16,000 px
Cache TTL 3,600 to 2,592,000 s (1 hour to 30 days), default 24 hours
Signed URL lifetime Maximum 30 days
Requests per minute and concurrency Set by your plan; see rate limits

Set your HTTP client timeout to at least 60 seconds. A shorter timeout cancels slow but valid renders on your side.

Troubleshooting

Symptom Likely cause Fix
401 missing_credentials No Authorization header, or the header has no Bearer prefix Send Authorization: Bearer rs_live_...
401 invalid_signature The signed string differs from the server's canonical string Encode, then sort, then sign; send the canonical string unchanged
401 expired_signature expires is in the past Use a Unix timestamp in seconds, in the future
An option has no effect in POST The field is flat instead of nested Move it into its group, for example output.format
An option has no effect in GET The parameter is not in the GET list Use POST
The saved file does not open You saved a JSON error body as an image Check the status code before you write the file
Client timeout before a response Client timeout under 60 s Raise it, or lower wait.timeout
429 too_many_requests Per-minute limit reached Wait for Retry-After; see error handling and retries

To try a request now, create a free API key and paste the curl command above into a terminal.

Related in this topic

Was this page helpful?