Menu
Sign up

Screenshot API in Java: HttpClient, full page, signed URLs and webhooks

Website screenshots from Java 17+ with java.net.http and no browser: full page, devices, a signed URL for img tags and webhook checks, compiled and tested.

Published November 8, 2026 · 5 min read

Java services that need screenshots of web pages — reports, archiving, link previews, monitoring — usually reach for Selenium or Playwright for Java, and with them a browser to install and keep alive on the server. This page shows the other route: one request per screenshot with the JDK's own java.net.http.HttpClient, a signed URL for <img> tags, and webhook verification, with no libraries at all. The class below was compiled and run with Java 25.0.5 (Temurin) against our API on 2026-11-08: the screenshot completed, the signed URL answered 302, and the webhook check accepted a correct signature and rejected a changed body. It needs Java 17 or newer (HexFormat).

The code

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.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Screenshots {
    static final String BASE = "https://shareglint.com";
    static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();

    /** One screenshot, waiting for the finished image; returns the JSON response. */
    static String take(String json) throws Exception {
        HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/v1/screenshots?wait=true"))
            .header("Authorization", "Bearer " + System.getenv("SHOT_API_KEY"))
            .header("Content-Type", "application/json")
            .timeout(Duration.ofSeconds(60))
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();
        HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 300) throw new IllegalStateException("HTTP " + res.statusCode() + ": " + res.body());
        return res.body();
    }

    /** A signed /s/ URL: HMAC over the path and the sorted, RFC 3986-encoded parameters. */
    static String signedUrl(String accountId, String secret, String ext, Map<String, String> params) throws Exception {
        String query = new TreeMap<>(params).entrySet().stream()
            .map(e -> encode(e.getKey()) + "=" + encode(e.getValue()))
            .collect(Collectors.joining("&"));
        String path = "/s/" + accountId + "." + ext;
        String s = hmacHex(secret, path + "?" + query).substring(0, 32);
        return BASE + path + "?" + query + "&s=" + s;
    }

    /** URLEncoder writes a space as "+"; the signature needs %20. */
    static String encode(String v) {
        return URLEncoder.encode(v, StandardCharsets.UTF_8).replace("+", "%20");
    }

    static String hmacHex(String secret, String data) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
    }

    /** X-Webhook-Signature = hex HMAC-SHA256 of timestamp + "." + raw body. */
    static boolean verifyWebhook(String rawBody, String timestamp, String signature, String secret) throws Exception {
        if (timestamp == null || signature == null) return false;
        if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300) return false;
        byte[] expected = hmacHex(secret, timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8);
        return MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.UTF_8));
    }

    public static void main(String[] args) throws Exception {
        String json = take("{\"url\":\"https://example.com/\",\"full_page\":true,\"block_cookie_banners\":true}");
        var m = Pattern.compile("\"url\":\"([^\"]+)\"").matcher(json);
        System.out.println(m.find() ? m.group(1).replace("\\/", "/") : json);

        String signed = signedUrl(System.getenv("SHOT_ACCOUNT_ID"), System.getenv("SHOT_SIGNING_SECRET"), "png",
            Map.of("url", "https://example.com/", "device", "mobile", "block_cookie_banners", "true"));
        HttpResponse<Void> res = HTTP.send(HttpRequest.newBuilder(URI.create(signed)).build(), HttpResponse.BodyHandlers.discarding());
        System.out.println("signed URL: " + res.statusCode());
    }
}

The JSON response is read with a regular expression only to keep the example free of dependencies; in a real project, map it with Jackson or Gson. Set SHOT_API_KEY, and for signed URLs SHOT_ACCOUNT_ID and SHOT_SIGNING_SECRET, in the environment.

One screenshot

take posts the options as JSON with ?wait=true and returns the response, whose url is the finished image. Useful options:

Option Example Effect
full_page true The whole scrollable page (2 renders)
lazy_load true Scrolls first so lazy images load
device "iphone-15", "pixel-8" Phone or tablet viewport, pixel ratio and user agent
selector "#pricing" Only that element
wait_for ".chart.ready" Waits for an element before the capture
block_cookie_banners true Accepts or hides consent dialogs
format "jpeg", "webp", "pdf" Output type; PNG by default

Set the request timeout longer than the capture (the example uses 60 s): full pages of heavy sites can take tens of seconds.

A signed URL for templates

A Thymeleaf or JSP page that shows a screenshot as an <img> should not call the API on every view. signedUrl builds a /s/ link that anyone can open and nobody can change. The Java-specific trap is URLEncoder, which encodes HTML form data and writes a space as +; the signature is computed over RFC 3986 encoding, so encode replaces + with %20. A TreeMap gives the sorted order the signature needs. The first request captures and charges; repeats within 24 hours redirect to the cached file. Java's HttpClient does not follow redirects by default, which is why our run printed the 302 itself.

Queue and webhook

For many pages, send requests without ?wait=true and with a webhook_url; the API answers at once with an id, and we call your endpoint with screenshot.completed or screenshot.failed. In Spring, take the body as a raw String (@RequestBody String body) and the two headers with @RequestHeader, call verifyWebhook, and only then parse the JSON — re-serializing a parsed object changes the bytes and breaks the signature. MessageDigest.isEqual compares in constant time. For lists of URLs, see bulk screenshots.

Errors

  • 402 quota_exceeded — no renders left; nothing was charged.
  • 422 — an invalid option or a non-public URL; do not retry.
  • 429 — rate limited; wait for Retry-After.
  • 500 with ?wait=true — the page failed to load; the error says why, and the render is given back.
  • 503 sync_overloaded — still running; poll GET /v1/screenshots/{id}.

Send an Idempotency-Key header when you retry a POST, so a second attempt returns the first result instead of a second capture.

Or run a browser from Java

If the pages are behind your login or on a private network, drive a browser yourself: Selenium (our Selenium screenshot guide covers its full-page quirks) or Playwright for Java, which has setFullPage(true) built in. You then own the browser: installation, fonts, memory, crashes. For public pages, the HTTP call above is less to run. The JDK's HttpClient documentation covers timeouts and redirects.

FAQ

How do I take a website screenshot in Java?

Either drive a browser with Selenium or Playwright for Java, or send one HTTP request to a screenshot API with java.net.http.HttpClient: POST the page URL and options as JSON to /v1/screenshots?wait=true and read the image URL from the response. The second needs no browser on your server.

How do I sign a screenshot URL in Java?

Sort the parameters (a TreeMap does it), encode each name and value with URLEncoder and replace + with %20, join them after the path with ?, and take the first 32 hex characters of HmacSHA256 with your signing secret as s. Our Java method returned a 302 from the server.

How do I verify the webhook signature in Java?

Compute hex HmacSHA256 over the X-Webhook-Timestamp header, a dot and the raw request body, with your webhook secret, and compare it to X-Webhook-Signature with MessageDigest.isEqual, which takes constant time. Reject timestamps older than five minutes.

What does a screenshot cost?

A viewport or element screenshot uses 1 render of the monthly quota, a full page 2. The same URL with the same options within 24 hours returns the existing file for free.