The B2B mandate runs on ZUGFeRD. Receiving e-invoices has been mandatory for all German businesses since January 2025: a buyer can no longer refuse a compliant e-invoice, and an ordinary PDF no longer counts as one. Issuing becomes mandatory from January 2027 for companies above EUR 800k turnover and from January 2028 for everyone else. ZUGFeRD profiles EN 16931 and above qualify; the MINIMUM and BASIC WL profiles are booking aids and do not. The current version is ZUGFeRD 2.3, identical to Factur-X 1.0.7, and the embedded attachment is named factur-x.xml in both brandings.
The B2G side runs on XRechnung. Invoices to German federal authorities have had to be XRechnung since November 2020, with state and municipal buyers following. XRechnung is Germany's national CIUS of EN 16931, maintained by KoSIT, and version 3.0 has been in force since February 2024. It adds roughly two dozen German business rules (the BR-DE codes), makes several optional EN 16931 fields mandatory, and is submitted through portals such as ZRE and OZG-RE that enforce the KoSIT rule set on acceptance.
GoBD archiving touches both. German bookkeeping rules require invoices to be archived as received, immutable and machine-evaluable, for the statutory retention period. ZUGFeRD makes that convenient (one PDF/A-3 file is both the visual record and the data), while XRechnung archives as the XML itself, which is one reason a rendering step for human review matters on the B2G side.
The engineering under both is the deep part: PDF/A-3 containers with embedded fonts and XMP profile declarations, CII XML satisfying about 200 EN 16931 business rules, German rule overlays on top, and Schematron to prove all of it. The technical overview of ZUGFeRD automation dissects the layers if you want the full picture.
A decision rule that covers nearly every case:
| Your customer | Format | Deliverable | Endpoint |
| German business (B2B) | ZUGFeRD EN 16931 | Hybrid PDF/A-3 | /v1/create/zugferd |
| Federal or state authority (B2G) | XRechnung 3.0 | Pure XML + Leitweg-ID | /v1/create/xrechnung |
| French business | Factur-X | Hybrid PDF/A-3 | /v1/create/facturx |
| Peppol network recipient | Peppol BIS 3.0 | UBL XML | /v1/create/ubl |
Because every row consumes the same invoice hash, "supporting all of them" in Ruby means a case statement over endpoints, not four integrations. This guide works the first two rows; the French and Peppol rows have guides of their own.
The Ruby gem situation
Search RubyGems for ZUGFeRD or XRechnung and the honest summary is: nothing you would put in a production Gemfile. The handful of hits date from the ZUGFeRD 1.0 era and have been dormant for years, predating ZUGFeRD 2.x, XRechnung 3.0, and most of the rules that matter today. Building the stack yourself runs into three walls, in ascending order of severity:
XML generation is the tractable one. Nokogiri can build the CII or UBL document, but the EN 16931 model is roughly 160 business terms with around 200 interlocking rules, plus the BR-DE overlay for XRechnung. Well-formed is easy; compliant is the project.
PDF/A-3 is the expensive one. For ZUGFeRD you need archival-grade PDF/A-3 with an embedded attachment and exact XMP metadata. Prawn does not produce PDF/A; HexaPDF can embed files and target PDF/A conformance but is AGPL-licensed for commercial use, and converting an existing invoice PDF into genuinely conformant PDF/A-3 (fonts, transparency, color spaces) is unsolved in Ruby.
Validation is the wall. The official rule sets, EN 16931 Schematron and the KoSIT artifacts for XRechnung, compile to XSLT 2.0. Ruby's XML stack, Nokogiri over libxslt, implements XSLT 1.0 and stops there; there is no Saxon for Ruby. The KoSIT validator itself is a Java application, so Ruby teams determined to validate locally end up managing a JVM subprocess and parsing its XML reports, which is a service dependency with extra steps.
The conclusion this guide draws: keep the compliance machinery server-side behind a REST API, and let your Ruby code do what Ruby is good at, moving data and orchestrating. Every operation below is one HTTP call.
Client setup with Net::HTTP
The standard library covers the whole integration, multipart uploads included. Nothing in this guide requires a Gemfile until the Rails section.
Sign up for a free InvoiceXML account and you will receive 100 free credits, no credit card required. Load the key from the environment or your secrets manager, never from source:
require "net/http"
require "json"
require "uri"
BASE_URL = "https://api.invoicexml.com"
API_KEY = ENV.fetch("INVOICEXML_API_KEY")
def api_http(uri)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 120
http
end
Create ZUGFeRD invoices in Ruby
The payload is a plain hash. Send net prices, quantities, and rates; totals and the per-rate VAT breakdown are computed server-side, and the result is validated against EN 16931 before you ever see it. The sample mixes Germany's 19% standard and 7% reduced rates to show the breakdown doing real work:
invoice = {
invoice: {
invoiceNumber: "RE-2026-0347",
issueDate: "2026-07-28",
currency: "EUR",
seller: {
name: "Rheinwerk Digital GmbH",
vatIdentifier: "DE298765432",
postalAddress: {
line1: "Hansaring 27",
city: "Köln",
postCode: "50670",
country: "DE"
}
},
buyer: {
name: "Nordlicht Handel GmbH",
postalAddress: {
line1: "Alsterufer 3",
city: "Hamburg",
postCode: "20354",
country: "DE"
}
},
paymentDetails: {
paymentAccountIdentifier: "DE44500105175407324931"
},
lines: [
{
quantity: 40,
item: { name: "Managed Hosting Juli 2026" },
priceDetails: { netPrice: 89.00 },
vatInformation: { rate: 19 }
},
{
quantity: 25,
item: { name: "Technisches Handbuch (Druckausgabe)" },
priceDetails: { netPrice: 24.00 },
vatInformation: { rate: 7 }
}
]
}
}
uri = URI("#{BASE_URL}/v1/create/zugferd")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(invoice)
response = api_http(uri).request(request)
raise response.body unless response.is_a?(Net::HTTPSuccess)
File.binwrite("rechnung-zugferd.pdf", response.body)
The response is a complete ZUGFeRD 2.3 document: PDF/A-3 container, embedded factur-x.xml, XMP metadata declaring the EN 16931 profile, checked against the official Schematron. If the data cannot produce a compliant invoice, the API answers HTTP 400 with the violated rules as structured findings, so an invalid document never leaves your system.
Validate ZUGFeRD invoices in Ruby
Validate before invoices go out, and validate what suppliers send in. The endpoint extracts the embedded XML, detects the declared profile from BT-24, and runs the matching XSD plus Schematron rules. Where the Java and C# guides hand-roll a multipart body builder, Ruby's Net::HTTP has multipart built in via set_form:
def validate_zugferd(path)
uri = URI("#{BASE_URL}/v1/validate/zugferd")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
File.open(path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
JSON.parse(api_http(uri).request(request).body)
end
end
report = validate_zugferd("rechnung-zugferd.pdf")
if report["valid"]
puts "Valid ZUGFeRD, profile #{report.dig("data", "profile")}"
else
report["errors"].each do |finding|
puts "[#{finding["rule"]}] #{finding["message"]}"
puts " fields: #{Array(finding["fields"]).join(", ")}"
end
end
Both valid and invalid invoices answer HTTP 200; the verdict lives in valid, and non-2xx statuses mean transport problems (bad key, unreadable upload). Findings carry the rule id, a plain-language message, business term codes, and field paths, ready to surface in a UI or forward to a supplier as-is.
Create XRechnung invoices in Ruby
Same hash shape, different endpoint, two additions that XRechnung enforces and base EN 16931 does not:
buyerReference carries the Leitweg-ID. This maps to BT-10 and is checked by rule BR-DE-15. The public sector buyer assigns it during onboarding; store it per buyer and inject it into every request. It is routing infrastructure, not invoice data, so no source document will ever contain it.
Seller contact and electronic addresses are mandatory. XRechnung requires the full seller.contact group (name, phone, email) and electronic addresses for both parties.
xrechnung = {
invoice: {
invoiceNumber: "RE-2026-0348",
issueDate: "2026-07-28",
dueDate: "2026-08-27",
currency: "EUR",
buyerReference: "05315-00023-11",
seller: {
name: "Rheinwerk Digital GmbH",
vatIdentifier: "DE298765432",
postalAddress: {
line1: "Hansaring 27",
city: "Köln",
postCode: "50670",
country: "DE"
},
contact: {
name: "Julia Brandt",
phone: "+49 221 9876543",
email: "[email protected]"
},
electronicAddress: { identifier: "DE298765432", schemeId: "0204" }
},
buyer: {
name: "Stadt Köln, Amt für Gebäudewirtschaft",
postalAddress: {
line1: "Willy-Brandt-Platz 2",
city: "Köln",
postCode: "50679",
country: "DE"
},
electronicAddress: { identifier: "05315-00023-11", schemeId: "0204" }
},
paymentDetails: {
paymentMeansCode: 58,
paymentAccountIdentifier: "DE44500105175407324931"
},
lines: [
{
quantity: 120,
unitCode: "HUR",
item: { name: "Wartung Gebäudeleittechnik" },
priceDetails: { netPrice: 95.00 },
vatInformation: { rate: 19, categoryCode: "S" }
}
]
}
}
uri = URI("#{BASE_URL}/v1/create/xrechnung")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(xrechnung)
response = api_http(uri).request(request)
raise response.body unless response.is_a?(Net::HTTPSuccess)
File.binwrite("rechnung-xrechnung.xml", response.body)
The response is XRechnung 3.0 in CII syntax, already checked against the EN 16931 Schematron and the KoSIT rules. XRechnung also permits a UBL binding, which matters if your stack serves the Peppol network as well; create it via POST /v1/create/ubl with options: { profile: "xrechnung" } and the same invoice hash. Both outputs are legally equivalent.
Validate XRechnung invoices in Ruby
POST /v1/validate/xrechnung auto-detects CII or UBL syntax and runs three layers: the XSD, the EN 16931 core Schematron, and the German BR-DE rules from the KoSIT Schematron, the same rule set ZRE and OZG-RE enforce on submission. It accepts XML from any source, so it doubles as an independent check on hand-built or third-party output:
def validate_xrechnung(path)
uri = URI("#{BASE_URL}/v1/validate/xrechnung")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
File.open(path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
JSON.parse(api_http(uri).request(request).body)
end
end
report = validate_xrechnung("rechnung-xrechnung.xml")
unless report["valid"]
report["errors"].each do |finding|
layer = finding["layer"] # "xsd", "en16931", or "cius"
puts "[#{finding["rule"]}] (#{layer}) #{finding["message"]}"
end
end
The layer tag on each finding is worth surfacing to users: a cius failure means the document is a perfectly fine EN 16931 invoice that specifically misses a German requirement, and the classic example is BR-DE-15 firing on a missing Leitweg-ID. Note this endpoint accepts XML only; a ZUGFeRD hybrid PDF declaring the XRechnung reference profile goes to /v1/validate/zugferd instead, and the embedded XML is routed to the same KoSIT rules automatically.
Receiving is the half of the mandate that arrived first: since January 2025 you cannot refuse a compliant e-invoice, so your intake pipeline has to handle whatever suppliers send. In practice that means three kinds of documents, each with its own endpoint: structured e-invoices (ZUGFeRD PDFs or XRechnung XML), old-school PDFs with no structured data, and invoices carrying embedded supporting documents.
Deterministic JSON from ZUGFeRD and XRechnung
For documents that already carry structured data, POST /v1/extract/json is pure XML parsing, no AI involved: upload a ZUGFeRD or Factur-X hybrid PDF (the embedded XML is pulled out automatically) or a standalone XRechnung, CII, or UBL XML file, and get back a normalized InvoiceDocument JSON whose field names mirror the /v1/create request bodies:
def extract_invoice(path)
uri = URI("#{BASE_URL}/v1/extract/json")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
File.open(path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
JSON.parse(api_http(uri).request(request).body)
end
end
data = extract_invoice("lieferant-zugferd.pdf")
puts "Rechnung #{data["invoiceNumber"]} von #{data.dig("seller", "name")}"
Because the parsing is deterministic, the output is an exact mapping of the source XML: what the supplier declared is what your ERP imports. For the raw embedded XML instead, /v1/extract/xml streams it out of the PDF container untouched, ready for Nokogiri.
AI parsing for old-school invoice PDFs
Not every supplier sends e-invoices yet. For typed, scanned, or photographed PDFs with no embedded XML, POST /v1/parse/json reads the document with AI and returns the same InvoiceDocument shape plus a confidence object that makes the non-determinism explicit: an overall score and four area scores (seller identification, buyer identification, tax calculation, line items), each from 0.0 to 1.0. Automate the confident reads, route the shaky ones to a human:
def parse_invoice_pdf(path)
uri = URI("#{BASE_URL}/v1/parse/json")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
File.open(path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
JSON.parse(api_http(uri).request(request).body)
end
end
result = parse_invoice_pdf("lieferant-scan.pdf")
invoice = result["invoice"]
confidence = result.dig("confidence", "overall")
if confidence < 0.7
queue_for_human_review(invoice, result["confidence"])
else
import_into_erp(invoice)
end
The two endpoints compose into one intake method: try the deterministic route first, and fall back to AI only when the API reports error code 4006 (no embedded XML):
def read_incoming_invoice(path)
uri = URI("#{BASE_URL}/v1/extract/json")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
response = File.open(path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
api_http(uri).request(request)
end
if response.is_a?(Net::HTTPSuccess)
return { "source" => "embedded-xml", "invoice" => JSON.parse(response.body) }
end
error = JSON.parse(response.body)
raise response.body unless error["errorCode"] == 4006
# No embedded XML: an old-school PDF, hand it to the AI parser
{ "source" => "ai" }.merge(parse_invoice_pdf(path))
end
E-invoices can carry their supporting documents inside the invoice itself: delivery notes, timesheets, or the original order, embedded base64 in the EN 16931 attachment group (BG-24). XRechnung makes this routine, because German B2G portals transmit supporting documents inside the XML rather than as separate files. POST /v1/extract/attachments collects every embedded payload and returns the original files as one ZIP:
def extract_attachments(path, zip_path)
uri = URI("#{BASE_URL}/v1/extract/attachments")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
File.open(path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
response = api_http(uri).request(request)
return nil if response.is_a?(Net::HTTPNotFound) # no embedded attachments
raise response.body unless response.is_a?(Net::HTTPSuccess)
File.binwrite(zip_path, response.body)
zip_path
end
end
extract_attachments("rechnung-xrechnung.xml", "belege.zip")
It accepts a standalone XML e-invoice (XRechnung in either syntax, CII, or UBL) or a ZUGFeRD / Factur-X hybrid PDF, and the attachment bytes are decoded into the ZIP verbatim, never parsed or rendered server-side. An invoice without embedded attachments answers 404 rather than an empty archive, which is why the snippet treats that status as a normal outcome.
Render XRechnung previews
XRechnung has no visual layer, so before submission (or when an incoming one needs human review) render it as a readable PDF:
def render_xrechnung(xml_path, pdf_path)
uri = URI("#{BASE_URL}/v1/render/xrechnung/to/pdf")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
File.open(xml_path, "rb") do |file|
request.set_form([["file", file]], "multipart/form-data")
File.binwrite(pdf_path, api_http(uri).request(request).body)
end
end
render_xrechnung("rechnung-xrechnung.xml", "rechnung-vorschau.pdf")
The preview is for human eyes only; the XML remains the legal document, and the rendered PDF should never be submitted to a portal.
Rails integration
In Rails, wrap the two formats in one service object; Faraday with faraday-multipart is the idiomatic client, and Rails credentials hold the key. The format decision from earlier collapses into a single public method:
# Gemfile
gem "faraday"
gem "faraday-multipart"
# app/services/german_invoice_service.rb
class GermanInvoiceService
BASE_URL = "https://api.invoicexml.com"
# Returns [bytes, extension] so the caller can attach and name the file.
def create(invoice_payload, public_sector:)
endpoint = public_sector ? "/v1/create/xrechnung" : "/v1/create/zugferd"
response = connection.post(endpoint) do |req|
req.headers["Content-Type"] = "application/json"
req.body = JSON.generate(invoice: invoice_payload)
end
raise InvoiceComplianceError, response.body unless response.success?
[response.body, public_sector ? "xml" : "pdf"]
end
def validate(bytes, public_sector:)
endpoint = public_sector ? "/v1/validate/xrechnung" : "/v1/validate/zugferd"
filename = public_sector ? "invoice.xml" : "invoice.pdf"
part = Faraday::Multipart::FilePart.new(
StringIO.new(bytes), "application/octet-stream", filename
)
JSON.parse(connection.post(endpoint, { file: part }).body)
end
private
def connection
@connection ||= Faraday.new(url: BASE_URL) do |f|
f.request :multipart
f.headers["Authorization"] =
"Bearer #{Rails.application.credentials.dig(:invoicexml, :api_key)}"
end
end
end
class InvoiceComplianceError < StandardError; end
Run generation in a background job (ActiveJob over Sidekiq or GoodJob), attach the result with ActiveStorage, and retry only what deserves retrying: transport failures and 5xx, never HTTP 400, which carries findings about your data that will not change on resend. For public sector buyers, store the Leitweg-ID on the buyer record and treat a missing one as a data error before the job even enqueues.
The highest-leverage Rails habit is a compliance spec in CI. One test pins the entire mapping from your models to both formats:
RSpec.describe GermanInvoiceService do
subject(:service) { described_class.new }
it "produces valid ZUGFeRD for B2B customers" do
bytes, _ext = service.create(build(:invoice).to_api_payload, public_sector: false)
report = service.validate(bytes, public_sector: false)
expect(report["valid"]).to be(true), report["errors"].inspect
end
it "produces valid XRechnung for public sector buyers" do
payload = build(:invoice, :with_leitweg_id).to_api_payload
bytes, _ext = service.create(payload, public_sector: true)
report = service.validate(bytes, public_sector: true)
expect(report["valid"]).to be(true), report["errors"].inspect
end
end
Build vs REST API
In Ruby the local route is not a library choice but a construction project: Nokogiri XML assembly against 200+ rules, an AGPL-licensed PDF/A dependency or a from-scratch container, no in-process path to Schematron at all, and a KoSIT release calendar (a new XRechnung version each year, patches in between) that becomes your operations problem. Teams that go this way usually end up operating a Java validator as a sidecar anyway.
The API side of the ledger: official EN 16931 and KoSIT rule sets applied server-side and updated before their effective dates, PDF/A-3 output that survives portal and recipient checks, deterministic extraction for structured incoming invoices plus AI parsing with confidence scores for old-school PDFs, structured findings with field paths, and both German formats plus Factur-X and Peppol UBL behind one integration. Your Gemfile gains nothing, or just Faraday, and when KoSIT or FeRD ship a new version there is nothing to monitor, no gem to wait for, and no redeploy: the current rules are simply live. That is the difference between a toolkit and a complete compliance service, with deep e-invoicing expertise and professional support behind it and missing features integrated on request.
Data handling is stateless by architecture: documents are processed in memory and purged when the response ships. Nothing is stored, nothing is logged, no invoice data trains any model. VAT numbers, IBANs, and Leitweg-IDs exist server-side only for the duration of a single HTTP exchange.
Endpoint reference
| Operation | Endpoint | Input | Output |
| Create ZUGFeRD | POST /v1/create/zugferd | JSON | PDF/A-3 binary |
| Create XRechnung (CII) | POST /v1/create/xrechnung | JSON | XRechnung 3.0 XML |
| Create XRechnung (UBL) | POST /v1/create/ubl + options.profile: xrechnung | JSON | XRechnung UBL XML |
| Validate ZUGFeRD | POST /v1/validate/zugferd | ZUGFeRD PDF | Validation JSON |
| Validate XRechnung | POST /v1/validate/xrechnung | XRechnung XML (CII or UBL) | Validation JSON |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
| Extract as JSON | POST /v1/extract/json | ZUGFeRD PDF or XRechnung XML | Structured JSON |
| Parse PDF with AI | POST /v1/parse/json | Any invoice PDF (typed, scanned, photo) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | ZUGFeRD PDF or XRechnung XML | ZIP archive |
| Extract CII XML | POST /v1/extract/xml | ZUGFeRD PDF | CII XML |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi | Interactive API explorer: api.invoicexml.com/v1/scalar
Get started
Every snippet in this guide is complete and self-contained: paste it into a file, set INVOICEXML_API_KEY, and run it with a stock Ruby 3.x, no Gemfile required. Runnable versions of these snippets, alongside Python, PHP, Java, C#, and Node.js examples, live in the Ruby folder of the examples repository.
Create a free InvoiceXML account → get 100 credits for free, no credit card required.
Related resources:
InvoiceXML is a REST API for European e-invoice compliance covering ZUGFeRD, XRechnung, Factur-X, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and callable from any Ruby application: Rails, Sinatra, Hanami, or a plain script.