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.availableoroutput.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:
- Take every query parameter except
signature, includingkey_idandexpires. - 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. - Join each pair as
key=value, sort the pairs as plain byte strings, and join them with&. - 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 |
Related
- POST /v1/screenshot and GET /v1/screenshot: every parameter.
- Response headers: the full header list.
- Test code that calls the API: stub this traffic in your test suite.
- Verify webhook signatures: the other HMAC in the API.
To try a request now, create a free API key and paste the curl command above into a terminal.