Skip to content
CastPDF

Java HTML to PDF with the built-in HttpClient

To convert HTML to PDF in Java, serialise your HTML (or a saved template ID) and data with Jackson, send it to CastPDF through java.net.http.HttpClient, and write the returned bytes with Files.write. Normal HTML5 and modern CSS work, with no XHTML rules. It is the same PDF generation API every stack calls, from Java 11 up.

  • Updated October 2026
  • 100 free PDFs a month

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.

Ways to make a PDF from Java
LibraryHow it worksWorth knowing
OpenPDFA fork of iText 4 under LGPL and MPL; you build documents in codeBusiness friendly licence and a familiar API; HTML input needs a companion such as Flying Saucer
iTextA full PDF toolkit; its pdfHTML add-on converts HTML and CSSVery capable; licensed under the AGPL or a commercial licence, so closed-source services usually need to buy one
Flying SaucerRenders XHTML with CSS 2.1 and writes the PDF through OpenPDFSolid for simple layouts; input must be well-formed XHTML, and modern CSS such as flexbox and grid is not supported
OpenHTMLtoPDFA descendant of Flying Saucer that writes PDFs with Apache PDFBoxAdds useful CSS3 features over its parent; flexbox and grid are still out of scope
CastPDFOne HTTPS request; current Chromium with print features runs on our serversA 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).

BookingConfirmation.java (Java 11+, Jackson)
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.

ConfirmationLinks.java
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.

CastPdf.java (retrying client)
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 HttpClient per 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 send costs very little.
  • Stream big files with HttpResponse.BodyHandlers.ofFile(path) instead of ofByteArray(), 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 as width: 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 a cpdf_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

Errors Java developers meet first, and how to fix them
Symptom or codeLikely causeFix
NullPointerException from Map.ofSystem.getenv returned null for a missing variable, and Map.of rejects null valuesSet the variable for the JVM process, or validate configuration at startup
HttpTimeoutException: request timed outThe request timeout was shorter than the renderRaise HttpRequest.timeout to 60 seconds or more and retry with the same key
SSLHandshakeException: PKIX path building failedA corporate proxy re-signs TLS traffic with a certificate the JVM does not trustImport the proxy's CA into the truststore the JVM uses
invalid_request (400)A POJO was serialised with camelCase keys such as templateIdAnnotate 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 templateUse 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.

FAQ

Common questions

Do I need an iText licence to make PDFs this way?

No. CastPDF is a hosted service you call over HTTPS, so no PDF library is linked into your application. You pay per document under your CastPDF plan instead of licensing a library.

Is there a Maven or Gradle dependency for CastPDF?

No, and you do not need one. The JDK has included java.net.http.HttpClient since Java 11, and Jackson or any other JSON library builds the body. The classes on this page compile with those two pieces alone.

How do I return the PDF from a Spring Boot controller?

Call the API in a service, then return a ResponseEntity of the bytes with the content type application/pdf and a Content-Disposition header. Spring RestClient or WebClient work as well as the JDK client. The API key stays in your server configuration.

Does my HTML have to be valid XHTML?

No. Unlike Flying Saucer and its descendants, CastPDF renders with Chromium, which accepts ordinary HTML5. Unclosed tags and modern CSS such as flexbox or grid render just as they do in a browser.

Can I use the same code from Kotlin or Scala?

Yes. Both run on the JVM and can call java.net.http.HttpClient directly. Kotlin teams often use Ktor client or OkHttp instead, and the request is the same JSON POST either way.

Can an Android app call the PDF API directly?

It should not, because anyone can extract a key from an app package. Let the app ask your backend for the document, and have the backend call CastPDF with the key. Returning a signed link to the app works well.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.