faktorei

Docs

Run the container. Then read the stylesheets.

Everything the container does is reachable from one image and one port. The stylesheets, the semantic contract and the parameter surface are Apache-2.0 and documented in the open repository, linked at the end.

Run the container

No signup, no license, no network calls out. The image is public, multi-arch (amd64, arm64), runs as a non-root user on a read-only root filesystem, and needs only a writable /tmp.

docker run -d --name faktorei --read-only --tmpfs /tmp -p 8080:8080 ghcr.io/faktorei/render:2026.05

Without a license file the container runs in evaluation mode: every feature works, output carries a watermark, and the shared allowance is 500 documents per 24 hours across /render and /batch. A license file removes both; nothing else changes.

CheckReturns
GET /health{"status":"ok","license":"evaluation"} — license is evaluation, ok, expiring, expired-grace or refused
GET /versionimage version and the spec cycle it validates against (spec_release, currently 2026.05)
GET /metricsPrometheus text: renders by syntax/profile/outcome, latency, faktorei_build_info

HTTP API

Three routes, all POST, all taking the document as the raw request body and answering with the finished file. The syntax (UBL invoice, UBL credit note, CII) is detected from the document; the national profile is detected from BT-24 unless you force one.

POST /render — one document to PDF/A-3b

curl -sS --data-binary @invoice.xml "http://localhost:8080/render?lang=de" -o invoice.pdf
Query parameterValuesDefault
langen · de · fr · nl (English, German, French, Dutch); also selects the number formaten
profileauto · generic · xrechnung · pint-a-nzauto (from BT-24)
pdfafalse to emit plain PDF instead of PDF/A-3bPDF/A-3b

Body: the XML, any encoding declared in the prolog. Response: application/pdf. Output is byte-deterministic — the same input and options produce the same bytes on every run and every architecture, because every date in the PDF is pinned to the invoice's issue date.

POST /render/facturx — hybrid Factur-X / ZUGFeRD

curl -sS --data-binary @invoice-cii.xml http://localhost:8080/render/facturx -o invoice-facturx.pdf

Takes a CII document, renders it, and embeds the original XML byte-exact as factur-x.xml with the Factur-X XMP extension schema. The conformance level (MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG) is read from the document's guideline identifier; profile forces the presentation profile as for /render. Always PDF/A-3b.

POST /batch — zip in, zip out

zip -j batch.zip in/*.xml
curl -sS --data-binary @batch.zip "http://localhost:8080/batch?lang=en" -o out.zip
unzip -l out.zip        # one PDF per input + report.json

The body is a zip of XML documents (entry names are flattened to their basename). The response is a zip with one PDF per input and a report.json listing every document with ok or the error that stopped it — a failing document never stops the others. Query lang and profile set job-wide defaults; add facturx=true to package every CII document as a hybrid. Put a manifest.jsonl in the zip to set lang, profile and the output filename per document (format: batch manifest). Inputs are streamed to disk, never buffered in memory, so a 2 GB batch is fine.

Status codes

CodeMeaning
400Empty body, not an EN 16931 document, or an invalid manifest (the message names the line).
413Document or batch over a configured ceiling (see configuration). The message names the limit.
422Glyph coverage: the document contains characters the embedded fonts cannot draw. The engine refuses rather than print placeholder boxes (policy).
429Evaluation allowance exhausted, with Retry-After. A batch larger than the remaining allowance is refused at submission, not mid-way.

Errors are text/plain with one line that says what to change. The engine logs nothing about document content.

Install a license

A license is one signed file, faktorei.lic, verified offline with a public key baked into the image. Mount it read-only at /app/license/faktorei.lic (or point FAKTOREI_LICENSE at it). The container watches the file: replacing it at renewal needs no restart.

docker run -d --name faktorei --read-only --tmpfs /tmp -p 8080:8080 \
  -v "$PWD/faktorei.lic:/app/license/faktorei.lic:ro" ghcr.io/faktorei/render:2026.05
curl -s localhost:8080/health      # {"status":"ok","license":"ok"}

What happens at expiry. Thirty days before, /health reports expiring. After expiry there is a 30-day grace period (expired-grace, full function), then the container degrades to evaluation mode — watermark and daily allowance — and keeps rendering. It never hard-stops. A file that fails signature verification is refused: at startup that is a hard error (the container exits, so a broken deploy is visible); at runtime a hot-swapped bad file is logged and the previous valid license stays in effect. Licenses issued since September 2026 require image 2025.11.5 or later.

Licenses are per deployment, not per instance: run as many replicas as you like with the same file. Pricing and the license agreement →

Configuration

Everything is an environment variable with a safe default. Ceilings are enforced before any transform starts and answer with 413.

VariableDefaultWhat it does
FAKTOREI_PORT8080HTTP listen port.
FAKTOREI_WORKERScoresParallel renders per process, for /batch and the CLI. Set to the replica's physical cores; rendering scales out with replicas better than up with cores (why).
FAKTOREI_LICENSE/app/license/faktorei.licPath of the license file.
FAKTOREI_MAX_DOC_MB10Largest single document, in MB.
FAKTOREI_MAX_LINES10000Most invoice lines in one document. The 500-line corpus fixture renders in about 62 MB of heap; size the JVM for your own ceiling.
FAKTOREI_MAX_ZIP_DOCS10000Most documents in one /batch zip.
FAKTOREI_MAX_BATCH_MB2048Largest /batch body, inflated, in MB.
FAKTOREI_MAX_BATCH_DOCS100000Most documents in one CLI batch run.
FAKTOREI_BATCH_TMP/tmpScratch directory for /batch staging. Point it at a large volume for big jobs.
FAKTOREI_PRICING_URLfaktorei.dev/pricingThe URL printed in the evaluation watermark.

Command line

The same image runs as a one-shot CLI over mounted directories — the HTTP service and the CLI share one render core, and CI asserts their output is byte-identical. This is the path for archive migrations and nightly runs.

# same image, same core — no server needed
docker run --rm -v "$PWD:/w" ghcr.io/faktorei/render:2026.05 render  /w/invoice.xml /w/invoice.pdf --lang de
docker run --rm -v "$PWD:/w" ghcr.io/faktorei/render:2026.05 facturx /w/invoice-cii.xml /w/invoice-facturx.pdf
docker run --rm -v "$PWD:/w" ghcr.io/faktorei/render:2026.05 batch   /w/in /w/out --workers 4 --manifest /w/in/manifest.jsonl --resume
CommandDoes
render <in> <out> [--lang L] [--profile P] [--pdfa] [--emit semantic|fo|pdf]One document. --emit stops at an intermediate stage for debugging.
facturx <cii.xml> <out.pdf> [--lang L] [--profile P]One hybrid.
batch <in-dir> <out-dir> [--workers N] [--lang L] [--profile P] [--facturx] [--manifest m.jsonl] [--resume]Every .xml in a directory, concurrently; writes each PDF atomically and a report.json. --resume continues an interrupted run from its checkpoint and refuses if the manifest changed.
serveThe HTTP service (the image's default command).
healthcheckExit 0 if the service answers; used by the image's own HEALTHCHECK.

Exit codes: 0 all documents rendered · 1 some failed (see report.json) · 2 usage · 4 refused before starting (ceiling, allowance, or a changed manifest on --resume).

Deploy

The container is stateless: no database, no outbound network, nothing written outside /tmp. Run it as you run any stateless service. Reference compose:

services:
  faktorei:
    image: ghcr.io/faktorei/render:2026.05
    ports: ["8080:8080"]
    read_only: true
    tmpfs: ["/tmp"]
    environment:
      FAKTOREI_WORKERS: "4"          # = the replica's cores; scale out, not up
    volumes:
      - ./faktorei.lic:/app/license/faktorei.lic:ro
    healthcheck:
      test: ["CMD", "/app/faktorei-engine/bin/faktorei-engine", "healthcheck"]
      interval: 30s
      timeout: 5s
      start_period: 20s
      retries: 3
  • Sizing. Several 2–4 vCPU replicas behind a load balancer beat one large node; the benchmark shows the curve and the per-core figure to size from.
  • Air-gapped. Nothing phones home: not the license check, not the fonts, not the stylesheets. Pull the image once, carry it in.
  • Pinning. :2026.05 is the current spec cycle and moves to each patch within it; :2026.05.N pins a build. There is deliberately no :latest, because crossing a spec cycle should be your decision.
  • Updates. A new cycle ships within three weeks of each Peppol / EN 16931 release and of each national profile's release; the spec_release in /version says which rules an image validates against.