Java HTML to PDF libraries compared
Java has mature PDF libraries, some of them older than most web frameworks. Picking one involves two questions people often skip: how much CSS the engine really understands, and what its licence asks of you. The table summarises the well-known choices.
| Library | How it works | Worth knowing |
|---|---|---|
| OpenPDF | A fork of iText 4 under LGPL and MPL; you build documents in code | Business friendly licence and a familiar API; HTML input needs a companion such as Flying Saucer |
| iText | A full PDF toolkit; its pdfHTML add-on converts HTML and CSS | Very capable; licensed under the AGPL or a commercial licence, so closed-source services usually need to buy one |
| Flying Saucer | Renders XHTML with CSS 2.1 and writes the PDF through OpenPDF | Solid for simple layouts; input must be well-formed XHTML, and modern CSS such as flexbox and grid is not supported |
| OpenHTMLtoPDF | A descendant of Flying Saucer that writes PDFs with Apache PDFBox | Adds useful CSS3 features over its parent; flexbox and grid are still out of scope |
| CastPDF | One HTTPS request; current Chromium with print features runs on our servers | A network call per document and a monthly plan above the free allowance |
If you already produce simple, well-formed XHTML and Flying Saucer is part of your build, it will keep doing its job. Teams look elsewhere when a designer hands over a modern HTML template full of flexbox, when the XHTML parser rejects an unclosed <br> at 2 a.m., or when the legal team asks what the AGPL means for a closed-source product. An HTTP API sidesteps the library licence entirely and renders the same CSS that Chrome shows on screen.
Your first booking confirmation in Java
Open the dashboard, create a test key (prefix cpdf_test_) and set it as the environment variable CASTPDF_API_KEY. Test renders are free, unlimited and watermarked. The HTTP client is part of the JDK since Java 11; for JSON the example uses Jackson databind, which most Java projects already include (com.fasterxml.jackson.core:jackson-databind).
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class BookingConfirmation {
private static final String HTML = String.join("",
"<h1>Booking confirmed: {{ booking.ref }}</h1>",
"<p>Dear {{ guest.name }}, your stay at {{ property.name }} is confirmed.</p>",
"<table><thead><tr><th>Night</th><th>Room</th><th>Rate</th></tr></thead><tbody>",
"{% for night in nights %}<tr><td>{{ night.date | format_date }}</td>",
"<td>{{ night.room }}</td><td>{{ night.rate | money: 'EUR' }}</td></tr>{% endfor %}",
"</tbody></table>",
"<p>Check-in from {{ property.check_in }}. Total {{ booking.total | money: 'EUR' }}</p>");
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> data = Map.of(
"booking", Map.of("ref", "BK-77310", "total", 438.00),
"guest", Map.of("name", "Priya Raman"),
"property", Map.of("name", "Hotel Alder, Ljubljana", "check_in", "15:00"),
"nights", List.of(
Map.of("date", "2026-11-12", "room", "Double, courtyard", "rate", 146.00),
Map.of("date", "2026-11-13", "room", "Double, courtyard", "rate", 146.00),
Map.of("date", "2026-11-14", "room", "Double, courtyard", "rate", 146.00)));
Map<String, Object> payload = Map.of(
"html", HTML,
"data", data,
"format", "A5",
"margins", "12mm",
"filename", "booking-BK-77310");
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.castpdf.com/v1/pdf"))
.timeout(Duration.ofSeconds(75))
.header("Authorization", "Bearer " + System.getenv("CASTPDF_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload)))
.build();
HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
JsonNode error = mapper.readTree(response.body()).path("error");
throw new IllegalStateException(error.path("code").asText() + ": " + error.path("message").asText());
}
Files.write(Path.of("booking-BK-77310.pdf"), response.body());
System.out.println("Saved booking-BK-77310.pdf, pages: " + response.headers().firstValue("x-pages").orElse("?"));
}
}Jackson turns the nested Map and List values into JSON, so quotes and accents in guest names are escaped correctly; never assemble the JSON body by string concatenation. The Liquid inside the HTML uses single-quoted arguments (money: 'EUR'), which saves you from escaping double quotes inside Java strings. Because the request includes data, CastPDF fills the template first, then lays out the page on A5 with 12 mm margins. HttpRequest.timeout limits the whole exchange, while connectTimeout only covers opening the connection.
Saved templates and signed links from a service class
Long HTML strings inside Java classes are awkward to review and impossible for a designer to edit. Save the confirmation layout as a template in the dashboard instead: HTML and CSS with a live preview, plus Simple mode, where the reservations team can change the sample values without code. The Java side shrinks to a template ID and a data map. The service below asks for a signed link, which suits a confirmation email or a "View booking" button.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public final class ConfirmationLinks {
private static final URI ENDPOINT = URI.create("https://api.castpdf.com/v1/pdf");
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static JsonNode createLink(String bookingRef, Map<String, Object> data)
throws IOException, InterruptedException {
Map<String, Object> payload = Map.of(
"template_id", System.getenv("BOOKING_TEMPLATE_ID"),
"data", data,
"filename", "booking-" + bookingRef,
"response", "url");
HttpRequest request = HttpRequest.newBuilder(ENDPOINT)
.timeout(Duration.ofSeconds(75))
.header("Authorization", "Bearer " + System.getenv("CASTPDF_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "booking-" + bookingRef)
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body = MAPPER.readTree(response.body());
if (response.statusCode() != 200) {
JsonNode error = body.path("error");
throw new IOException(error.path("code").asText() + ": " + error.path("message").asText());
}
return body;
}
public static void main(String[] args) throws Exception {
Map<String, Object> data = Map.of(
"booking", Map.of("ref", "BK-77311", "total", 292.00),
"guest", Map.of("name", "Lukas Brenner"),
"property", Map.of("name", "Hotel Alder, Ljubljana", "check_in", "15:00"),
"nights", List.of(
Map.of("date", "2026-12-03", "room", "Single, garden", "rate", 146.00),
Map.of("date", "2026-12-04", "room", "Single, garden", "rate", 146.00)));
JsonNode link = createLink("BK-77311", data);
System.out.println(link.path("url").asText() + " expires " + link.path("expires_at").asText());
}
}The Idempotency-Key reuses the booking reference. If a guest double-clicks, or your message consumer redelivers the "booking created" event, the repeated request gets the confirmation that already exists and nothing is billed twice. Keep in mind that data replaces the template's sample data completely, so the map must carry every field the template uses.
The response JSON includes id, pages, bytes, url and expires_at. The url works without an API key until it expires. Store the id with the booking, because GET /v1/pdf/:id returns a fresh link when a guest asks for the confirmation again weeks later. The API reference documents every request and response field.
Error handling and retries in Java
Errors share one JSON shape: an error object with a stable code, a readable message and a docs_url. The client below turns them into a checked ApiException that carries both the HTTP status and the code. It retries 409 (the first request with the same key is still rendering), 429 (sleeping for Retry-After), 503 (exponential backoff) and any IOException from the transport, which includes HttpTimeoutException. An HttpRequest is immutable and can be sent again, but building it inside the loop keeps the code easy to follow.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class CastPdf {
private static final URI ENDPOINT = URI.create("https://api.castpdf.com/v1/pdf");
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static final class ApiException extends IOException {
private final int status;
private final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
public int status() { return status; }
public String code() { return code; }
}
public static byte[] render(String jsonBody, String idempotencyKey) throws IOException, InterruptedException {
final int maxAttempts = 5;
for (int attempt = 1; ; attempt++) {
HttpRequest request = HttpRequest.newBuilder(ENDPOINT)
.timeout(Duration.ofSeconds(75))
.header("Authorization", "Bearer " + System.getenv("CASTPDF_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<byte[]> response;
try {
response = CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
if (attempt == maxAttempts) {
throw e;
}
Thread.sleep(1000L << attempt);
continue;
}
int status = response.statusCode();
if (status == 200) {
return response.body();
}
if ((status == 409 || status == 429 || status == 503) && attempt < maxAttempts) {
long seconds = response.headers().firstValue("Retry-After")
.map(CastPdf::parseSeconds)
.orElse(1L << attempt);
Thread.sleep(seconds * 1000);
continue;
}
JsonNode error = MAPPER.readTree(response.body()).path("error");
throw new ApiException(status, error.path("code").asText("unknown"), error.path("message").asText());
}
}
private static long parseSeconds(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return 2;
}
}
}Catch CastPdf.ApiException where you can respond sensibly: show the guest a friendly message on template_render_error, page someone on spending_cap_reached, and let your job framework retry anything else later. Because the idempotency key stays the same across attempts and across job retries, a timeout never turns into two confirmations.
Production checklist for Java
- Create one
HttpClientper application, for example a static field or a Spring bean. It is thread safe and pools connections; a new client per request wastes threads and TLS handshakes. - Always set
HttpRequest.timeout. Without it, the JDK client waits indefinitely for a response. Choose at least 60 seconds, since a render may take up to 30 seconds. - Keep long renders off request threads. Hand them to an executor or a job queue, or run them on virtual threads with Java 21, where a blocking
sendcosts very little. - Stream big files with
HttpResponse.BodyHandlers.ofFile(path)instead ofofByteArray(), so a large confirmation pack never sits on the heap. Check the status code before trusting the file, because an error body is written there too. - Never format HTML or CSS with
String.format: a CSS value such aswidth: 100%is read as a format specifier and throws. Use plain concatenation, a template file, or a saved template. - Keep the key on the server, in an environment variable or your secrets store. Never ship it inside an Android app or a desktop client. Swap
cpdf_test_for acpdf_live_key at launch; nothing else changes. - Respect the limits of 50 pages and 40 MB per document, and shrink property photos before embedding them.
Common errors from Java code
| Symptom or code | Likely cause | Fix |
|---|---|---|
NullPointerException from Map.of | System.getenv returned null for a missing variable, and Map.of rejects null values | Set the variable for the JVM process, or validate configuration at startup |
HttpTimeoutException: request timed out | The request timeout was shorter than the render | Raise HttpRequest.timeout to 60 seconds or more and retry with the same key |
SSLHandshakeException: PKIX path building failed | A corporate proxy re-signs TLS traffic with a certificate the JVM does not trust | Import the proxy's CA into the truststore the JVM uses |
invalid_request (400) | A POJO was serialised with camelCase keys such as templateId | Annotate fields with @JsonProperty("template_id") or use a snake case naming strategy |
template_render_error (422) | Thymeleaf or FreeMarker syntax like ${booking.ref} left in a Liquid template | Use Liquid tags such as {{ booking.ref }}; read details.line and details.column |
rate_limited (429) | Too many requests per minute (test keys allow 20) | Sleep for Retry-After seconds, then retry |
The quickstart shows the dashboard side: keys, starter templates and the template editor. Building on .NET too? The .NET page makes the same calls with HttpClient and System.Net.Http.Json. For a compact standard library version, see the Go page.