Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in microtech büro+: E-Invoicing with the GraphQL API and One API Call

Many German SMEs run microtech büro+. German law now requires them to send and receive e-invoices. This guide shows a small C# service that reads posted invoices through the büro+ GraphQL API and turns each one into a ZUGFeRD file with one REST API call. It also covers email delivery by the API, XRechnung for public buyers, and checking supplier invoices before they become Eingangsrechnungen.

This guide connects microtech büro+ to ZUGFeRD and XRechnung. A small C# service reads posted invoices through the büro+ GraphQL API. It sends each invoice as JSON to one REST API. The answer is a validated ZUGFeRD PDF, which the service stores and records. Nothing changes inside büro+, and no print layout is touched. The email section shows how the API delivers the invoice to the customer. The import section validates supplier invoices and turns them into Eingangsrechnungen. The service has the same shape as the one in the SelectLine guide.


Why microtech büro+ needs ZUGFeRD now

German büro+ users must handle e-invoices. Every German business must be able to receive them since January 2025. From January 2027, a business with more than EUR 800,000 turnover in the previous year must also issue them. From January 2028, all businesses must issue them. A normal PDF does not count as an e-invoice. An e-invoice holds structured data in XML. ZUGFeRD puts this XML into a PDF/A-3 file. The customer's staff read the PDF, and the customer's software reads the XML. The Germany mandate page has all dates and formats.

A second path next to büro+. büro+ has its own e-invoice functions: the Warenwirtschaft can create XRechnung and ZUGFeRD, and the DMS can receive e-invoices. The service in this guide runs next to büro+ and adds its own strengths. It validates each invoice before it leaves the house. It can send the invoice from your own mail domain. It returns a German report to suppliers whose invoices fail the rules. Partners install the same build at every customer.

New format versions need no new build. ZUGFeRD, XRechnung and the EN 16931 rules change over time. The API switches to each new version on the day it takes effect. The service sends the same JSON before and after the change.


What you need

  • microtech büro+ Gen. 24 with the GraphQL API. The GraphQL license comes with the Enterprise edition or a partner app license. The Mandant needs the flag GraphQL aktiviert.
  • An OAuth application. Register it under Meine Firma, Überblick, Systemeinstellungen, Anwendungen verwalten. Choose the client type Confidential and tick GraphQL-API Zugriff.
  • A büro+ user for the service. It needs the flag Zugriff über GraphQL erlaubt, read rights on Vorgänge and Adressen, and create rights on Vorgänge for the import.
  • Three settings from the Mandant: the Vorgangsarten to process (for example Rechnung I and Rechnung II), a cost article for imported lines, and your input tax keys (Steuerschlüssel) per VAT rate.
  • Where the service runs: as a Windows service or a scheduled task on .NET 8. It needs HTTPS to the büro+ data server and to api.invoicexml.com.
  • An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.

How the integration works

A user posts a Rechnung in büro+. In its next run, the service finds the Vorgang through the GraphQL API. It reads the header, the positions and the units and builds one JSON body. The API calculates the totals and the VAT, creates the XML, embeds it in a PDF/A-3 file and validates it. The service saves the PDF in the archive folder and records the Belegnummer. Or the API emails the invoice to the customer.

CustomerFormatEndpointResult
German business (B2B)ZUGFeRD EN 16931/v1/create/zugferdHybrid PDF/A-3
German business, delivered by the APIZUGFeRD EN 16931/v1/create/zugferd/emailEmail with the PDF attached
German public buyer (B2G)XRechnung/v1/create/xrechnungXML with Leitweg-ID
French businessFactur-X EN 16931/v1/create/facturxHybrid PDF/A-3

All four endpoints take the same JSON body. The listings below give you five classes and one table:

  • MicrotechClient: gets the OAuth token and sends GraphQL queries and mutations.
  • InvoiceXmlClient: calls the InvoiceXML create, validate and extract endpoints.
  • InvoiceMapper: reads the Vorgang, its positions and the Mandant data and builds the JSON.
  • EInvoiceService: finds posted invoices, creates the file, stores it, sends by email.
  • InboundService: validates supplier files, sends reports back and creates Eingangsrechnungen.
  • EInvoiceLog: a table in the service's own database with the processed Belegnummern.

On the büro+ side, the service uses the GraphQL tables tblTransactions (Vorgänge), tblTransactionItems (positions), tblAddresses, tblClient (Mandant), tblUnitsOfMeasure and tblCurrencies.


Step 1: Connect to the büro+ GraphQL API and InvoiceXML

Each Mandant has its own GraphQL address on the büro+ data server. It has the form https://<server>/microtech/erp/mand/<MandNr>/. The service asks the token endpoint for an access token with the client credentials flow. Then it posts each query to graphql/v1 with the header Authorization: Bearer <token>. A token is valid for 60 minutes by default. The settings live in appsettings.json. Keep the client secret and the API key in the Windows Credential Manager, environment variables or your secret store, not in the file.

{
  "Microtech": {
    "MandantUrl": "https://erp.ihre-firma.de/microtech/erp/mand/01/"
  },
  "EInvoice": {
    "InvoiceTypes": [ "Rechnung I", "Rechnung II" ],
    "LookBackDays": 14,
    "PollMinutes": 5,
    "ArchiveFolder": "\\\\fs01\\Archiv\\E-Rechnungen",
    "InboundFolder": "\\\\fs01\\Archiv\\Eingangsrechnungen",
    "SenderAddress": "[email protected]",
    "CostArticle": "EINKAUF-SONST",
    "InputTaxKeys": { "19": 11, "7": 12 }
  }
}

The numbers in InputTaxKeys are examples. Use the Vorsteuer keys of your Mandant from the table tblValueAddedTaxTypes. The test query in the last section lists them.

// MicrotechClient.cs: OAuth token, then GraphQL over HTTPS
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

public sealed class MicrotechClient
{
    private readonly HttpClient _http;
    private readonly string _clientId;
    private readonly string _clientSecret;
    private string? _token;
    private DateTime _tokenExpires;

    public MicrotechClient(string mandantUrl, string clientId, string clientSecret)
    {
        _http = new HttpClient { BaseAddress = new Uri(mandantUrl) };
        (_clientId, _clientSecret) = (clientId, clientSecret);
    }

    // GraphQL string and number literals, safely escaped
    public static string Str(string? value) => JsonSerializer.Serialize(value ?? "");
    public static string Num(decimal value) => value.ToString(CultureInfo.InvariantCulture);

    // Sends a query or mutation and returns the "data" object
    public async Task<JsonNode> RunAsync(string graphql)
    {
        var request = NewRequest("graphql/v1", JsonContent.Create(new { query = graphql }));
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await TokenAsync());
        var response = await _http.SendAsync(request);
        var text = await response.Content.ReadAsStringAsync();
        var body = JsonNode.Parse(text);
        // A mutation is all or nothing: one error and büro+ rolls the whole operation back
        if (!response.IsSuccessStatusCode || body?["errors"] is JsonArray { Count: > 0 })
            throw new InvalidOperationException($"büro+ GraphQL {(int)response.StatusCode}: {text}");
        return body!["data"]!;
    }

    private async Task<string> TokenAsync()
    {
        if (_token != null && DateTime.UtcNow < _tokenExpires) return _token;
        var request = NewRequest("token", new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["grant_type"] = "client_credentials",
            ["scope"] = "graphql",
        }));
        var basic = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_clientId}:{_clientSecret}"));
        request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
        var response = await _http.SendAsync(request);
        response.EnsureSuccessStatusCode();   // 401: check the client id, the secret and the linked user
        var json = (await response.Content.ReadFromJsonAsync<JsonNode>())!;
        _token = (string)json["access_token"]!;
        _tokenExpires = DateTime.UtcNow.AddSeconds((int)json["expires_in"]! - 60);
        return _token;
    }

    // The data server speaks HTTP/2; fall back to HTTP/1.1 if a proxy needs it
    private static HttpRequestMessage NewRequest(string path, HttpContent content) => new(HttpMethod.Post, path)
    {
        Content = content,
        Version = HttpVersion.Version20,
        VersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
    };
}

// InvoiceXmlClient.cs: create files, validate with a report, extract JSON
public sealed class InvoiceXmlClient
{
    private static readonly JsonSerializerOptions Options = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,   // empty fields are left out
    };
    private readonly HttpClient _http = new() { BaseAddress = new Uri("https://api.invoicexml.com/") };

    public InvoiceXmlClient(string apiKey)
        => _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

    public async Task<byte[]> CreateAsync(string path, object body)
    {
        var response = await _http.PostAsJsonAsync(path, body, Options);
        if (!response.IsSuccessStatusCode)   // 400: "errors" lists each rule id with the JSON path
            throw new InvalidOperationException(await response.Content.ReadAsStringAsync());
        return await response.Content.ReadAsByteArrayAsync();
    }

    // Report endpoints: one form field "file" plus "validationLanguage"
    public async Task<(bool Valid, byte[] Report)> ValidateWithReportAsync(string path, byte[] file, string fileName)
    {
        using var form = new MultipartFormDataContent
        {
            { new ByteArrayContent(file), "file", fileName },
            { new StringContent("de"), "validationLanguage" },
        };
        var response = await _http.PostAsync(path, form);
        response.EnsureSuccessStatusCode();
        var valid = response.Headers.TryGetValues("X-Invoice-Valid", out var values) && values.First() == "true";
        return (valid, await response.Content.ReadAsByteArrayAsync());
    }

    public async Task<JsonNode> ExtractAsync(byte[] file, string fileName)
    {
        using var form = new MultipartFormDataContent { { new ByteArrayContent(file), "file", fileName } };
        var response = await _http.PostAsync("v1/extract/json", form);
        if (!response.IsSuccessStatusCode)
            throw new InvalidOperationException(await response.Content.ReadAsStringAsync());
        return (await response.Content.ReadFromJsonAsync<JsonNode>())!;
    }
}

GraphQL answers a broken query with an errors array, often with HTTP status 200. That is why RunAsync checks the array and not only the status code. Str turns any text into a valid GraphQL string, so names with quotes or umlauts cannot break a query. After 25 failed login attempts, büro+ blocks requests for 300 seconds. Let the service stop and alert after a 401 instead of retrying.


Step 2: Map büro+ fields to JSON

One GraphQL query reads the Vorgang by its Belegnummer, with its positions in the nested table tblTransactionItems. The units, the currencies and your company data change rarely. The mapper reads them once at start from tblUnitsOfMeasure, tblCurrencies and tblClient.

büro+ GraphQL fieldJSON pathNote
fldBelegNrinvoice.invoiceNumberBT-1
fldDatinvoice.issueDateyyyy-MM-dd
fldDat + fldNettoTginvoice.dueDateNet days of the payment terms
fldWaehr via tblCurrencies.fldISOBezinvoice.currencyShort name to ISO code
fldBestellNrinvoice.purchaseOrderReferenceThe customer's order number, BT-13
fldReNa2, fldReStr, fldRePLZ, fldReOrt, fldReLandKennzinvoice.buyerInvoice address of the Vorgang
fldUStIdinvoice.buyer.vatIdentifierBuyer VAT id
fldBezlines[].item.namePer position
fldMge, fldEinh via tblUnitsOfMeasure.fldCodeEinhlines[].quantity, lines[].unitCodebüro+ stores the UN/ECE code per unit
acoEPr.totalNetAmount, fldRabSzlines[].priceDetails.netPriceNet unit price after the line discount
fldStSchlSzlines[].vatInformation.rateRate of the tax key, for example 19
fldLtwgIDinvoice.buyerReferenceLeitweg-ID, XRechnung only
tblClient: fldNa1, fldStr, fldPLZ, fldOrt, fldUStId, tblBnkVb.fldIBANinvoice.seller, paymentDetailsYour company from the Mandant

You do not send totals or VAT amounts. The API derives them from the lines. When the lines and the totals do not match, the API returns an error instead of a file. So a rounding or discount problem shows up before the customer sees the invoice.

// InvoiceMapper.cs: Vorgang + positions + Mandant -> InvoiceXML JSON
public sealed record MappedInvoice(object Invoice, string? Email, string? LeitwegId);

public sealed class InvoiceMapper
{
    // Position kinds that only show sums: no invoice line
    private static readonly string[] SumFlags = { "fldZWSumKz", "fldGrpSumKz", "fldRabSumKz", "fldGBSumKz", "fldTitelFusKz" };

    private readonly MicrotechClient _mt;
    private readonly SellerContact _contact;   // name, phone, email of your invoicing contact (configuration)
    private Dictionary<string, string> _units = new();
    private Dictionary<string, string> _currencies = new();
    private JsonNode _mandant = null!;

    public InvoiceMapper(MicrotechClient mt, SellerContact contact) => (_mt, _contact) = (mt, contact);

    public async Task LoadParametersAsync()
    {
        var data = await _mt.RunAsync("""
            query {
              tblUnitsOfMeasure { rowsRead { fldKuBez fldCodeEinh } }
              tblCurrencies { rowsRead { fldKuBez fldISOBez } }
              tblClient { rowRead { fldNa1 fldStr fldPLZ fldOrt fldUStId tblBnkVb { rowsRead { fldIBAN } } } }
            }
            """);
        _units = Lookup(data["tblUnitsOfMeasure"]!, "fldKuBez", "fldCodeEinh");
        _currencies = Lookup(data["tblCurrencies"]!, "fldKuBez", "fldISOBez");
        _mandant = data["tblClient"]!["rowRead"]!;
    }

    public async Task<MappedInvoice> BuildAsync(string belegNr)
    {
        var data = await _mt.RunAsync($$"""
            query {
              tblTransactions {
                rowRead(exactMatch: { byBelegNr: { kf1BelegNr: { string: {{MicrotechClient.Str(belegNr)}} } } }) {
                  fldBelegNr fldDat fldNettoTg(as: INT) fldWaehr fldBestellNr fldLtwgID fldUStId
                  fldReNa2 fldReStr fldRePLZ fldReOrt fldReLandKennz fldReEMail1
                  tblTransactionItems {
                    rowsRead {
                      fldBez fldMge(as: FLOAT) fldEinh fldRabSz(as: FLOAT) fldStSchlSz(as: FLOAT)
                      acoEPr { totalNetAmount }
                      fldZWSumKz(as: BOOLEAN) fldGrpSumKz(as: BOOLEAN) fldRabSumKz(as: BOOLEAN)
                      fldGBSumKz(as: BOOLEAN) fldTitelFusKz(as: BOOLEAN)
                    }
                  }
                }
              }
            }
            """);
        var doc = data["tblTransactions"]!["rowRead"]
            ?? throw new InvalidOperationException($"Vorgang {belegNr} nicht gefunden");

        var issueDate = DateOnly.Parse(((string)doc["fldDat"]!)[..10], CultureInfo.InvariantCulture);
        var leitwegId = (string?)doc["fldLtwgID"];
        var xrechnung = !string.IsNullOrWhiteSpace(leitwegId);   // contact and electronic addresses only for XRechnung
        var buyerVatId = (string?)doc["fldUStId"];

        var lines = doc["tblTransactionItems"]!["rowsRead"]!.AsArray()
            .Where(p => (decimal)p!["fldMge"]! != 0 && !SumFlags.Any(f => (bool?)p[f] == true))
            .Select(p => new
            {
                quantity = (decimal)p!["fldMge"]!,
                unitCode = _units.GetValueOrDefault(((string?)p["fldEinh"])?.Trim() ?? "", "C62"),
                item = new { name = (string?)p["fldBez"] },
                priceDetails = new
                {
                    netPrice = Math.Round((decimal)p["acoEPr"]!["totalNetAmount"]!
                                          * (1 - ((decimal?)p["fldRabSz"] ?? 0) / 100), 4),
                },
                vatInformation = new { rate = (decimal)p["fldStSchlSz"]! },
            })
            .ToList();

        var invoice = new
        {
            invoiceNumber = (string)doc["fldBelegNr"]!,
            issueDate = issueDate.ToString("yyyy-MM-dd"),
            dueDate = issueDate.AddDays((int?)doc["fldNettoTg"] ?? 0).ToString("yyyy-MM-dd"),
            currency = _currencies.GetValueOrDefault((string?)doc["fldWaehr"] ?? "", "EUR"),
            purchaseOrderReference = (string?)doc["fldBestellNr"],
            buyerReference = xrechnung ? leitwegId : null,
            seller = new
            {
                name = (string?)_mandant["fldNa1"],
                vatIdentifier = (string?)_mandant["fldUStId"],
                postalAddress = new
                {
                    line1 = (string?)_mandant["fldStr"], city = (string?)_mandant["fldOrt"],
                    postCode = (string?)_mandant["fldPLZ"], country = "DE",
                },
                contact = xrechnung ? new { name = _contact.Name, phone = _contact.Phone, email = _contact.Email } : null,
                electronicAddress = xrechnung ? new { identifier = (string?)_mandant["fldUStId"], schemeId = "9930" } : null,
            },
            buyer = new
            {
                name = (string?)doc["fldReNa2"],   // Name 2 holds the company name
                vatIdentifier = buyerVatId,
                postalAddress = new
                {
                    line1 = (string?)doc["fldReStr"], city = (string?)doc["fldReOrt"],
                    postCode = (string?)doc["fldRePLZ"], country = IsoCountry((string?)doc["fldReLandKennz"]),
                },
                electronicAddress = xrechnung ? new { identifier = buyerVatId, schemeId = "9930" } : null,
            },
            paymentDetails = new
            {
                paymentAccountIdentifier = (string?)_mandant["tblBnkVb"]!["rowsRead"]!.AsArray().FirstOrDefault()?["fldIBAN"],
            },
            lines,
        };
        return new MappedInvoice(invoice, (string?)doc["fldReEMail1"], leitwegId);
    }

    private static Dictionary<string, string> Lookup(JsonNode table, string key, string value) =>
        table["rowsRead"]!.AsArray()
            .Where(r => !string.IsNullOrWhiteSpace((string?)r![value]))
            .GroupBy(r => ((string)r![key]!).Trim())
            .ToDictionary(g => g.Key, g => ((string)g.First()![value]!).Trim());

    // Country codes of your Mandant to ISO 3166; extend for your customers
    private static string IsoCountry(string? code) => code?.Trim().ToUpperInvariant() switch
    {
        null or "" or "D" => "DE",
        "A" => "AT",
        var iso => iso,
    };
}

Units need no switch block in the code. büro+ keeps a UN/ECE code per unit in the field fldCodeEinh (for example H87 for Stück). Fill it once for every unit you use. A unit without a code falls back to C62 (one). Some Vorgänge carry a document discount (fldGPrVogRabSz). Compare one such test invoice with the büro+ total before you go live. If a Vorgang has no invoice address of its own, check in the Apollo Sandbox of your installation which fields hold the address.


Step 3: Create the ZUGFeRD invoice from the büro+ Vorgang

The create method maps the Vorgang and posts the body. The answer is the finished ZUGFeRD PDF.

// EInvoiceService.cs: create, store, email, find posted invoices
public sealed class EInvoiceService
{
    private readonly MicrotechClient _mt;
    private readonly InvoiceXmlClient _api;
    private readonly InvoiceMapper _mapper;
    private readonly EInvoiceLog _log;            // processed Belegnummern, in the service's own database
    private readonly EInvoiceSettings _settings;  // the "EInvoice" section of appsettings.json

    public EInvoiceService(MicrotechClient mt, InvoiceXmlClient api, InvoiceMapper mapper,
                           EInvoiceLog log, EInvoiceSettings settings)
        => (_mt, _api, _mapper, _log, _settings) = (mt, api, mapper, log, settings);

    public async Task<(byte[] File, string FileName)> CreateAsync(string belegNr)
    {
        var mapped = await _mapper.BuildAsync(belegNr);
        var pdf = await _api.CreateAsync("v1/create/zugferd", new { invoice = mapped.Invoice });
        return (pdf, $"{belegNr}-zugferd.pdf");
    }

For a French customer, post the same body to v1/create/facturx. Factur-X and ZUGFeRD share one standard, so the mapper stays the same. The Factur-X API toolkit has the French details.


Step 4: Store the PDF and record the invoice

The service saves the file in the archive folder from the configuration, with one subfolder per year. Then it writes the Belegnummer into its own log table. The service never changes the Vorgang in büro+. A timer starts the search for new invoices every PollMinutes minutes.

    public async Task StoreAsync(byte[] file, string fileName)
    {
        var folder = Path.Combine(_settings.ArchiveFolder, DateTime.Today.Year.ToString());
        Directory.CreateDirectory(folder);
        await File.WriteAllBytesAsync(Path.Combine(folder, fileName), file);
    }

    // Started by a timer: posted invoices of the configured Vorgangsarten without an e-invoice
    public async Task ProcessNewInvoicesAsync()
    {
        var since = DateTime.Today.AddDays(-_settings.LookBackDays).ToString("yyyy-MM-dd");
        var data = await _mt.RunAsync($$"""
            query {
              tblTransactions {
                rowsRead(fastFilter: { ge: [{ field: fldDat }, { value: {{MicrotechClient.Str(since)}} }] }) {
                  fldBelegNr
                  fldArt(as: DISPLAY_TEXT)
                  fldGebuchtKz(as: BOOLEAN)
                  fldStorniertKz(as: BOOLEAN)
                }
              }
            }
            """);

        foreach (var row in data["tblTransactions"]!["rowsRead"]!.AsArray())
        {
            if (!_settings.InvoiceTypes.Contains((string?)row!["fldArt"])) continue;
            if ((bool?)row["fldGebuchtKz"] != true || (bool?)row["fldStorniertKz"] == true) continue;
            var belegNr = (string)row["fldBelegNr"]!;
            if (await _log.ContainsAsync(belegNr)) continue;

            var (file, fileName) = await CreateAsync(belegNr);
            await StoreAsync(file, fileName);
            await _log.AddAsync(belegNr, "zugferd");
        }
    }

    // EInvoiceLog table (service database, not büro+):
    // CREATE TABLE EInvoiceLog (BelegNr nvarchar(20) PRIMARY KEY,
    //                           Route nvarchar(20) NOT NULL, ProcessedAt datetime2 NOT NULL)

Which invoices. The service compares the Vorgangsart by its name, for example Rechnung I. büro+ numbers Vorgangsarten per installation, so names are the safer key. Only posted Vorgänge (fldGebuchtKz) that are not cancelled get an e-invoice. The log table makes sure that no invoice is created twice. If a Vorgangsart in your installation moves invoices to the archive when they are posted, run the same query on tblTransactionsArchive as well.

The documents in büro+. The microtech DMS keeps documents with a link to their Vorgang. In the GraphQL table tblDocuments, this link is the field fldVogBelegNr. To write the PDF into the DMS from the service, check in the Apollo Sandbox how your build accepts file contents for tblDocuments. Until then, the archive folder is the place for the files.


Send the invoice by email from the API

The API can deliver the invoice for you. In one call it creates the ZUGFeRD PDF, validates it and emails it to the customer. The file never passes through your server. In Germany there is no central platform for B2B e-invoices. So an email that carries the ZUGFeRD PDF completes the delivery.

Start by verifying your sending domain in the dashboard. Add the domain there and publish the three CNAME records it shows. Then every address on that domain can send. Your customers see your own address and reply to your own mailbox. The body holds the invoice JSON and an email object. The recipient is fldReEMail1, the email address of the invoice address in the Vorgang:

    public async Task SendByEmailAsync(string belegNr)
    {
        var mapped = await _mapper.BuildAsync(belegNr);
        var to = mapped.Email ?? throw new InvalidOperationException($"Vorgang {belegNr}: keine E-Mail-Adresse");

        await _api.CreateAsync("v1/create/zugferd/email", new
        {
            invoice = mapped.Invoice,
            email = new
            {
                from = _settings.SenderAddress,   // an address on your verified domain
                to,
                subject = $"Rechnung {belegNr}",
                message = $"Guten Tag,\n\nanbei erhalten Sie die Rechnung {belegNr} als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen",
            },
        });
        // 200: created, validated and handed to delivery. The response holds no file.
        await _log.AddAsync(belegNr, "zugferd-email");
    }
}

Use this route when the API should send. Use the plain endpoint when büro+ should store the file and send it itself. XRechnung (/v1/create/xrechnung/email) and Factur-X have the same route.


Import incoming supplier invoices

German businesses have had to accept e-invoices since January 2025. They arrive as ZUGFeRD PDFs or XRechnung XML files. A mailbox or folder watcher passes each file to the service. The service handles it in three steps:

  1. Validate. Send PDFs to POST /v1/validate/zugferd/report and XML files to /v1/validate/xrechnung/report, with validationLanguage=de. The API checks EN 16931 and the German rules. The header X-Invoice-Valid carries the result. The response body is a German PDF report. For a JSON list of the findings, use /v1/validate/zugferd (no /report).
  2. Return invalid invoices. If X-Invoice-Valid is false, send the PDF report back to the sender and end here. The report names each failed rule and the field. The supplier can fix the invoice without a call to your team.
  3. Extract. If the file passed, send it to POST /v1/extract/json. The answer is the invoice as JSON, in the shape of the create body, plus the totals.
// InboundService.cs: validate, return the report or extract, create the Eingangsrechnung
public sealed class InboundService
{
    private readonly MicrotechClient _mt;
    private readonly InvoiceXmlClient _api;
    private readonly EInvoiceSettings _settings;   // cost article, input tax keys, inbound folder
    private readonly IMailer _mailer;              // your SMTP helper

    public InboundService(MicrotechClient mt, InvoiceXmlClient api, EInvoiceSettings settings, IMailer mailer)
        => (_mt, _api, _settings, _mailer) = (mt, api, settings, mailer);

    public async Task ProcessAsync(byte[] file, string fileName, string senderEmail)
    {
        // 1. Validate: XML is XRechnung, PDF is ZUGFeRD
        var isXml = Path.GetExtension(fileName).Equals(".xml", StringComparison.OrdinalIgnoreCase);
        var (valid, report) = await _api.ValidateWithReportAsync(
            isXml ? "v1/validate/xrechnung/report" : "v1/validate/zugferd/report", file, fileName);

        // 2. Invalid: the German report goes back to the supplier
        if (!valid)
        {
            await _mailer.SendAsync(senderEmail,
                subject: $"Ihre Rechnung {fileName} wurde nicht angenommen",
                text: "Die Prüfung nach EN 16931 ist fehlgeschlagen. Der Prüfbericht im Anhang nennt jede Regel und jedes Feld. Bitte senden Sie uns die korrigierte Rechnung.",
                attachment: ($"Pruefbericht-{Path.GetFileNameWithoutExtension(fileName)}.pdf", report));
            return;
        }

        // 3. Valid: read the data, create the Eingangsrechnung, keep the original file
        var extracted = await _api.ExtractAsync(file, fileName);
        var belegNr = await CreateIncomingInvoiceAsync(extracted["invoice"]!, extracted["invoice"]!["totals"]);
        Directory.CreateDirectory(_settings.InboundFolder);
        await File.WriteAllBytesAsync(Path.Combine(_settings.InboundFolder, $"{belegNr}{Path.GetExtension(fileName)}"), file);
    }

Plain PDFs. A few suppliers still send PDF files with no XML inside. These go to POST /v1/parse/json. Its AI reads typed, scanned and photographed invoices. The answer has the same JSON and a confidence object with scores between 0.0 and 1.0. Create the Eingangsrechnung when the scores are high and pass the rest to a person. A file that is no invoice gets the error code 4008.


Create the Eingangsrechnung from the JSON

Both extraction endpoints return the same JSON, so one method creates the büro+ Vorgang. It finds the supplier address by the VAT id (fldUStId) and checks it with fnIsVendor. Then one mutation creates a Vorgang of the Vorgangsart Eingangsrechnung. Each invoice line becomes a position on the cost article from the configuration. @acquireLocks locks the tables first, and the whole mutation succeeds or fails as one.

    private async Task<string> CreateIncomingInvoiceAsync(JsonNode invoice, JsonNode? totals)
    {
        // The supplier: an address with the seller's VAT id that is a vendor
        var vatId = (string)invoice["seller"]!["vatIdentifier"]!;
        var found = await _mt.RunAsync($$"""
            query {
              tblAddresses {
                rowsRead(fastFilter: { eq: [{ field: fldUStId }, { value: {{MicrotechClient.Str(vatId)}} }] }) {
                  fldAdrNr
                  fnIsVendor
                }
              }
            }
            """);
        var supplier = found["tblAddresses"]!["rowsRead"]!.AsArray().FirstOrDefault(a => (bool?)a!["fnIsVendor"] == true)
            ?? throw new InvalidOperationException($"Kein Lieferant mit USt-IdNr. {vatId}");

        // One position per invoice line, on the cost article, with the input tax key of its rate
        var positions = new StringBuilder();
        var n = 0;
        foreach (var line in invoice["lines"]!.AsArray())
        {
            var rate = ((decimal)line!["vatInformation"]!["rate"]!).ToString("0.##", CultureInfo.InvariantCulture);
            var taxKey = _settings.InputTaxKeys[rate];   // "19" -> your Vorsteuer key
            positions.Append($$"""
                p{{++n}}: rowNew(assignProduct: { kf1ArtNr: { text: {{MicrotechClient.Str(_settings.CostArticle)}} } }) {
                  fldMge(set: { float: {{MicrotechClient.Num((decimal)line["quantity"]!)}} })
                  fldBez(set: { text: {{MicrotechClient.Str((string?)line["item"]!["name"])}} })
                  fldStSchl(set: { int: {{taxKey}} })
                  fldEPreis(set: { float: {{MicrotechClient.Num((decimal)line["priceDetails"]!["netPrice"]!)}} })
                }
                """);
        }

        var info = $"Rechnung {invoice["invoiceNumber"]} vom {invoice["issueDate"]}, Gesamtbetrag {totals?["grandTotalAmount"]}";
        var result = await _mt.RunAsync($$"""
            mutation @acquireLocks(forWriting: [tblTransactions, tblTransactionItems],
                                   forReading: [tblAddresses, tblProducts]) {
              tblTransactions {
                rowNew(assignAddress: { kf1AdrNr: { text: {{MicrotechClient.Str(supplier["fldAdrNr"]!.ToString())}} } }) {
                  fldArt(set: { text: "Eingangsrechnung" })
                  fldIklStKz(set: { boolean: false })
                  fldDat(set: { text: {{MicrotechClient.Str((string?)invoice["issueDate"])}} })
                  fldInfo(set: { text: {{MicrotechClient.Str(info)}} })
                  rowSaveAndModify {
                    fldBelegNr
                    tblTransactionItems {
                      {{positions}}
                    }
                  }
                }
              }
            }
            """);
        var belegNr = (string?)result["tblTransactions"]!["rowNew"]!["rowSaveAndModify"]!["fldBelegNr"];
        return string.IsNullOrEmpty(belegNr) ? throw new InvalidOperationException("Eingangsrechnung nicht angelegt") : belegNr;
    }
}

Within a position, büro+ runs the fields in the order you write them. So the quantity comes first, then the tax key, then the price. Setting fldDat and fldEPreis needs two rights for the service user: Vorgangseingabe: Vorgangsdatum ändern and Wunschpreis eingeben. Without a right, büro+ answers with the error Argument "set" is not defined. The supplier's invoice number and the total go into fldInfo. The clerk compares the total with the büro+ total, checks the accounts and posts the Vorgang. The service does not post it.


XRechnung for German public buyers

Public buyers in Germany at federal, state and municipal level ask for XRechnung. An XRechnung is an XML file only, with no PDF part. The ZRE and OZG-RE portals test each file against the KoSIT rules. The mapper from step 2 already adds the extra fields when the Vorgang has a Leitweg-ID. The create method only needs to choose the endpoint:

    // EInvoiceService.CreateAsync, now with XRechnung for Vorgänge with a Leitweg-ID
    public async Task<(byte[] File, string FileName)> CreateAsync(string belegNr)
    {
        var mapped = await _mapper.BuildAsync(belegNr);
        var (path, suffix) = string.IsNullOrWhiteSpace(mapped.LeitwegId)
            ? ("v1/create/zugferd", "zugferd.pdf")
            : ("v1/create/xrechnung", "xrechnung.xml");   // the XRechnung answer is XML
        var file = await _api.CreateAsync(path, new { invoice = mapped.Invoice });
        return (file, $"{belegNr}-{suffix}");
    }

The Leitweg-ID is the routing id that the public buyer gives you. Without it, the XRechnung fails rule BR-DE-15. In büro+, the Vorgang has its own Leitweg-ID field, fldLtwgID in the GraphQL API. Enter it in the Vorgang before the invoice is posted. The mapper puts it into buyerReference. The seller contact comes from the service configuration. The electronic addresses use the VAT ids with scheme 9930. For Peppol, use the Peppol participant id and its scheme instead.

The API returns the XML after the KoSIT check. Deliver it the way the buyer asks: upload it to ZRE or OZG-RE, send it over Peppol, or email it where the buyer accepts email. To keep a readable copy, send the XML to /v1/render/xrechnung/to/pdf and store the PDF it returns. The XRechnung API toolkit covers the format in depth.


büro+ versions, interfaces and hosting

Version and license. The GraphQL API is part of microtech büro+ Gen. 24. Gen. 24 comes in the editions Basic, Core and Enterprise. The GraphQL license comes with Gen. 24 Enterprise or with a partner app license. The flag GraphQL aktiviert only appears in the Mandant when the license is present. From build 7145, büro+ starts one endpoint per Mandant by itself. Older installations ran a GraphQL server as an Automatisierungs-Dienst with its own URL, and these keep working.

Server setup. Switch on HTTP/2 for the data server and store an SSL certificate in the server configuration (BpConfig.exe). The API needs HTTPS with a domain name, not an IP address. You activate the endpoint under Datei, Informationen, Aktuelle Firma / Filiale / Mandant, Mandant bearbeiten, register Weitere Angaben. The status page https://<server>/microtech/erp/mand/ lists the running endpoints without a login. In BpServer.ini, the section [GraphQLRelay] sets how many executor instances run per endpoint (MaxInstances, default 10).

Test the access. Before you start the service, test the token and one query with curl. This query also lists the tax keys you need for InputTaxKeys:

# 1. Get a token with the client credentials of the OAuth application
curl -s -X POST "https://erp.ihre-firma.de/microtech/erp/mand/01/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=client_credentials&scope=graphql"

# 2. Run a test query with the access_token from the answer
curl -s -X POST "https://erp.ihre-firma.de/microtech/erp/mand/01/graphql/v1" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ tblValueAddedTaxTypes { rowsRead { fldStSchl fldBez fldSz(as: FLOAT) } } }"}'

In the Enterprise edition, the endpoint URL opens the Apollo Sandbox in the browser. There you can try queries and look up fields with __type(name: "..."). Query single types only. The full schema is very large.

Several Mandanten. Each Mandant has its own endpoint, for example mand/01 and mand/02. Register one OAuth application per Mandant and run the service once per Mandant with its own settings.

Other interfaces. büro+ also has a COM interface for external programs and an import and export interface for XLS, XLSX, TXT and CSV files. This guide uses GraphQL because it works over HTTPS, needs no client installation and can read and write Vorgänge with positions in one call.

Hosting. microtech runs büro+ on premises or in a private cloud. The service works with both. It only needs HTTPS access to the domain of the data server. In a private cloud, run the service on a machine that can reach that domain.


Endpoint reference

PurposeEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdInvoice JSONHybrid PDF/A-3
Create and email ZUGFeRDPOST /v1/create/zugferd/emailInvoice JSON + email objectEmail sent from your domain
Create XRechnungPOST /v1/create/xrechnungInvoice JSON with Leitweg-IDXRechnung XML
Create and email XRechnungPOST /v1/create/xrechnung/emailInvoice JSON + email objectEmail sent from your domain
Create Factur-XPOST /v1/create/facturxInvoice JSONHybrid PDF/A-3
Validate with PDF reportPOST /v1/validate/zugferd/report, /xrechnung/reportPDF or XML filePDF report, X-Invoice-Valid header
Validate as JSONPOST /v1/validate/zugferd, /xrechnungPDF or XML fileFindings per rule
Extract structured dataPOST /v1/extract/jsonHybrid PDF or XML fileInvoice JSON
Parse plain PDFPOST /v1/parse/jsonAny PDF or imageInvoice JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsHybrid PDF or XML fileZIP of embedded files
Render XRechnungPOST /v1/render/xrechnung/to/pdfXRechnung XMLReadable PDF

Get started

The whole integration is five classes and one log table. The service reads posted Vorgänge through the büro+ GraphQL API and turns them into ZUGFeRD or XRechnung files. It stores them or lets the API email them. It also checks supplier invoices and creates Eingangsrechnungen. büro+ stays unchanged, and the service holds no format rules, so new format versions need no new build. The integrations hub lists guides for more platforms, such as SAP Business One, Dynamics NAV and Odoo.

Related guides:

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