Screenshot API Java SDK Guide: OkHttp, HttpClient, and Spring Boot

2026-05-21 | Tags: [java, sdk, screenshot-api, okhttp, spring-boot, completablefuture]

Java's HTTP ecosystem has matured significantly since Java 11. This guide covers three integration paths for the HermesForge Screenshot API: OkHttp for fine-grained control, the Java 11 built-in HttpClient for zero-dependency integrations, and Spring Boot with WebClient for reactive applications.

Maven and Gradle Dependencies

<!-- pom.xml — OkHttp -->
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.12.0</version>
</dependency>

<!-- pom.xml — Jackson for JSON parsing -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.0</version>
</dependency>
// build.gradle — Gradle
dependencies {
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0'
    // Spring Boot WebClient (spring-boot-starter-webflux)
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

Custom Exception Hierarchy

public class ScreenshotException extends RuntimeException {
    private final int statusCode;

    public ScreenshotException(String message, int statusCode) {
        super(message);
        this.statusCode = statusCode;
    }

    public int getStatusCode() { return statusCode; }
    public boolean isRetriable() { return false; }
}

public class RateLimitException extends ScreenshotException {
    private final long retryAfterSeconds;

    public RateLimitException(long retryAfterSeconds) {
        super("Rate limit exceeded", 429);
        this.retryAfterSeconds = retryAfterSeconds;
    }

    public long getRetryAfterSeconds() { return retryAfterSeconds; }

    @Override
    public boolean isRetriable() { return true; }
}

public class AuthException extends ScreenshotException {
    public AuthException() { super("Invalid or missing API key", 401); }
}

public class InvalidUrlException extends ScreenshotException {
    public InvalidUrlException(String url) {
        super("URL could not be loaded: " + url, 422);
    }
}

public class ScreenshotTimeoutException extends ScreenshotException {
    public ScreenshotTimeoutException() { super("Request timed out", 0); }
    @Override public boolean isRetriable() { return true; }
}

OkHttp Client

OkHttp is the most widely used HTTP client in Java/Android. Its OkHttpClient is designed to be shared — one instance per application.

import okhttp3.*;
import java.io.IOException;
import java.time.Duration;
import java.util.Objects;

public class ScreenshotClient {
    private static final String BASE_URL = "https://hermesforge.dev";
    private static final MediaType WILDCARD = MediaType.parse("*/*");

    private final OkHttpClient http;
    private final String apiKey;

    public ScreenshotClient(String apiKey) {
        this.apiKey = apiKey;
        this.http = new OkHttpClient.Builder()
            .connectTimeout(Duration.ofSeconds(10))
            .readTimeout(Duration.ofSeconds(60))
            .addInterceptor(chain -> {
                Request req = chain.request().newBuilder()
                    .header("X-API-Key", apiKey)
                    .header("User-Agent", "JavaScreenshotClient/1.0")
                    .build();
                return chain.proceed(req);
            })
            .build();
    }

    public byte[] capture(String url, String format, boolean fullPage, int width)
            throws ScreenshotException {
        HttpUrl requestUrl = Objects.requireNonNull(
            HttpUrl.parse(BASE_URL + "/api/screenshot")
        ).newBuilder()
            .addQueryParameter("url", url)
            .addQueryParameter("format", format)
            .addQueryParameter("full_page", String.valueOf(fullPage))
            .addQueryParameter("width", String.valueOf(width))
            .build();

        Request request = new Request.Builder()
            .url(requestUrl)
            .get()
            .build();

        try (Response response = http.newCall(request).execute()) {
            return handleResponse(response, url);
        } catch (IOException e) {
            throw new ScreenshotTimeoutException();
        }
    }

    public byte[] capture(String url) throws ScreenshotException {
        return capture(url, "webp", false, 1280);
    }

    private byte[] handleResponse(Response response, String url) throws IOException {
        switch (response.code()) {
            case 200:
                return Objects.requireNonNull(response.body()).bytes();
            case 401:
            case 403:
                throw new AuthException();
            case 422:
                throw new InvalidUrlException(url);
            case 429:
                String retryAfter = response.header("Retry-After", "60");
                long delay = Long.parseLong(Objects.requireNonNull(retryAfter));
                throw new RateLimitException(delay);
            default:
                String body = response.body() != null
                    ? Objects.requireNonNull(response.body()).string() : "";
                throw new ScreenshotException(
                    "HTTP " + response.code() + ": " + body, response.code()
                );
        }
    }
}

Retry Logic

public class RetryingScreenshotClient {
    private final ScreenshotClient client;
    private final int maxAttempts;

    public RetryingScreenshotClient(String apiKey, int maxAttempts) {
        this.client = new ScreenshotClient(apiKey);
        this.maxAttempts = maxAttempts;
    }

    public byte[] captureWithRetry(String url, String format,
            boolean fullPage, int width) throws ScreenshotException {
        int attempt = 0;
        while (true) {
            attempt++;
            try {
                return client.capture(url, format, fullPage, width);
            } catch (RateLimitException e) {
                if (attempt >= maxAttempts) throw e;
                sleepSeconds(e.getRetryAfterSeconds());
            } catch (ScreenshotException e) {
                if (!e.isRetriable() || attempt >= maxAttempts) throw e;
                sleepSeconds((long) Math.pow(2, attempt - 1));
            }
        }
    }

    private void sleepSeconds(long seconds) {
        try {
            Thread.sleep(seconds * 1000L);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted during retry sleep", e);
        }
    }
}

Java 11 HttpClient — Zero Dependencies

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

public class StandardScreenshotClient {
    private static final String BASE_URL = "https://hermesforge.dev";
    private final HttpClient http;
    private final String apiKey;

    public StandardScreenshotClient(String apiKey) {
        this.apiKey = apiKey;
        this.http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .version(HttpClient.Version.HTTP_2)
            .build();
    }

    public byte[] capture(String targetUrl, String format,
            boolean fullPage) throws Exception {
        String query = "url=" + URLEncoder.encode(targetUrl, StandardCharsets.UTF_8)
            + "&format=" + format
            + "&full_page=" + fullPage;

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/api/screenshot?" + query))
            .header("X-API-Key", apiKey)
            .header("Accept", "image/webp, image/png")
            .timeout(Duration.ofSeconds(60))
            .GET()
            .build();

        HttpResponse<byte[]> response = http.send(
            request, HttpResponse.BodyHandlers.ofByteArray()
        );

        return switch (response.statusCode()) {
            case 200 -> response.body();
            case 401, 403 -> throw new AuthException();
            case 422 -> throw new InvalidUrlException(targetUrl);
            case 429 -> {
                String retryAfter = response.headers()
                    .firstValue("Retry-After").orElse("60");
                throw new RateLimitException(Long.parseLong(retryAfter));
            }
            default -> throw new ScreenshotException(
                "HTTP " + response.statusCode(), response.statusCode()
            );
        };
    }

    // Async variant using CompletableFuture
    public java.util.concurrent.CompletableFuture<byte[]> captureAsync(
            String targetUrl, String format, boolean fullPage) {
        String query = "url=" + URLEncoder.encode(targetUrl, StandardCharsets.UTF_8)
            + "&format=" + format + "&full_page=" + fullPage;

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/api/screenshot?" + query))
            .header("X-API-Key", apiKey)
            .timeout(Duration.ofSeconds(60))
            .GET()
            .build();

        return http.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
            .thenApply(response -> {
                if (response.statusCode() == 200) return response.body();
                throw new RuntimeException("HTTP " + response.statusCode());
            });
    }
}

Parallel Captures with CompletableFuture

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;

public class ParallelScreenshotCapture {
    private final StandardScreenshotClient client;
    private final ExecutorService executor;

    public ParallelScreenshotCapture(String apiKey, int concurrency) {
        this.client = new StandardScreenshotClient(apiKey);
        this.executor = Executors.newFixedThreadPool(concurrency);
    }

    public Map<String, byte[]> captureMany(List<String> urls,
            String format, boolean fullPage) {
        List<CompletableFuture<Map.Entry<String, byte[]>>> futures = urls.stream()
            .map(url -> client.captureAsync(url, format, fullPage)
                .thenApply(data -> Map.entry(url, data))
                .exceptionally(ex -> {
                    System.err.println("Failed " + url + ": " + ex.getMessage());
                    return null;
                }))
            .collect(Collectors.toList());

        return futures.stream()
            .map(CompletableFuture::join)
            .filter(Objects::nonNull)
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                Map.Entry::getValue
            ));
    }

    public void shutdown() {
        executor.shutdown();
    }
}

// Usage:
// ParallelScreenshotCapture capture = new ParallelScreenshotCapture(apiKey, 5);
// Map<String, byte[]> results = capture.captureMany(
//     List.of("https://example.com", "https://ruby-lang.org"),
//     "webp", true
// );

Spring Boot Integration

Configuration

// ScreenshotProperties.java
@ConfigurationProperties(prefix = "hermes.screenshot")
@Validated
public class ScreenshotProperties {
    @NotBlank
    private String apiKey;

    private String format = "webp";
    private boolean fullPage = false;
    private int width = 1280;
    private int maxRetries = 3;

    // getters and setters
}
# application.yml
hermes:
  screenshot:
    api-key: ${HERMES_API_KEY}
    format: webp
    full-page: false
    width: 1280
    max-retries: 3

WebClient Bean

@Configuration
@EnableConfigurationProperties(ScreenshotProperties.class)
public class ScreenshotConfig {

    @Bean
    public WebClient screenshotWebClient(ScreenshotProperties props) {
        return WebClient.builder()
            .baseUrl("https://hermesforge.dev")
            .defaultHeader("X-API-Key", props.getApiKey())
            .defaultHeader("User-Agent", "SpringBootApp/1.0")
            .codecs(config -> config.defaultCodecs()
                .maxInMemorySize(10 * 1024 * 1024))  // 10 MB
            .build();
    }
}

Reactive Service

@Service
public class ScreenshotService {
    private final WebClient webClient;
    private final ScreenshotProperties props;

    public ScreenshotService(WebClient screenshotWebClient,
            ScreenshotProperties props) {
        this.webClient = screenshotWebClient;
        this.props = props;
    }

    public Mono<byte[]> capture(String url) {
        return webClient.get()
            .uri(uriBuilder -> uriBuilder
                .path("/api/screenshot")
                .queryParam("url", url)
                .queryParam("format", props.getFormat())
                .queryParam("full_page", props.isFullPage())
                .queryParam("width", props.getWidth())
                .build())
            .retrieve()
            .onStatus(status -> status.value() == 429, response ->
                response.headers().header("Retry-After").stream()
                    .findFirst()
                    .map(Long::parseLong)
                    .map(delay -> Mono.error(new RateLimitException(delay)))
                    .orElseGet(() -> Mono.error(new RateLimitException(60)))
            )
            .onStatus(status -> status.value() == 401 || status.value() == 403,
                response -> Mono.error(new AuthException()))
            .onStatus(status -> status.value() == 422,
                response -> Mono.error(new InvalidUrlException(url)))
            .bodyToMono(byte[].class)
            .retryWhen(Retry.backoff(props.getMaxRetries(), Duration.ofSeconds(1))
                .filter(ex -> ex instanceof ScreenshotException &&
                              ((ScreenshotException) ex).isRetriable())
                .doBeforeRetry(signal ->
                    log.warn("Retrying screenshot for {}, attempt {}",
                        url, signal.totalRetries() + 1)));
    }

    public Flux<Map.Entry<String, byte[]>> captureMany(List<String> urls) {
        return Flux.fromIterable(urls)
            .flatMap(url -> capture(url)
                .map(data -> Map.entry(url, data))
                .onErrorResume(ex -> {
                    log.error("Failed to capture {}: {}", url, ex.getMessage());
                    return Mono.empty();
                }),
                5  // max concurrency
            );
    }
}

Async with @Async

@Service
public class AsyncScreenshotService {
    private final RetryingScreenshotClient client;

    public AsyncScreenshotService(
            @Value("${hermes.screenshot.api-key}") String apiKey) {
        this.client = new RetryingScreenshotClient(apiKey, 3);
    }

    @Async("screenshotExecutor")
    public CompletableFuture<byte[]> captureAsync(String url) {
        try {
            byte[] data = client.captureWithRetry(url, "webp", false, 1280);
            return CompletableFuture.completedFuture(data);
        } catch (ScreenshotException e) {
            return CompletableFuture.failedFuture(e);
        }
    }
}

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "screenshotExecutor")
    public Executor screenshotExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("screenshot-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

REST Controller Example

@RestController
@RequestMapping("/api/preview")
public class PreviewController {
    private final ScreenshotService screenshotService;

    @GetMapping(produces = "image/webp")
    public Mono<ResponseEntity<byte[]>> preview(@RequestParam String url) {
        return screenshotService.capture(url)
            .map(data -> ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("image/webp"))
                .contentLength(data.length)
                .body(data))
            .onErrorReturn(AuthException.class,
                ResponseEntity.status(401).build())
            .onErrorReturn(RateLimitException.class,
                ResponseEntity.status(429).build())
            .onErrorReturn(ResponseEntity.status(500).build());
    }
}

Comparison Table

Approach Best for Async Dependencies Notes
OkHttp Android, standalone JVM Via Call.enqueue okhttp3 Industry standard, great interceptors
Java 11 HttpClient No-dep microservices CompletableFuture None (JDK) Good for CLI tools, lambdas
Spring WebClient Reactive Spring Boot Mono/Flux spring-webflux Best for reactive pipelines
Spring @Async Traditional Spring Boot CompletableFuture spring-context Familiar, thread-pool based

Key Patterns

Share OkHttpClient — treat it as a singleton. It manages thread pools and connection pools internally. Creating one per request wastes resources.

Use switch expressions with statusCode() — Java 14+ switch expressions on response.statusCode() make status dispatch readable. Fall through to a default case that throws.

CompletableFuture vs Mono — for non-reactive Spring apps, @Async with CompletableFuture is simpler than introducing Reactor. For reactive apps, use WebClient end-to-end to avoid blocking the event loop.

CallerRunsPolicy for backpressure — when the screenshot executor queue is full, CallerRunsPolicy applies backpressure by running the task on the caller thread instead of dropping it. This slows the producer rather than losing work.

Interrupt handling — always restore the interrupted status when catching InterruptedException. Use Thread.currentThread().interrupt() before re-throwing.


Free API key at hermesforge.dev. 50 captures/day, no credit card required.