The Java screenshot API page uses the rs-java SDK. This page does the same work in Spring Boot with no SDK at all, because many teams prefer one HTTP client that they already know, test and trace. You get four parts: a RestClient bean, a controller that streams images and PDFs without holding them in memory, @Async captures on virtual threads, and tests with MockRestServiceServer. A short signed-URL component covers og:image tags.
Last tested: 2026-10-19 with OpenJDK 21.0.2, Maven 3.9.16, Spring Boot 4.1.1 (Spring Framework 7.0.9) and
spring-boot-starter-test.mvn testpassed all six tests. We also started the full application against a local stub of our API and saw the three@Asynccaptures run on virtual threads. We did not call the live API for this page.
What each part is for
| Job | Spring piece | API call |
|---|---|---|
og:image for your own pages |
Controller that streams a PNG | POST /v1/screenshot with url and preset |
| Invoices, receipts, reports | Controller that streams a PDF | POST /v1/screenshot with html and output.format: pdf |
| Archive a list of pages | @Async service on virtual threads |
One request per URL, capped |
| Social cards that crawlers load directly | Signed-URL component | GET /v1/screenshot, no request to your app |
Dependencies and properties
Only spring-boot-starter-web is needed, plus spring-boot-starter-test for the tests. RestClient, MockRestServiceServer and HtmlUtils are part of Spring Framework.
# src/main/resources/application.properties rs.api-key=${RS_API_KEY} rs.base-url=${RS_BASE_URL:https://api.renderscreenshot.com} rs.signing-key=${RS_SIGNING_KEY} rs.public-key-id=${RS_PUBLIC_KEY_ID} spring.threads.virtual.enabled=true # @Async tasks run on virtual threads; cap how many render at once spring.task.execution.simple.concurrency-limit=4
RS_BASE_URL exists only so that a local stub can stand in for the API.
The RestClient bean
@SpringBootApplication @EnableAsync public class ShotsApplication { @Bean RestClient renderScreenshot(@Value("${rs.base-url}") String baseUrl, @Value("${rs.api-key}") String apiKey) { return configure(RestClient.builder(), baseUrl, apiKey).build(); } /** Shared by the bean and the tests, so MockRestServiceServer can bind to the same builder. */ static RestClient.Builder configure(RestClient.Builder builder, String baseUrl, String apiKey) { var factory = new JdkClientHttpRequestFactory(); factory.setReadTimeout(Duration.ofSeconds(90)); // above the API's 60-second maximum wait return builder .requestFactory(factory) .baseUrl(baseUrl) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey); } public static void main(String[] args) { SpringApplication.run(ShotsApplication.class, args); } }
The configure method is the important design choice. The tests call it with their own builder, so they exercise the same base URL, header and timeout as production. The read timeout must be longer than the slowest render. The API waits at most 60 seconds for a page (wait options), so 90 seconds leaves room for the network.
A controller that streams PNGs and PDFs
@RestController class ShotController { private static final Logger log = LoggerFactory.getLogger(ShotController.class); private static final Pattern SLUG = Pattern.compile("[a-z0-9-]{1,80}"); private final RestClient api; ShotController(RestClient api) { this.api = api; } /** A page of your site as an Open Graph PNG, streamed straight from the API. */ @GetMapping("/og/{slug}.png") void ogImage(@PathVariable String slug, HttpServletResponse out) { if (!SLUG.matcher(slug).matches()) { out.setStatus(404); return; } stream(Map.of("url", "https://example.com/blog/" + slug, "preset", "og_card"), "public, max-age=86400", out); } /** HTML you build on the server, as an A4 PDF. */ @PostMapping("/invoices/pdf") void invoicePdf(@RequestParam String customer, @RequestParam String amount, HttpServletResponse out) { String html = """ <!doctype html><meta charset="utf-8"> <style>body{font:14px system-ui;margin:0} h1{font-size:22px}</style> <h1>Invoice for %s</h1><p>Amount due: %s</p> """.formatted(HtmlUtils.htmlEscape(customer), HtmlUtils.htmlEscape(amount)); stream(Map.of( "html", html, "output", Map.of("format", "pdf", "filename", "invoice"), "pdf", Map.of("paper", "a4", "margin", "1.5cm", "print_background", true)), "private, no-store", out); } private void stream(Map<String, Object> body, String cacheControl, HttpServletResponse out) { api.post().uri("/v1/screenshot") .contentType(MediaType.APPLICATION_JSON) .body(body) .exchange((request, response) -> { int status = response.getStatusCode().value(); if (status == 200) { out.setContentType(response.getHeaders().getFirst("Content-Type")); out.setHeader("Cache-Control", cacheControl); var disposition = response.getHeaders().getFirst("Content-Disposition"); if (disposition != null) out.setHeader("Content-Disposition", disposition); response.getBody().transferTo(out.getOutputStream()); // no byte[] in the heap return null; } log.warn("render failed: {} {}", status, new String(response.getBody().readAllBytes())); if (status == 429) { out.setHeader("Retry-After", response.getHeaders().getFirst("Retry-After")); out.setStatus(503); } else { out.setStatus(502); } return null; }); } }
Imports are left out; they are all from org.springframework, jakarta.servlet, org.slf4j and java.util.
Why it is built this way:
exchange(), notbody(byte[].class).exchangegives you the open response stream, andtransferTocopies it to the servlet output as it arrives. A 20-page PDF or a long full-page PNG never sits in the heap as one array.exchangealso does not throw on 4xx or 5xx, so one lambda handles both outcomes.- The slug check is the security boundary. Visitors choose the slug, and only
[a-z0-9-]reaches the URL. Without it, anyone could point your key at any page and spend your credits. - The HTML is escaped.
customercomes from a form.HtmlUtils.htmlEscapeturns<script>into text before the HTML goes to Chrome. If you render with Thymeleaf, itsth:textescapes for you. - The PDF options follow the API.
paper,marginandprint_backgroundlive in thepdfobject, andoutput.filenamemakes the API send aContent-Dispositionheader, which the controller passes on. All options are on the PDF parameters page. The PDF generation API page compares this approach with other PDF engines. - A 429 becomes a 503 with
Retry-After. The rate limit is per account, and all your keys share it (rate limits). A web request should not wait 30 seconds and retry. It should tell the client or CDN when to come back. The logged body holds the API'serror.code; the error codes index explains each one.
Many captures with @Async on virtual threads
For a list of URLs, such as a nightly archive, call a service method once per URL and let Spring run each call on its own virtual thread:
@Service public class BulkCapture { private final RestClient api; BulkCapture(RestClient api) { this.api = api; } /** Runs on a virtual thread from Spring's task executor; returns when the file is on disk. */ @Async public CompletableFuture<Path> capture(String url, Path file) throws Exception { for (int attempt = 1; ; attempt++) { try { byte[] png = api.post().uri("/v1/screenshot") .contentType(MediaType.APPLICATION_JSON) .body(Map.of("url", url, "preset", "full_page")) .retrieve() .body(byte[].class); return CompletableFuture.completedFuture(Files.write(file, png)); } catch (HttpClientErrorException e) { if (e.getStatusCode() != HttpStatus.TOO_MANY_REQUESTS || attempt == 4) throw e; String wait = e.getResponseHeaders().getFirst("Retry-After"); Thread.sleep(Long.parseLong(wait == null ? "5" : wait) * 1000); // cheap on a virtual thread } } } }
@Component public class Archiver { private final BulkCapture bulk; Archiver(BulkCapture bulk) { this.bulk = bulk; } /** Starts one @Async capture per URL and waits for all of them. */ public List<Path> archive(List<String> urls, Path dir) throws Exception { var futures = new ArrayList<CompletableFuture<Path>>(); for (int i = 0; i < urls.size(); i++) { futures.add(bulk.capture(urls.get(i), dir.resolve("page-" + i + ".png"))); } CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join(); return futures.stream().map(CompletableFuture::join).toList(); } }
Three points about this pair:
- The call must cross a Spring proxy.
@Asyncworks only when another bean calls the method, which is whyArchiveris a separate class. A call from insideBulkCaptureitself would run on the caller's thread. - The two properties do the threading. With
spring.threads.virtual.enabled=true, Boot's task executor starts a virtual thread for each task. In our run against the stub, all three captures loggedVirtualThread[...].spring.task.execution.simple.concurrency-limitcaps how many run at the same time. Keep it small: the API limits requests per minute for the whole account (rate limits reference), and each of these captures is one request. - Sleeping is fine here. A virtual thread that waits out
Retry-Afterdoes not hold an OS thread. Hereretrieve()throwsHttpClientErrorExceptionfor any 4xx, which suits a job: the job fails loudly on a bad URL and retries only a 429.
allOf(...).join() throws if any capture fails, but the other files are still written. For more than a few dozen URLs, the batch endpoint is better: one POST /v1/batch counts as one request against the per-minute limit, and our side does the fan-out. For queues that must survive a restart, see screenshots in background jobs.
Signed og:image URLs
A controller that streams images still runs one request through your app for each crawler fetch. A signed URL sends the crawler to the API directly. This component signs URLs with the documented algorithm, without the SDK:
@Component public class OgImageUrls { private final String endpoint; private final String keyId; private final SecretKeySpec key; OgImageUrls(@Value("${rs.base-url}") String baseUrl, @Value("${rs.public-key-id}") String keyId, // rs_pub_... @Value("${rs.signing-key}") String signingKey) { // rs_secret_... this.endpoint = baseUrl + "/v1/screenshot"; this.keyId = keyId; this.key = new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); } /** Same URL for a whole UTC day, valid 7 to 8 days (the maximum is 30). */ public String forPage(String pageUrl) { Instant expires = Instant.now().truncatedTo(ChronoUnit.DAYS).plus(Duration.ofDays(8)); return sign(Map.of("url", pageUrl, "preset", "og_card"), expires); } String sign(Map<String, String> params, Instant expires) { var fields = new TreeMap<String, String>(params); fields.put("key_id", keyId); fields.put("expires", Long.toString(expires.getEpochSecond())); String query = fields.entrySet().stream() .map(e -> encode(e.getKey()) + "=" + encode(e.getValue())) .sorted() .collect(Collectors.joining("&")); try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(key); byte[] digest = mac.doFinal(query.getBytes(StandardCharsets.UTF_8)); return endpoint + "?" + query + "&signature=" + HexFormat.of().formatHex(digest); } catch (GeneralSecurityException e) { throw new IllegalStateException(e); } } // Form encoding as the server does it: space -> "+", only A-Z a-z 0-9 - _ . ~ kept. private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("*", "%2A").replace("%7E", "~"); } }
Put ogImageUrls.forPage(...) into your model and render it in the og:image meta tag. The expiry is aligned to the UTC day, so every page view on the same day gets the same URL, and crawlers and CDNs can cache the image. Signed URLs work only for GET and HEAD requests, and expire after 30 days at most.
We found one startup trap while we tested this. If RS_SIGNING_KEY is empty, SecretKeySpec throws IllegalArgumentException: Empty key, and the context does not start. That is why the property has no default: a missing key stops the deploy instead of producing broken images.
Tests with MockRestServiceServer
The tests bind MockRestServiceServer to a builder from configure(...) and run the controller with standalone MockMvc. They need no Spring context and no network:
class ShotControllerTest { private MockRestServiceServer api; private MockMvc mvc; @BeforeEach void setUp() { RestClient.Builder builder = ShotsApplication.configure(RestClient.builder(), "https://api.test", "rs_live_test"); api = MockRestServiceServer.bindTo(builder).build(); mvc = MockMvcBuilders.standaloneSetup(new ShotController(builder.build())).build(); } @Test void streamsThePngWithCacheHeaders() throws Exception { api.expect(requestTo("https://api.test/v1/screenshot")) .andExpect(method(HttpMethod.POST)) .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer rs_live_test")) .andExpect(MockRestRequestMatchers.content().json(""" {"url": "https://example.com/blog/hello-world", "preset": "og_card"}""")) .andRespond(withSuccess(new byte[] {(byte) 0x89, 'P', 'N', 'G'}, MediaType.IMAGE_PNG)); mvc.perform(get("/og/hello-world.png")) .andExpect(status().isOk()) .andExpect(header().string("Cache-Control", "public, max-age=86400")) .andExpect(content().bytes(new byte[] {(byte) 0x89, 'P', 'N', 'G'})); api.verify(); } @Test void turnsA429IntoA503WithRetryAfter() throws Exception { api.expect(requestTo("https://api.test/v1/screenshot")) .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS).header("Retry-After", "12")); mvc.perform(get("/og/hello-world.png")) .andExpect(status().isServiceUnavailable()) .andExpect(header().string("Retry-After", "12")); } @Test void escapesUserInputInThePdfHtml() throws Exception { api.expect(requestTo("https://api.test/v1/screenshot")) .andExpect(MockRestRequestMatchers.jsonPath("$.html").value(containsString("<script>"))) .andExpect(MockRestRequestMatchers.jsonPath("$.output.format").value("pdf")) .andRespond(withSuccess("%PDF-1.7", MediaType.APPLICATION_PDF)); mvc.perform(post("/invoices/pdf").param("customer", "<script>x</script>").param("amount", "$40")) .andExpect(status().isOk()) .andExpect(header().string("Content-Type", "application/pdf")); } }
Both MockRestRequestMatchers and MockMvcResultMatchers define header(), content() and jsonPath(). If you import both with a wildcard, the compiler reports "reference to content is ambiguous". So the listing imports only requestTo and method statically, and it writes the class name for the API-side matchers.
The full suite has three more tests. One sends a bad slug and checks that no request reached the API. One calls BulkCapture.capture directly with a Retry-After: 0 response, followed by a success. The last checks OgImageUrls.sign against the reference vector in the signed URL docs, and our signature matched it. For contract tests in other languages, see the SDK testing guide.
When to use the SDK instead
Use rs-java if you want typed responses, takeJson() with the CDN URL, and batch helpers without writing JSON maps. The Java screenshot API page shows it with a Spring controller and Mockito. Use the RestClient approach on this page if your team already standardizes on RestClient for observability, retries and testing, or if you want to stream responses.
Get a free API key, set it as RS_API_KEY, and run mvn spring-boot:run. The free plan includes 150 credits every month.