# 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 2026-11-08 · https://shareglint.com/en/screenshot-api/java

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

```java
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](https://shareglint.com/en/guides/bulk-website-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](https://shareglint.com/en/guides/selenium-screenshot) covers its full-page quirks) or [Playwright for Java](https://playwright.dev/java/docs/screenshots), 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`](https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpClient.html) 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.
