How to Add Peppol Validation to Your CI/CD Pipeline
Why validate invoices in CI/CD
If your application generates e-invoices, a validation step in your build pipeline catches errors before they reach production. A missing mandatory field, an invalid tax calculation, or a malformed XML structure will fail Peppol validation at the Access Point and get rejected. Catching these issues in CI saves time and prevents delivery failures.
The Peppol Validator API is free, requires no signup or API key, and supports both UBL and CII invoice formats.
The API
The Peppol Validator exposes a simple REST endpoint for invoice validation.
Validate a single file
curl -X POST https://peppolvalidator.com/api/validate \
-F "files=@invoice.xml"The API accepts multipart/form-data with one or more files. It returns a session ID and redirects to a results page. For CI/CD, you will want to poll the status endpoint until validation completes.
Full validation flow
The validation is asynchronous. Here is the complete flow:
- Upload your invoice(s) via
POST /api/validate - Poll the session status via
GET /api/status/{sessionId}until all files havestatus: "done" - Fetch individual results via
GET /api/results/{sessionId}/{fileId}
Response format
The status endpoint returns:
{
"sessionId": "abc-123",
"files": [
{
"fileId": "file-001",
"filename": "invoice.xml",
"status": "done"
}
]
}Each result contains:
{
"valid": false,
"errors": [
{
"id": "BR-01",
"text": "An Invoice shall have a Specification identifier.",
"severity": "error",
"location": "/Invoice"
}
],
"warnings": [],
"errorCount": 1,
"warningCount": 0
}GitHub Actions example
Here is a complete GitHub Actions workflow that validates sample invoices on every push:
name: Validate Peppol Invoices
on:
push:
paths:
- 'invoices/**'
- 'templates/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate invoices
run: |
set -e
# Upload all XML files in the invoices directory
FILES=""
for f in invoices/*.xml; do
[ -f "$f" ] && FILES="$FILES -F files=@$f"
done
if [ -z "$FILES" ]; then
echo "No invoice files found"
exit 0
fi
# Upload and capture the redirect URL
RESPONSE=$(curl -s -w "\n%{redirect_url}" \
-X POST https://peppolvalidator.com/api/validate \
$FILES)
SESSION_URL=$(echo "$RESPONSE" | tail -1)
SESSION_ID=$(echo "$SESSION_URL" | grep -oP 'r/\K[^/]+')
echo "Session: $SESSION_ID"
# Poll until all files are done
for i in $(seq 1 30); do
STATUS=$(curl -s "https://peppolvalidator.com/api/status/$SESSION_ID")
PENDING=$(echo "$STATUS" | jq '[.files[] | select(.status != "done")] | length')
if [ "$PENDING" -eq 0 ]; then
break
fi
echo "Waiting for validation... ($PENDING files pending)"
sleep 2
done
# Check results
FAILED=0
for FILE_ID in $(echo "$STATUS" | jq -r '.files[].fileId'); do
RESULT=$(curl -s "https://peppolvalidator.com/api/results/$SESSION_ID/$FILE_ID")
FILENAME=$(echo "$STATUS" | jq -r ".files[] | select(.fileId==\"$FILE_ID\") | .filename")
ERRORS=$(echo "$RESULT" | jq '.errorCount')
WARNINGS=$(echo "$RESULT" | jq '.warningCount')
if [ "$ERRORS" -gt 0 ]; then
echo "::error::$FILENAME: $ERRORS error(s), $WARNINGS warning(s)"
echo "$RESULT" | jq -r '.errors[] | " - [\(.id)] \(.text)"'
FAILED=1
else
echo "::notice::$FILENAME: valid ($WARNINGS warning(s))"
fi
done
exit $FAILEDThis workflow:
- Triggers when invoice files change
- Uploads all XML files from the
invoices/directory - Polls until validation completes
- Fails the build if any invoice has errors
- Shows detailed error messages in the GitHub Actions log
GitLab CI example
validate-invoices:
stage: test
image: alpine:latest
before_script:
- apk add --no-cache curl jq
script:
- |
FILES=""
for f in invoices/*.xml; do
[ -f "$f" ] && FILES="$FILES -F files=@$f"
done
RESPONSE=$(curl -s -w "\n%{redirect_url}" \
-X POST https://peppolvalidator.com/api/validate $FILES)
SESSION_ID=$(echo "$RESPONSE" | tail -1 | grep -oP 'r/\K[^/]+')
# Poll for completion
for i in $(seq 1 30); do
STATUS=$(curl -s "https://peppolvalidator.com/api/status/$SESSION_ID")
PENDING=$(echo "$STATUS" | jq '[.files[] | select(.status != "done")] | length')
[ "$PENDING" -eq 0 ] && break
sleep 2
done
# Check results
echo "$STATUS" | jq -r '.files[].fileId' | while read FILE_ID; do
RESULT=$(curl -s "https://peppolvalidator.com/api/results/$SESSION_ID/$FILE_ID")
ERRORS=$(echo "$RESULT" | jq '.errorCount')
[ "$ERRORS" -gt 0 ] && exit 1
done
only:
changes:
- invoices/**Writing a validation script
For more control, wrap the API calls in a script. Here is a Node.js example:
const BASE_URL = "https://peppolvalidator.com";
async function validateInvoice(filePath: string): Promise<boolean> {
const form = new FormData();
form.append("files", new Blob([readFileSync(filePath)]), basename(filePath));
const uploadRes = await fetch(`${BASE_URL}/api/validate`, {
method: "POST",
body: form,
redirect: "manual",
});
const sessionUrl = uploadRes.headers.get("location") ?? "";
const sessionId = sessionUrl.split("/r/")[1];
// Poll for completion
let status;
for (let i = 0; i < 30; i++) {
const res = await fetch(`${BASE_URL}/api/status/${sessionId}`);
status = await res.json();
const pending = status.files.filter((f: { status: string }) => f.status !== "done");
if (pending.length === 0) break;
await new Promise((r) => setTimeout(r, 2000));
}
// Check results
let allValid = true;
for (const file of status.files) {
const res = await fetch(`${BASE_URL}/api/results/${sessionId}/${file.fileId}`);
const result = await res.json();
if (result.errorCount > 0) {
console.error(`${file.filename}: ${result.errorCount} error(s)`);
for (const err of result.errors) {
console.error(` [${err.id}] ${err.text}`);
}
allValid = false;
}
}
return allValid;
}Handling validation results
Errors vs warnings
The Peppol Validator distinguishes between errors and warnings:
- Errors mean the invoice violates a mandatory rule and will be rejected by the Peppol network. Your CI pipeline should fail on errors.
- Warnings indicate best-practice violations or informational messages. They should not block the build, but you may want to log them.
Common CI failures
These are the rules that most commonly fail in CI pipelines:
| Rule | Issue | Fix |
|---|---|---|
BR-01 |
Missing specification identifier | Add cbc:CustomizationID |
BR-CO-10 |
Line amounts do not sum to total | Check your calculation logic |
BR-S-08 |
VAT category code mismatch | Ensure tax category matches line items |
PEPPOL-EN16931-R004 |
Specification identifier not matching BIS 3.0 | Use the correct CustomizationID value |
See the full validation error reference for details on each rule.
Best practices
Test with real-world samples. Do not just validate a single template. Include edge cases: credit notes, zero-amount lines, multiple tax rates, international addresses.
Pin your test invoices. If your application generates invoices dynamically, save known-good samples as test fixtures. This makes CI deterministic and fast.
Validate early. Run validation on pull requests, not just on the main branch. Catch issues before they merge.
Monitor for rule updates. Peppol and EN16931 rules are updated periodically (typically twice a year). A previously valid invoice might fail after a rule update. Keep an eye on OpenPeppol release notes.
The Peppol Validator API is free and requires no authentication. Start integrating validation into your pipeline today.
Ready to validate your Peppol invoices?
Drag and drop your UBL XML files and get instant validation against BIS Billing 3.0 and EN16931 rules. Free, no signup required.
Validate now