ZUGFeRD (B2B). A hybrid PDF/A-3: the visual invoice a human reads, with CII XML embedded as an attachment named factur-x.xml. Profiles EN 16931 (COMFORT) and above qualify as e-invoices under the mandate; MINIMUM and BASIC WL are booking aids and do not. Reception has been compulsory for every German business since January 2025, so a customer can no longer refuse one, and issuance becomes mandatory from January 2027 above EUR 800k turnover and from January 2028 for everyone else.
XRechnung (B2G). Pure XML, no PDF wrapper, mandatory for invoicing German federal, state, and municipal buyers. It is a CIUS of EN 16931 with the German KoSIT rules layered on: the BR-DE family adds requirements the base standard leaves optional, most notably the Leitweg-ID routing identifier enforced by BR-DE-15. Submission runs through portals such as ZRE and OZG-RE, or over Peppol.
What they share. One semantic model, roughly 200 EN 16931 business rules, the same BT and BG field codes. That is why a single Go struct serves both, and why the intake side of your service can accept either without branching until the very end.
Which endpoint for which customer?
| Customer | Format | Endpoint | You get back |
| German business | ZUGFeRD EN 16931 | /v1/create/zugferd | PDF/A-3 bytes |
| German authority | XRechnung 3.0 | /v1/create/xrechnung | XML bytes |
| French business | Factur-X | /v1/create/facturx | PDF/A-3 bytes |
| Peppol recipient | Peppol BIS 3.0 | /v1/create/ubl | UBL XML bytes |
Same request body for every row, which makes format selection a field on your customer record rather than a fork in your code.
The Go library landscape
Short version: there is no ZUGFeRD or XRechnung library for Go, and unlike Python or Java, this is not a gap waiting on a motivated maintainer. Three walls stand in the way, and each one is load-bearing.
Wall one: namespaced XML. CII is deeply namespaced, with rsm, ram, and udt prefixes carrying meaning throughout the document. Go's encoding/xml is a pragmatic marshaller, not a full XML stack: its handling of namespace prefixes on output is famously awkward, and there is no XPath. Producing schema-valid CII with it means fighting the library on every element.
Wall two: PDF/A-3 conformance. The Go PDF ecosystem can create and manipulate documents, and some libraries can even attach files. Attaching a file is not the requirement. ZUGFeRD needs PDF/A-3 conformance: embedded font subsets, an ICC output intent, XMP metadata carrying the ZUGFeRD extension schema, and the correct /AF associated-file relationship on the attachment. Miss any of it and the file opens fine everywhere while failing conformance at the recipient's platform. No Go library produces that guarantee today.
Wall three: Schematron. This is the decisive one. The official EN 16931 and KoSIT validation artifacts are Schematron, which compiles to XSLT 2.0. Go has no XSLT 2.0 processor, and cgo bindings to libxslt only reach XSLT 1.0, which cannot execute these rule sets. Validating in-process from Go means embedding a JVM to run Saxon, or shelling out to a Java validator and parsing its output, which is a distributed system with extra steps.
So the realistic options for a Go team are running a Java validator alongside your service and hand-building the XML and PDF layers around it, or making an HTTP call. The rest of this guide takes the second route, where the XML generation, the PDF/A-3 conformance, the Schematron execution, and the specification updates all live behind one endpoint, kept current by a team that does e-invoicing compliance full time.
Client setup with net/http
Sign up for a free InvoiceXML account and you receive 100 free credits with the 30-day trial, no credit card required. Keep the key in the environment, never in source:
package invoicexml
import (
"net/http"
"os"
"time"
)
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
}
func New() *Client {
return &Client{
BaseURL: "https://api.invoicexml.com",
APIKey: os.Getenv("INVOICEXML_API_KEY"),
HTTP: &http.Client{
Timeout: 60 * time.Second,
},
}
}
One http.Client for the whole process: it is safe for concurrent use and pools connections, so creating one per request throws away keep-alive and leaks file descriptors under load. The 60 second timeout is deliberate for document generation, which is slower than a typical JSON API call.
The invoice model is a plain struct with JSON tags, which means your compiler catches field mistakes that a map would let through:
type Invoice struct {
InvoiceNumber string `json:"invoiceNumber"`
IssueDate string `json:"issueDate"`
Currency string `json:"currency"`
BuyerReference string `json:"buyerReference,omitempty"` // Leitweg-ID for B2G
Seller Party `json:"seller"`
Buyer Party `json:"buyer"`
PaymentDetails *PaymentDetails `json:"paymentDetails,omitempty"`
Lines []Line `json:"lines"`
}
type Party struct {
Name string `json:"name"`
VATIdentifier string `json:"vatIdentifier,omitempty"`
PostalAddress Address `json:"postalAddress"`
Contact *Contact `json:"contact,omitempty"` // mandatory for XRechnung
}
type Address struct {
Line1 string `json:"line1"`
City string `json:"city"`
PostCode string `json:"postCode"`
Country string `json:"country"`
}
type Line struct {
Quantity float64 `json:"quantity"`
Item Item `json:"item"`
PriceDetails PriceDetails `json:"priceDetails"`
VATInformation VATInformation `json:"vatInformation"`
}
Note what is absent: no total, no tax total, no VAT breakdown. The API computes them from the lines and refuses to emit a document whose arithmetic does not reconcile, so a rounding bug in your pricing surfaces as a structured error rather than as an invoice your customer's software rejects.
Create ZUGFeRD invoices in Go
func (c *Client) Create(ctx context.Context, format string, inv Invoice) ([]byte, error) {
body, err := json.Marshal(map[string]Invoice{"invoice": inv})
if err != nil {
return nil, fmt.Errorf("marshal invoice: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL+"/v1/create/"+format, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("create %s: %w", format, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
// 4xx carries the violated EN 16931 rules as structured findings
return nil, fmt.Errorf("create %s: %s: %s", format, resp.Status, data)
}
return data, nil
}
Calling it for a German B2B customer:
inv := invoicexml.Invoice{
InvoiceNumber: "RE-2026-0817",
IssueDate: "2026-08-01",
Currency: "EUR",
Seller: invoicexml.Party{
Name: "Schwarzwald Praezisionstechnik GmbH",
VATIdentifier: "DE246813579",
PostalAddress: invoicexml.Address{
Line1: "Talstrasse 47", City: "Freiburg",
PostCode: "79098", Country: "DE",
},
},
Buyer: invoicexml.Party{
Name: "Hansa Werkzeughandel AG",
PostalAddress: invoicexml.Address{
Line1: "Schlachte 22", City: "Bremen",
PostCode: "28195", Country: "DE",
},
},
PaymentDetails: &invoicexml.PaymentDetails{
PaymentAccountIdentifier: "DE89370400440532013000",
},
Lines: []invoicexml.Line{{
Quantity: 120,
Item: invoicexml.Item{Name: "Fraesspindel Serie HF, Wartungssatz"},
PriceDetails: invoicexml.PriceDetails{NetPrice: 64.90},
VATInformation: invoicexml.VATInformation{Rate: 19},
}},
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pdf, err := client.Create(ctx, "zugferd", inv)
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("RE-2026-0817.pdf", pdf, 0o600); err != nil {
log.Fatal(err)
}
The returned bytes are a complete ZUGFeRD hybrid: PDF/A-3 container, embedded CII XML, XMP metadata declaring the profile, already validated against the full EN 16931 rule set. Because Create takes the format as a parameter, the same method serves Factur-X and Peppol UBL, and the B2G path below.
Validate ZUGFeRD invoices in Go
The create endpoints validate what they produce, so validation earns its place at your boundaries: files from upstream systems, invoices received from suppliers, or output from a legacy service. Uploads use mime/multipart from the standard library:
func (c *Client) upload(ctx context.Context, path, filename string, doc []byte) ([]byte, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", filename)
if err != nil {
return nil, err
}
if _, err := part.Write(doc); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Closing the writer before sending is mandatory: it writes the closing boundary, and forgetting it produces a malformed body that fails server-side with an error that looks nothing like the actual mistake. With the helper in place, validation is a small type and one call:
type ValidationResult struct {
Valid bool `json:"valid"`
Errors []struct {
Rule string `json:"rule"`
Message string `json:"message"`
Location string `json:"location"`
} `json:"errors"`
}
func (c *Client) Validate(ctx context.Context, format, filename string, doc []byte) (*ValidationResult, error) {
raw, err := c.upload(ctx, "/v1/validate/"+format, filename, doc)
if err != nil {
return nil, err
}
var result ValidationResult
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("decode verdict: %w", err)
}
return &result, nil
}
verdict, err := client.Validate(ctx, "zugferd", "incoming.pdf", doc)
if err != nil {
log.Fatal(err)
}
if !verdict.Valid {
for _, e := range verdict.Errors {
log.Printf("[%s] %s (%s)", e.Rule, e.Message, e.Location)
}
}
Both valid and invalid documents answer HTTP 200 here, so branch on verdict.Valid rather than the status code. Every finding carries the rule id, which is the same identifier a recipient's platform would quote back at you, plus a readable message and the field path that produced it.
Create XRechnung invoices in Go
Same struct, same method, different endpoint, plus the two things the German B2G rules add:
inv.BuyerReference = "04011000-1234512345-06" // Leitweg-ID, enforced by BR-DE-15
inv.Seller.Contact = &invoicexml.Contact{
Name: "Rechnungsstelle",
Telephone: "+49 761 5550140",
Email: "[email protected]",
}
xml, err := client.Create(ctx, "xrechnung", inv)
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("RE-2026-0817.xml", xml, 0o600); err != nil {
log.Fatal(err)
}
The Leitweg-ID is the field that catches teams out. It is assigned by the public buyer during onboarding, appears on no source document, and cannot be derived from anything else, so it belongs on your customer record as a string. Store it as text: the identifier contains leading zeros and dashes that numeric handling destroys.
The response is XRechnung 3.0 XML validated against the EN 16931 Schematron and the KoSIT rules, including the whole BR-DE family. For the UBL syntax variant instead of CII, call /v1/create/ubl with the XRechnung profile.
Validate XRechnung invoices in Go
The same helper, one different path segment:
verdict, err := client.Validate(ctx, "xrechnung", "invoice.xml", xml)
This runs the KoSIT rule set on top of EN 16931, so the findings you get back are the ones ZRE and OZG-RE would produce. Running it before submission turns a portal rejection, which arrives days later through a human, into an error your Go service handles at issue time. The rule ids that appear most often have reference pages: the rule and identifier reference explains the cryptic ones in plain language.
Read incoming invoices: extraction plus AI
Reception is the half of the mandate already live for every German business, and it splits cleanly by whether the document carries structure.
Structured documents go to /v1/extract/json, which reads the embedded XML of a ZUGFeRD or Factur-X hybrid, or standalone XRechnung, CII, or UBL XML, and returns normalized JSON. Deterministic: what the supplier declared is what you get.
raw, err := client.upload(ctx, "/v1/extract/json", "received.pdf", doc)
if err != nil {
log.Fatal(err)
}
var extracted struct {
Invoice Invoice `json:"invoice"`
}
if err := json.Unmarshal(raw, &extracted); err != nil {
log.Fatal(err)
}
log.Printf("supplier %s invoice %s",
extracted.Invoice.Seller.Name, extracted.Invoice.InvoiceNumber)
Plain PDFs from suppliers who have not moved yet go to /v1/parse/json, which reads them with AI (typed, scanned, and photographed) and returns the same JSON shape plus a confidence object with an overall score and per-area scores. That is what makes automatic routing safe:
var parsed struct {
Invoice Invoice `json:"invoice"`
Confidence struct {
Overall float64 `json:"overall"`
} `json:"confidence"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
log.Fatal(err)
}
if parsed.Confidence.Overall >= 0.7 {
book(parsed.Invoice)
} else {
reviewQueue(parsed.Invoice)
}
Documents the classifier decides are not invoices fail with error code 4008, and multi-invoice PDFs with 4009, so the endpoint filters as well as parses. Where an invoice carries embedded supporting documents (BG-24 delivery notes, timesheets), /v1/extract/attachments returns them as a ZIP.
Render XRechnung previews
XRechnung is machine-readable and nothing else, which becomes a problem the moment a human must approve or archive one. /v1/render/xrechnung/to/pdf returns a readable PDF of the document:
preview, err := client.upload(ctx, "/v1/render/xrechnung/to/pdf", "invoice.xml", xml)
if err != nil {
log.Fatal(err)
}
os.WriteFile("preview.pdf", preview, 0o600)
Useful for approval screens and support tooling. The rendered PDF is a visual representation: in a B2G flow the compliant artifact remains the XML you submit.
Batch issuance with goroutines
Month-end batches are where Go earns its keep, and also where naive concurrency gets a service rate limited. One goroutine per invoice across ten thousand invoices opens ten thousand concurrent requests; a bounded pool is what you want:
import "golang.org/x/sync/errgroup"
func IssueBatch(ctx context.Context, c *Client, invoices []Invoice) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // bounded concurrency, one shared http.Client
for _, inv := range invoices {
inv := inv
g.Go(func() error {
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
pdf, err := c.Create(reqCtx, "zugferd", inv)
if err != nil {
return fmt.Errorf("invoice %s: %w", inv.InvoiceNumber, err)
}
return store(inv.InvoiceNumber, pdf)
})
}
return g.Wait()
}
Three things worth keeping: the shared client (pooled connections, no per-request setup cost), the per-request context deadline so one stalled call cannot pin a worker, and wrapping the error with the invoice number, because errgroup returns the first failure and "invoice RE-2026-0817: 400" is actionable while a bare status code is not.
On retries, split the cases. Transport errors and 5xx are worth retrying with backoff. A 4xx from a create endpoint is a data problem: the same struct produces the same finding every time, so it belongs in a dead-letter path with the findings attached, not in a retry loop.
Go libraries vs REST API
For most languages this section weighs a real library against an API. For Go there is no library to weigh, so the honest comparison is between building the pieces yourself and making an HTTP call.
Building it in Go means owning three hard problems at once. Hand-rolled CII XML fighting encoding/xml over namespace prefixes; PDF/A-3 conformance including XMP extension schema and the /AF relationship, which no Go PDF library guarantees; and Schematron validation that Go simply cannot execute, forcing a JVM alongside your binary and turning a static-binary deployment into a two-runtime deployment. Then the specifications move: ZUGFeRD revises, XRechnung releases annually, and each cycle is rework across all three layers.
The API is a complete compliance service. The official rule sets stay current server-side and go live on their effective dates, so there is nothing to monitor, no library release to wait for, and no redeploy when a specification changes. One integration covers ZUGFeRD, XRechnung, Factur-X, Peppol UBL, and CII, plus validation, extraction, AI parsing, and rendering. Deep e-invoicing expertise and professional support stand behind it, and missing capabilities get built when customers need them. Your go.mod stays clean and your deployment stays a single static binary, which is presumably part of why you chose Go.
The deciding question is who owns the moving target. Build it yourself and the regulator's calendar dictates your release schedule indefinitely. Call the API and your integration is finished the day it works, because keeping it compliant is our job.
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 passing through your Go service never persist outside the single HTTP call.
Endpoint reference
| Operation | Endpoint | Input | Output |
| Create ZUGFeRD | POST /v1/create/zugferd | JSON | PDF/A-3 binary |
| Create XRechnung | POST /v1/create/xrechnung | JSON | XRechnung 3.0 XML |
| Validate ZUGFeRD | POST /v1/validate/zugferd | Hybrid PDF | Validation JSON |
| Validate XRechnung | POST /v1/validate/xrechnung | XRechnung XML | Validation JSON |
| Extract as JSON | POST /v1/extract/json | Hybrid PDF or CII/UBL XML | Structured JSON |
| Parse PDF with AI | POST /v1/parse/json | Any invoice PDF (typed, scanned) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Hybrid PDF or CII/UBL XML | ZIP archive |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
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 struct, the invoice struct, and two methods: Create for issuing and upload for everything that takes a file. No dependencies beyond the standard library, and nothing to revisit when the formats move.
Create a free InvoiceXML account → get 100 free credits with the 30-day trial, no credit card required.
Related resources: