Automation MCP Server Features Blog Pricing Contact
Integration ZUGFeRD Format

Create and Validate ZUGFeRD and XRechnung Invoices in Go: Complete Guide

One Go integration for both German e-invoice formats: ZUGFeRD hybrid PDFs for B2B and XRechnung pure XML for the public sector. Creating from typed structs, validating against EN 16931 and the KoSIT rules, reading incoming invoices with deterministic extraction plus AI parsing, and issuing batches with goroutines. Standard library only, no dependencies, and an honest look at why the Go ecosystem has no e-invoicing library worth waiting for.

Billing German customers from a Go service means producing two different deliverables from the same invoice data. The B2B mandate (reception compulsory since January 2025, issuance phasing in from 2027) runs on ZUGFeRD: a PDF/A-3 the customer can open, carrying CII XML their accounting software parses. The public sector runs on XRechnung: no PDF at all, pure XML, submitted to portals that enforce the KoSIT rule set and reject anything without a Leitweg-ID. For teams whose stack is a handful of Go binaries, the question is how much of that machinery has to move into the codebase.

This guide covers both formats together, because they share the EN 16931 semantic model and the API takes the same invoice struct for either. What changes between a B2B and a B2G invoice below is the endpoint and two extra fields. Everything runs on net/http and mime/multipart from the standard library, so the integration adds nothing to your go.mod. If your market is France, the identical hybrid format travels as Factur-X and only the endpoint name changes.

Go also deserves a blunt tooling conversation, and it goes differently than it does for Java or PHP. There is no ZUGFeRD library for Go, the reasons are structural rather than a matter of nobody having gotten around to it, and the library landscape section explains exactly which three walls a Go implementation runs into. The ZUGFeRD API and XRechnung API keep all three server-side.

The German mandate in two formats

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?

CustomerFormatEndpointYou get back
German businessZUGFeRD EN 16931/v1/create/zugferdPDF/A-3 bytes
German authorityXRechnung 3.0/v1/create/xrechnungXML bytes
French businessFactur-X/v1/create/facturxPDF/A-3 bytes
Peppol recipientPeppol BIS 3.0/v1/create/ublUBL 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

OperationEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdJSONPDF/A-3 binary
Create XRechnungPOST /v1/create/xrechnungJSONXRechnung 3.0 XML
Validate ZUGFeRDPOST /v1/validate/zugferdHybrid PDFValidation JSON
Validate XRechnungPOST /v1/validate/xrechnungXRechnung XMLValidation JSON
Extract as JSONPOST /v1/extract/jsonHybrid PDF or CII/UBL XMLStructured JSON
Parse PDF with AIPOST /v1/parse/jsonAny invoice PDF (typed, scanned)Structured JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsHybrid PDF or CII/UBL XMLZIP archive
Render XRechnung as PDFPOST /v1/render/xrechnung/to/pdfXRechnung XMLPDF 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:

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded