A Spring Boot PDF endpoint has two distinct jobs: render trusted application data into HTML, then return the PDF produced from that HTML. Keeping those steps explicit makes it easier to test templates, protect invoice access, and change delivery from a direct download to an async job later.
This example uses Java 17 or newer, Spring Boot 4.1.1, Thymeleaf, and the official com.relaypdf:relaypdf SDK. It serves a fixed synthetic invoice at /invoices/1042/pdf. Download the example projects, fixtures, and results.
Build and run
The archive contains java/pom.xml, the application class, and src/main/resources/templates/invoice.html. Spring Boot's system requirements document its Java and build-tool requirements; the project pins the framework version so a later default does not silently change the example.
Authenticate with npx @relaypdf/cli setup --env .env, approve the browser login, and load the generated environment. Then run:
set -a
. ./.env
set +a
mvn -f java/pom.xml package
java -jar java/target/invoice-demo-1.0.0.jar --server.address=127.0.0.1 --server.port=5081
Download the example invoice:
curl --fail http://127.0.0.1:5081/invoices/1042/pdf --output invoice-1042.pdf
No browser binary is installed in the Spring application. The SDK makes an HTTP request to the PDF API. Keep RELAYPDF_API_KEY in the server environment, and use RELAYPDF_BASE_URL only when you intentionally want another API deployment.
Render HTML with a template engine
The HTML template uses th:text for the customer name. Thymeleaf escapes that value before it becomes HTML, which prevents ordinary customer data from becoming markup. Prefer escaped template expressions for names, descriptions, and addresses. Raw HTML insertion should be a separate, deliberate capability with its own trust boundary.
The invoice includes a UTF-8 declaration, A4 page dimensions, margins, and a table with rows that should remain intact where possible. Store these print rules with the template. A responsive screen layout may use overflow containers and narrow columns that are inappropriate for pagination.
Here is the complete application:
package com.relaypdf.example;
import com.relaypdf.BinaryResult;
import com.relaypdf.RelayPDF;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.util.Map;
@SpringBootApplication
public class Application {
public static void main(String[] args) { SpringApplication.run(Application.class, args); }
@Bean RelayPDF relayPdf() {
String key = System.getenv("RELAYPDF_API_KEY");
if (key == null || key.isBlank()) throw new IllegalStateException("Run relaypdf setup first");
String base = System.getenv("RELAYPDF_BASE_URL");
return base == null ? new RelayPDF(key) : new RelayPDF(key, base);
}
@RestController
static class InvoiceController {
private final RelayPDF client;
private final TemplateEngine templates;
InvoiceController(RelayPDF client, TemplateEngine templates) { this.client=client; this.templates=templates; }
@GetMapping(value="/invoices/1042/pdf", produces=MediaType.APPLICATION_PDF_VALUE)
ResponseEntity<byte[]> invoice() {
// Fixed synthetic record; authorize the requested record in a real application.
Context context = new Context();
context.setVariable("customer", "Example Workshop");
String html = templates.process("invoice", context);
try {
var result = client.pdf.fromHtml(html, Map.of("response","binary","filename","invoice-1042.pdf"));
if (!(result instanceof BinaryResult pdf)) throw new IllegalStateException("Unexpected response kind");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\"invoice-1042.pdf\"")
.header(HttpHeaders.CACHE_CONTROL,"private, no-store").body(pdf.bytes());
} catch (RuntimeException error) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,"PDF generation failed");
}
}
}
}
Decide how the request should behave under failure
The controller returns PDF bytes with an explicit attachment filename and disables shared caching. Upstream runtime failures become a generic gateway error; document content and the API key are never placed in the response.
This is a synchronous example. Set HTTP and reverse-proxy timeouts intentionally in the application where you use it. A servlet timeout does not necessarily cancel work already accepted by the PDF service. For large reports, persist an async job ID and let the browser fetch status through your own application.
Do not add an unrestricted retry around the controller. If a network connection drops after the request has been accepted, a second submission could produce duplicate work. The batch processing example uses stable request identities and local progress records to handle this situation.
Authorize the record before generating it
The route intentionally exposes only a fixed demonstration invoice. When you replace it with /invoices/{id}/pdf, load the invoice using both the ID and the authenticated account. Restricting who can view the HTML page is insufficient if the download route loads the invoice independently.
For templates shared across many services, consider a published Handlebars template on RelayPDF. For a Spring application already using Thymeleaf, rendering HTML locally may be easier to maintain. Both approaches ultimately submit document inputs to the same HTML-to-PDF API.
Validate the document as part of delivery
Open the returned PDF and check the customer, total, and page dimensions. Test a long line item and enough rows to create a second page. Confirm that the table header repeats and the totals block remains readable.
Use searchable-text assertions for essential labels, page-count checks for unexpected pagination, and raster comparisons for layout changes. The CI tutorial includes those checks with an intentionally broken fixture. The Java SDK reference covers merge, conversion, async jobs, and other document operations beyond this endpoint.
The supplied project was compiled and its download endpoint exercised against staging on September 7, 2026. It returned HTTP 200 and passed three invoice-text checks. Inspect the generated PDF and application check results.