Why Java teams hit Peppol
The pressure arrives from three directions at once:
Mandates. Belgium requires structured B2B e-invoicing from January 2026, and Peppol BIS 3.0 is the practical route. The Netherlands, Norway, Denmark, Sweden, and Finland have run public-sector invoicing on Peppol for years, with NLCIUS and EHF as their national profiles. Beyond Europe, PINT is extending the same model to Australia, New Zealand, Singapore, Japan, and Malaysia. If your Java application bills customers in any of these markets, UBL is not optional.
Buyer requirements ahead of the law. Large buyers routinely require Peppol delivery before their government does, because it removes manual handling on their side. That request usually reaches an engineering team as a ticket with a deadline and a CustomizationID nobody recognizes.
The German connection. Plenty of Java shops already produce ZUGFeRD or XRechnung for domestic customers, which are CII-based, and then discover their Belgian or Dutch customers want UBL. Two syntaxes, one semantic model. The conversion section covers that bridge, and if you have not built the German side yet, the companion guides to ZUGFeRD in Java and XRechnung in Java walk it end to end.
The technical work is deeper than "generate some XML." A compliant Peppol invoice must satisfy roughly 200 EN 16931 business rules, the Peppol BIS 3.0 overlay on top of them, and the correct CustomizationID and ProfileID declaration, which is what tells every downstream system which rule set applies. Getting that identifier wrong is the single most common cause of access-point rejection; the Peppol BIS CustomizationID reference spells out the exact values.
Document layer and transport layer
Worth stating plainly, because it determines what you build and what you buy:
| Layer | Question it answers | Who provides it |
| Document layer | Is this a valid Peppol BIS 3.0 invoice? | This API, or a library stack you maintain |
| Transport layer | How does it reach the recipient? | A certified access point (AS4, SMP lookup) |
Java teams typically already have the transport half handled, whether through a commercial access point provider or a self-hosted AS4 implementation. Nothing here changes that. What changes is where the compliance work lives: instead of maintaining Schematron artifacts and CIUS rule sets in your build, you make one HTTP call and hand your access point a document that has already been checked. The Peppol integration overview covers how the layers fit together.
Setting up the Java client
All examples use java.net.http.HttpClient, part of the JDK since Java 11, written here with Java 17+ text blocks. The only third-party dependency is Jackson, and only where a response needs parsing.
Sign up for a free InvoiceXML account and you receive 100 free credits with the 30-day trial, no credit card required. Keep the API key in an environment variable or your secrets manager, never in source:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
String apiKey = System.getenv("INVOICEXML_API_KEY");
String baseUrl = "https://api.invoicexml.com";
The client is immutable and thread-safe: create it once and share it across the application.
Create Peppol UBL invoices in Java
Send the invoice as JSON. Totals and the VAT breakdown are computed from the line items, so your code never does invoice arithmetic that a rule might later disagree with.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
String payload = """
{
"invoice": {
"invoiceNumber": "INV-2026-4471",
"issueDate": "2026-07-31",
"currency": "EUR",
"seller": {
"name": "Kempen Industriele Techniek BV",
"vatIdentifier": "BE0899123456",
"postalAddress": {
"line1": "Havenlaan 118",
"city": "Antwerpen",
"postCode": "2030",
"country": "BE"
}
},
"buyer": {
"name": "Maasvlakte Logistiek B.V.",
"postalAddress": {
"line1": "Europaweg 210",
"city": "Rotterdam",
"postCode": "3199 LD",
"country": "NL"
}
},
"paymentDetails": {
"paymentAccountIdentifier": "BE68539007547034"
},
"lines": [
{
"quantity": 40,
"item": { "name": "Conveyor maintenance, July 2026" },
"priceDetails": { "netPrice": 82.50 },
"vatInformation": { "rate": 21 }
}
]
}
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/create/ubl"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
HttpResponse<byte[]> response =
http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IllegalStateException(new String(response.body(), StandardCharsets.UTF_8));
}
Files.write(Path.of("invoice-peppol.xml"), response.body());
That is the entire request. The API computes the totals, builds the VAT breakdown, stamps the Peppol BIS Billing 3.0 CustomizationID, validates against EN 16931 plus the Peppol overlay, and returns UBL 2.1 XML. A 400 response carries the violated rules as structured findings with field paths, which is a permanent condition rather than a transient one: retrying the same payload reproduces it, so treat 4xx as terminal and only retry 5xx and transport errors.
One Java-specific note on the text block above: it is fine for a guide, but in production build the JSON with Jackson or your own record types rather than string interpolation, so that a customer name containing a quote character cannot corrupt the request.
Runnable example: Create.java →
Validate before you submit
The create endpoint already validates what it produces, so this step is about documents from anywhere else: files from an upstream system, invoices received through Peppol, or output from a legacy service you have not migrated. It is also the gate worth putting in front of your access point.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.UUID;
static HttpRequest.BodyPublisher multipartXml(Path file, String boundary)
throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\""
+ file.getFileName() + "\"\r\n"
+ "Content-Type: application/xml\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.write(Files.readAllBytes(file));
out.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
return HttpRequest.BodyPublishers.ofByteArray(out.toByteArray());
}
static JsonNode validateUbl(HttpClient http, String baseUrl, String apiKey, Path xml)
throws IOException, InterruptedException {
String boundary = "----" + UUID.randomUUID();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/validate/ubl"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(multipartXml(xml, boundary))
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
return new ObjectMapper().readTree(response.body());
}
JsonNode result = validateUbl(http, baseUrl, apiKey, Path.of("incoming.xml"));
if (!result.get("valid").asBoolean()) {
for (JsonNode error : result.get("errors")) {
System.out.printf("[%s] %s (%s)%n",
error.get("rule").asText(),
error.get("message").asText(),
error.path("location").asText(""));
}
}
Valid and invalid documents both return HTTP 200, so branch on the valid flag rather than the status code; a non-200 here means the request itself was wrong. Findings carry the rule id, so a failure your Java code logs is the same identifier the recipient's platform would have quoted back at you. The rules that surface most often in Peppol traffic have their own reference pages: PEPPOL-EN16931-R001 (a buyer reference or order reference is required), R010, R020, and CL008.
The production shape is create, validate, then transmit, with the access point call last:
public String issueToPeppol(Object invoice) throws Exception {
byte[] ubl = createUbl(invoice); // /v1/create/ubl
Path staged = Files.write(Files.createTempFile("ubl", ".xml"), ubl);
JsonNode check = validateUbl(http, baseUrl, apiKey, staged);
if (!check.get("valid").asBoolean()) {
throw new InvoiceRejectedException(check.get("errors").toString());
}
return accessPoint.send(ubl); // your existing transport
}
Catching a data error here costs one call. Catching it after transmission costs a rejection at the recipient's access point, a support thread, and a corrected document.
Runnable example: Validate.java →
National CIUS variants
Peppol BIS 3.0 is the baseline; several countries layer a national CIUS on top, tightening rules and adding mandatory fields. On the receiving side you need to do nothing: validation reads the declared CustomizationID and applies the matching overlay. On the sending side, pin the profile with invoice.specificationId:
// Dutch customer: emit NLCIUS instead of plain Peppol BIS 3.0
String payload = """
{
"invoice": {
"specificationId": "urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0",
"invoiceNumber": "INV-2026-4472",
...
}
}
""";
| Market | Profile | Notes |
| Peppol-wide default | Peppol BIS Billing 3.0 | Stamped automatically when nothing is pinned |
| Netherlands | NLCIUS | Tighter reference and identifier requirements |
| Norway | EHF | Long-standing national profile built on BIS |
| Non-EU Peppol markets | PINT | Australia, New Zealand, Singapore, Japan, Malaysia |
| German B2G over Peppol | XRechnung UBL | Leitweg-ID in buyerReference; see the XRechnung guide |
One integration covers all of them, because the difference between markets is a string in the payload rather than a branch in your code.
Convert between UBL and CII
EN 16931 defines one semantic model with two permitted syntaxes: UBL 2.1 (Peppol) and UN/CEFACT CII (ZUGFeRD, Factur-X, XRechnung CII). Java applications serving both German and Peppol markets end up needing both, and conversion is lossless because the underlying model is identical:
static byte[] convert(HttpClient http, String baseUrl, String apiKey,
Path source, String direction) // "ubl/to/cii" or "cii/to/ubl"
throws IOException, InterruptedException {
String boundary = "----" + UUID.randomUUID();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/convert/" + direction))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(multipartXml(source, boundary))
.build();
return http.send(request, HttpResponse.BodyHandlers.ofByteArray()).body();
}
byte[] cii = convert(http, baseUrl, apiKey, Path.of("peppol-invoice.xml"), "ubl/to/cii");
The practical shape this enables: accept whichever syntax arrives, normalize internally, and emit whichever syntax the recipient expects. A German subsidiary sending CII to a Dutch parent that only reads Peppol UBL is one call, not a mapping project.
Render UBL as a readable PDF
UBL is machine-readable and nothing else, which becomes a problem the moment a human needs to approve, archive, or query an invoice. POST /v1/render/ubl/to/pdf returns a formatted PDF of the document:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/render/ubl/to/pdf"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(multipartXml(Path.of("invoice-peppol.xml"), boundary))
.build();
Files.write(Path.of("invoice-preview.pdf"),
http.send(request, HttpResponse.BodyHandlers.ofByteArray()).body());
Useful for approval screens, customer portals, and support tooling. The rendered PDF is a visual representation, not a hybrid: the compliant artifact in a Peppol flow remains the UBL XML.
Runnable example: Render.java →
For invoices arriving from your access point, /v1/extract/json parses UBL, CII, or a hybrid PDF into one normalized JSON shape, so the code that maps into your domain model does not care which syntax the sender used:
JsonNode invoice = extractInvoice(http, baseUrl, apiKey, Path.of("received.xml"));
SupplierInvoice booked = new SupplierInvoice(
invoice.get("invoiceNumber").asText(),
LocalDate.parse(invoice.get("issueDate").asText()),
new BigDecimal(invoice.get("totalAmount").asText()));
The extraction is deterministic: what the supplier declared is what you get. Where an incoming document carries embedded supporting files (delivery notes, timesheets), /v1/extract/attachments returns them as a ZIP. And for the suppliers outside Peppol who still send plain PDFs, /v1/parse/json reads them with AI and returns the same JSON shape plus a confidence object, so your Java code can auto-book confident reads and route the rest to a review queue.
Runnable example: ExtractJson.java →
Spring Boot integration
In Spring Boot 3.2+, wrap the API in a service on RestClient and let the container own configuration and lifecycle:
// application.yml
// invoicexml:
// base-url: https://api.invoicexml.com
// api-key: ${INVOICEXML_API_KEY}
@Service
public class PeppolInvoiceService {
private final RestClient client;
public PeppolInvoiceService(RestClient invoiceXmlClient) {
this.client = invoiceXmlClient;
}
public byte[] createUbl(Object invoice) {
return client.post()
.uri("/v1/create/ubl")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("invoice", invoice))
.retrieve()
.body(byte[].class);
}
public ValidationResult validate(byte[] ubl) {
MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
form.add("file", new ByteArrayResource(ubl) {
@Override
public String getFilename() { return "invoice.xml"; }
});
return client.post()
.uri("/v1/validate/ubl")
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(form)
.retrieve()
.body(ValidationResult.class);
}
}
Spring's converters handle multipart encoding, so the hand-rolled helper from the plain Java examples is unnecessary here. For resilience, wrap the calls with Resilience4j and retry only 5xx and transport failures; a 4xx is a data problem that will not fix itself on the second attempt.
Java Peppol libraries vs REST API
Java deserves a straight answer here, because unlike Node.js or PHP it has real open-source Peppol tooling: ph-ubl for the UBL object model, phive for validation, peppol-commons for identifier handling, and Saxon-HE as a genuine XSLT 2.0 processor so Schematron runs in-process. These are capable, well-maintained projects.
What they cost is ownership, and it is recurring. The Peppol validation artifacts update quarterly from OpenPeppol, each national CIUS moves on its own calendar, and EN 16931 itself revises. Every cycle is the same loop: watch the announcement, wait for the library release, bump the version, retest your invoice corpus, redeploy every service that issues invoices. Miss the window between an effective date and your deployment and you are shipping documents against a stale rule set, which surfaces as rejections at recipients rather than as a failing build. Add PDF rendering, unstructured intake, and CII bridging and the stack widens further.
The API is a complete compliance service. The EN 16931 rules, the Peppol overlay, and every CIUS stay current server-side, live on their effective dates, with no dependency to bump, nothing to monitor, and no redeploy on your side. One integration covers creation, validation, conversion, rendering, and intake across UBL, CII, ZUGFeRD, Factur-X, and XRechnung; deep e-invoicing expertise and professional support stand behind it; and missing capabilities get built when customers need them. Your build carries zero compliance dependencies.
The deciding question is who owns the moving target. With a library stack, the regulator's calendar dictates your release schedule indefinitely. With the API, your integration is finished the day it works, because keeping it compliant is our job rather than a recurring line item in your sprint planning.
On data handling: every document is processed statelessly, in memory, and purged when the response is delivered. Nothing is written to disk, nothing is logged, and no invoice data trains any model, so the VAT numbers, IBANs, and trading relationships flowing through your Java application never persist outside the single HTTP call.
Complete endpoint reference for Java
| Operation | Endpoint | Input | Output |
| Create Peppol UBL | POST /v1/create/ubl | JSON | UBL 2.1 XML |
| Validate UBL | POST /v1/validate/ubl | UBL XML | Validation JSON |
| Convert UBL to CII | POST /v1/convert/ubl/to/cii | UBL XML | CII XML |
| Convert CII to UBL | POST /v1/convert/cii/to/ubl | CII XML | UBL XML |
| Render UBL as PDF | POST /v1/render/ubl/to/pdf | UBL XML | PDF binary |
| Extract as JSON | POST /v1/extract/json | UBL/CII XML or hybrid PDF | Structured JSON |
| Extract attachments | POST /v1/extract/attachments | UBL/CII XML or hybrid PDF | ZIP archive |
| Parse PDF with AI | POST /v1/parse/json | Invoice PDF (typed or scanned) | Structured JSON + confidence |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi | Interactive API explorer: api.invoicexml.com/v1/scalar
Get started
The whole integration is the client setup plus the two calls that matter: create, then validate before you hand the document to your access point. Everything else in this guide is the same pattern with a different URL.
The create, validate, extract, and render operations are on GitHub as standalone Java classes (OkHttp based, each with its own main method). Clone the repo, set INVOICEXML_API_KEY, and you have a working Peppol integration in minutes:
InvoiceXML/ubl-api-examples/java →
Create a free InvoiceXML account → get 100 free credits with the 30-day trial, no credit card required.
Related resources: