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; pollGET /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.