A GET /invoices/{id}.pdf endpoint that fills a Thymeleaf template and returns a downloadable PDF, in two versions: pure Java with OpenHTMLtoPDF, and Chrome-rendered through an API with RestClient. Both were run end to end on Spring Boot 4.1.1 and JDK 25, and both return a 1-page A4 invoice with "Page 1 of 1" in the corner.
| Option | Pick it when |
|---|---|
| OpenHTMLtoPDF Pure Java, in your JVM, LGPL | Your templates are simple (blocks, tables, floats) and you want no external process or service. |
| An API (Chromium) One HTTP call from RestClient | Your HTML uses flexbox, grid or web fonts and must look like it does in Chrome, without running a browser next to your app. |
Running Chromium yourself with Playwright for Java is the third option; its code and setup are in HTML to PDF in Java. In a web app it means a browser process per instance, so pool it (one Browser, a new Page per request) and keep it off small containers.
A standard Spring Boot project (Initializr with Spring Web and Thymeleaf), plus OpenHTMLtoPDF and jsoup. pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<groupId>com.example</groupId>
<artifactId>invoice</artifactId>
<version>1.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>io.github.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.1.97</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.23.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The API version needs nothing beyond spring-boot-starter-web, so drop the last two dependencies if that is the only one you use. Gradle: the same coordinates as implementation lines.
src/main/resources/templates/invoice.html. The @page rule sets the paper size, margins and page numbers; both renderers honor it:
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><style>
@page { size: A4; margin: 20mm 15mm;
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9px; } }
body { font-family: sans-serif; }
.top { background: #eef2ff; padding: 12px; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
tr { page-break-inside: avoid; }
</style></head><body>
<div class="top"><h1 th:text="'Invoice #' + ${number}">Invoice</h1><p th:text="'Due: ' + ${due}">Due</p></div>
<table><tr><th>Item</th><th>Price</th></tr>
<tr th:each="line : ${lines}"><td th:text="${line.item}">Item</td><td th:text="${line.price}">0</td></tr></table>
</body></html>
The data, as a Thymeleaf Context both controllers share (InvoiceData.java):
package com.example.invoice;
import java.util.List;
import java.util.Map;
import org.thymeleaf.context.Context;
public final class InvoiceData {
public record Line(String item, String price) {}
// In a real app this comes from your database
public static Context context(long id) {
Context ctx = new Context();
ctx.setVariables(Map.of(
"number", id,
"due", "2026-11-01",
"lines", List.of(new Line("Consulting (3h)", "€450.00"), new Line("Hosting, Oct.", "€19.00"))));
return ctx;
}
}
We call TemplateEngine.process() instead of returning a view name: the controller needs the HTML as a String, not a rendered response. Spring Boot auto-configures the engine with the templates/ folder, so just inject it.
PdfController.java:
package com.example.invoice;
import java.io.ByteArrayOutputStream;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.jsoup.Jsoup;
import org.jsoup.helper.W3CDom;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.TemplateEngine;
@RestController
public class PdfController {
private final TemplateEngine templates;
public PdfController(TemplateEngine templates) {
this.templates = templates;
}
@GetMapping("/invoices/{id}.pdf")
public ResponseEntity<byte[]> invoice(@PathVariable long id) throws Exception {
String html = templates.process("invoice", InvoiceData.context(id));
// jsoup turns HTML5 into the well-formed DOM OpenHTMLtoPDF needs
var doc = new W3CDom().fromJsoup(Jsoup.parse(html));
var out = new ByteArrayOutputStream();
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.withW3cDocument(doc, "classpath:/static/"); // base for relative images/CSS
builder.toStream(out);
builder.run();
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("invoice-" + id + ".pdf").build().toString())
.body(out.toByteArray());
}
}
Run mvn spring-boot:run and open http://localhost:8080/invoices/1042.pdf: the browser downloads invoice-1042.pdf with the blue header, selectable text, the euro amounts and the page number. Notes:
<meta charset="utf-8"> without a closing slash fails. jsoup plus W3CDom accepts normal HTML.classpath:/static/ points at src/main/resources/static, so <img src="logo.png"> works if the file is there.builder.useFont(new File("NotoSans-Regular.ttf"), "Noto Sans") and use that family in the CSS.ContentDisposition.attachment() forces a download; use ContentDisposition.inline() to open it in the browser's PDF viewer.ApiPdfController.java. Same template, but the HTML is sent to Shotmint's /v1/pdf, which renders it in headless Chromium and returns the PDF bytes:
package com.example.invoice;
import java.util.Map;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.thymeleaf.TemplateEngine;
@RestController
public class ApiPdfController {
private final TemplateEngine templates;
private final RestClient shotmint = RestClient.builder()
.baseUrl("https://shotmint.creasework.com")
.defaultHeader("X-API-Key", System.getenv("SHOTMINT_KEY"))
.build();
public ApiPdfController(TemplateEngine templates) {
this.templates = templates;
}
@GetMapping("/invoices/{id}/chrome.pdf")
public ResponseEntity<byte[]> invoice(@PathVariable long id) {
String html = templates.process("invoice", InvoiceData.context(id));
byte[] pdf = shotmint.post()
.uri("/v1/pdf")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("html", html, "format", "A4", "margin", "20mm"))
.retrieve() // a 4xx/5xx throws RestClientResponseException with the JSON error
.body(byte[].class);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("invoice-" + id + ".pdf").build().toString())
.body(pdf);
}
}
Set the key in the environment (export SHOTMINT_KEY=sm_...; a free key takes one click and needs no signup) and open /invoices/1042/chrome.pdf. Result: the same 1-page A4 invoice, page number included, rendered by Chromium, so flexbox, grid and web fonts work too. Notes:
retrieve() throw RestClientResponseException, which Spring turns into a 500. Catch it (or add an @ExceptionHandler) and read getResponseBodyAsString() for the JSON error, e.g. a 401 "Missing or invalid API key".@{/logo.png} gives a relative path, so prefix your public host.format (A3, A4, A5, Letter, Legal, Tabloid), margin, landscape, scale, print_background (on by default) and screen_media. To render a live page instead, send "url" instead of "html".X-Credits-Remaining header tells you how many are left.RestClient; use RestTemplate.postForObject("/v1/pdf", request, byte[].class) with the same JSON body and header.| Symptom | Fix |
|---|---|
SAXParseException: element type "meta" must be terminated | You passed HTML5 straight to withHtmlContent(). Parse it with jsoup + W3CDom as above. |
| Euro sign or accents garbled | Missing <meta charset="utf-8"> in the template, or a non-UTF-8 source encoding in the build. |
| Side-by-side boxes stacked (OpenHTMLtoPDF) | Flexbox/grid aren't supported. Use a table or floats, or the API. |
| Logo missing | Relative URL with the wrong base. OpenHTMLtoPDF: check the base URI. API: use an absolute public URL. |
| PDF opens as text or downloads as "1042" | Set MediaType.APPLICATION_PDF and a Content-Disposition filename. |
Thymeleaf TemplateInputException | The file isn't at src/main/resources/templates/invoice.html, or the name passed to process() has a typo. |
Simple invoices and reports you design yourself: OpenHTMLtoPDF keeps everything in the JVM. If the template needs modern CSS, must match the browser, or comes from designers who use flexbox, use the API rather than shipping Chromium with every instance. To check a template first, paste the rendered HTML into the free HTML to PDF converter; no key needed. Plain Java, Playwright and the dependency-free HttpClient version: HTML to PDF in Java.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons