Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in Sage 100: E-Invoicing with a Small C# Service and One API Call

Many German and Austrian companies run Sage 100, the ERP formerly known as Sage Office Line. German law now requires e-invoices. In this guide, a small C# service reads sales invoices from the Sage 100 database. One REST API call turns each one into a ZUGFeRD invoice. It also covers email delivery by the API, XRechnung for public buyers, and checking supplier invoices before they reach the Buchungsimport.

This guide connects Sage 100 to ZUGFeRD and XRechnung with one REST API. It is about Sage 100 by Sage GmbH, the ERP for Germany and Austria that was called Sage Office Line before. It is not about the US product Sage 100 (formerly MAS 90). You build a small C# service that runs next to Sage 100. It reads saved sales invoices from the Sage 100 SQL Server database, read-only. It sends one JSON body per invoice and stores the returned PDF. The email section lets the API deliver the invoice to the customer. The import section checks supplier invoices and hands them to the Buchungsimport. The service follows the same pattern as the SAP Business One guide.


Why Sage 100 needs ZUGFeRD now

German companies on Sage 100 must send and receive e-invoices. Receiving became mandatory in January 2025. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must send e-invoices. From January 2028, every business must. A PDF printed from a Sage 100 report form is a plain PDF. An e-invoice carries structured XML that software can read. ZUGFeRD puts this XML inside the PDF, so people and software read the same file. The Germany mandate page lists the dates and formats.

One service for every Sage 100 customer. Current Sage 100 versions can also write e-invoices in the print process. The service in this guide is a second path that runs next to Sage 100. Partners install it the same way at every customer. It validates every invoice before it leaves the house. It can send the invoice from the customer's own mail domain. It checks incoming invoices and sends a German report back to the supplier.

Format updates do not reach the service. ZUGFeRD, XRechnung and the EN 16931 rules get new versions. The API switches to each version on its effective date. The service sends the same JSON before and after. No Sage 100 update and no new service release is needed for a format change.


What you need

  • Sage 100 9.0 with any service pack, as Sage 100 or Sage 100cloud, on Microsoft SQL Server. For older releases and hosted installations, see versions and hosting.
  • A Windows server in the customer network with the .NET 8 runtime. It needs access to the SQL Server and HTTPS to api.invoicexml.com. The Sage 100 application server or a small virtual machine is enough.
  • A Windows service account. It gets SELECT rights on six Sage 100 tables and its own small database. The script is in step 4.
  • Clean customer data in Sage 100: address, VAT id (EU-UStID) and email address, plus the Leitweg-ID for public buyers. For customers that the service handles, leave the e-invoice setting in the Sage 100 customer master switched off. Then each invoice is created once.
  • 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 saves a sales invoice in the Sage 100 Warenwirtschaft. The service finds it in the next polling run. It reads the document, the positions and the customer account and builds one JSON body. The API calculates totals and VAT, creates the XML, embeds it in a PDF/A-3 file and validates it. The service writes the PDF to the archive folder and records the invoice in its own table. Or the API emails the invoice to the customer directly.

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

Every endpoint takes the same JSON body. The service is a .NET Worker Service with these parts. You build them from the listings below:

  • InvoiceXmlClient: the HTTPS calls to the API, for JSON bodies and file uploads.
  • InvoiceMapper: two SQL queries against Sage 100 and the JSON body built from them.
  • EInvoiceService: finds new invoices, creates the file, stores it, records it, sends it by email.
  • Worker: the polling loop that runs as a Windows service.
  • InboundService and BookingExport: check supplier invoices, reply with the report, prepare the Buchungsimport.

From Sage 100, the service uses only standard tables (KHKVKBelege, KHKVKBelegePositionen, KHKVKBelegeZKD, KHKKontokorrent, KHKAdressen, KHKSteuertabelle) and the Buchungsimport of the Rechnungswesen.


Step 1: Store the API key and call the API

All settings live in appsettings.json. The connection string uses Windows authentication, so no SQL password is stored. Leave the API key empty in the file. Set it as the environment variable InvoiceXml__ApiKey for the service account, or load it from your secret store. .NET reads both into the same setting.

{
  "Sage": {
    "ConnectionString": "Server=SQL01;Database=Sage100;Integrated Security=true;TrustServerCertificate=true",
    "Mandant": 1,
    "InvoiceTypes": [ "VFR" ],
    "StartDate": "2026-11-01",
    "PollSeconds": 60,
    "ArchiveFolder": "\\\\FS01\\Archiv\\E-Rechnungen",
    "InboundFolder": "\\\\FS01\\Rewe\\Buchungsimport"
  },
  "Seller": {
    "Name": "Muster Maschinenbau GmbH", "VatId": "DE123456789",
    "Street": "Industriestraße 4", "City": "Kassel", "PostCode": "34117", "Country": "DE",
    "Iban": "DE02120300000000202051", "Phone": "+49 561 123450", "Email": "[email protected]"
  },
  "Mail": { "From": "[email protected]" },
  "Inbound": { "ExpenseAccount": "3400", "TaxCodes": { "19": "9", "7": "8" } },
  "InvoiceXml": { "ApiKey": "" }
}

The API client has one method for JSON bodies and two for file uploads. The upload endpoints take a multipart form with one field named file.

// InvoiceXmlClient.cs: every call to api.invoicexml.com
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

public sealed class InvoiceXmlException(string body) : Exception(body);

public sealed class InvoiceXmlClient
{
    // Null properties are left out of the JSON, so B2B invoices carry no buyerReference
    private static readonly JsonSerializerOptions JsonOptions =
        new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };

    private readonly HttpClient _http = new() { BaseAddress = new Uri("https://api.invoicexml.com/") };

    public InvoiceXmlClient(IConfiguration config) =>
        _http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", config["InvoiceXml:ApiKey"]);

    // Create endpoints: JSON in, PDF or XML out
    public async Task<byte[]> CreateAsync(string path, object body)
    {
        using var response = await _http.PostAsJsonAsync(path, body, JsonOptions);
        if (!response.IsSuccessStatusCode)   // 400: an errors array with rule id and JSON path
            throw new InvoiceXmlException(await response.Content.ReadAsStringAsync());
        return await response.Content.ReadAsByteArrayAsync();
    }

    // Report endpoints: the verdict is in a header, the body is the PDF report
    public async Task<(bool Valid, byte[] ReportPdf)> ValidateWithReportAsync(string path, byte[] file, string fileName)
    {
        using var form = FileForm(file, fileName);
        form.Add(new StringContent("de"), "validationLanguage");
        using var response = await _http.PostAsync(path, form);
        response.EnsureSuccessStatusCode();
        var valid = response.Headers.TryGetValues("X-Invoice-Valid", out var values)
                    && values.Single() == "true";
        return (valid, await response.Content.ReadAsByteArrayAsync());
    }

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

    private static MultipartFormDataContent FileForm(byte[] file, string fileName)
    {
        var form = new MultipartFormDataContent();
        form.Add(new ByteArrayContent(file), "file", fileName);
        return form;
    }
}

The service needs three NuGet packages: Microsoft.Data.SqlClient for SQL Server, Dapper for the queries and Microsoft.Extensions.Hosting.WindowsServices to run as a Windows service.


Step 2: Map Sage 100 fields to JSON

The API takes an EN 16931 invoice as JSON. The invoice data comes from the Sage 100 tables. The seller data (the Mandant) comes from the Seller section of the configuration. This is the mapping:

Sage 100 fieldJSON pathNote
KHKVKBelege.Belegnummerinvoice.invoiceNumberBT-1. BelID is the internal key
KHKVKBelege.Belegdatuminvoice.issueDateFormat yyyy-MM-dd
KHKVKBelegeZKD.Faelligkeitinvoice.dueDateRow with IstStandard = -1, the main payment term
KHKVKBelege.WKzinvoice.currencyCurrency key, for example EUR
KHKVKBelege.Referenznummerinvoice.purchaseOrderReference"Ihre Belegnummer" in the document, BT-13
Configurationinvoice.sellerName, VAT id, address, IBAN, contact
A0Name1, A0Strasse, A0PLZ, A0Ort, A0Landinvoice.buyerThe A2 fields when the invoice goes to a different recipient
KHKKontokorrent.EULand + EUUStIDinvoice.buyer.vatIdentifierAccount from KHKVKBelege.Rechnungsempfaenger
KHKVKBelegePositionen.Bezeichnung1lines[].item.nameOnly PosType = 1 (article lines)
Menge, Mengeneinheitlines[].quantity, lines[].unitCodeUN/ECE code: H87 pieces, HUR hours
Einzelpreis, Preiseinheit, Rabattlines[].priceDetails.netPricePrice per unit after the line discount
Steuercode to KHKSteuertabelle.Steuersatzlines[].vatInformation.rate19, 7 or 0
KHKKontokorrent.LeitwegIDinvoice.buyerReferenceXRechnung only

Do not send totals, VAT amounts or rounding. The API calculates them from the lines. If the numbers do not add up, the API returns an error and no file. You see the problem before the customer does.

// InvoiceMapper.cs: one Sage 100 sales invoice -> InvoiceXML JSON
using Dapper;
using Microsoft.Data.SqlClient;

public sealed record SellerSettings(string Name, string VatId, string Street, string City,
    string PostCode, string Country, string Iban, string Phone, string Email);

public sealed record MappedInvoice(object Invoice, string Number, string? Email, bool IsPublicBuyer);

public sealed class InvoiceMapper(IConfiguration config, SellerSettings seller)
{
    // Header: A2 address if the invoice goes to a different recipient, else A0
    private const string HeaderSql = """
        SELECT b.Belegnummer, b.Belegdatum, b.WKz, b.Referenznummer,
               IIF(b.A2Name1 > '', b.A2Name1,   b.A0Name1)   AS Name,
               IIF(b.A2Name1 > '', b.A2Strasse, b.A0Strasse) AS Street,
               IIF(b.A2Name1 > '', b.A2PLZ,     b.A0PLZ)     AS PostCode,
               IIF(b.A2Name1 > '', b.A2Ort,     b.A0Ort)     AS City,
               IIF(b.A2Name1 > '', b.A2Land,    b.A0Land)    AS Country,
               k.EULand, k.EUUStID, k.LeitwegID, a.EMail, z.Faelligkeit
        FROM KHKVKBelege b
        JOIN KHKKontokorrent k ON k.Mandant = b.Mandant AND k.Kto = b.Rechnungsempfaenger AND k.KtoArt = 'D'
        JOIN KHKAdressen a     ON a.Mandant = k.Mandant AND a.Adresse = k.Adresse
        LEFT JOIN KHKVKBelegeZKD z ON z.Mandant = b.Mandant AND z.BelID = b.BelID AND z.IstStandard = -1
        WHERE b.Mandant = @Mandant AND b.BelID = @BelID
        """;

    // Positions: article lines only; the VAT rate comes from the tax code
    private const string LinesSql = """
        SELECT p.Bezeichnung1, p.Menge, p.Mengeneinheit, t.Steuersatz,
               ROUND(p.Einzelpreis / ISNULL(NULLIF(p.Preiseinheit, 0), 1)
                     * (1 - ISNULL(p.Rabatt, 0) / 100), 4) AS NetPrice
        FROM KHKVKBelegePositionen p
        LEFT JOIN KHKSteuertabelle t ON t.Steuercode = p.Steuercode
        WHERE p.Mandant = @Mandant AND p.BelID = @BelID AND p.PosType = 1   -- 0 group, 2 sum, 3 text
        ORDER BY p.[Index]
        """;

    public async Task<MappedInvoice> BuildAsync(int belId)
    {
        await using var db = new SqlConnection(config["Sage:ConnectionString"]);
        var key = new { Mandant = config.GetValue<short>("Sage:Mandant"), BelID = belId };
        var h = await db.QueryFirstAsync(HeaderSql, key);
        var rows = await db.QueryAsync(LinesSql, key);

        var number = ((int)h.Belegnummer).ToString();
        var buyerVat = VatId((string?)h.EULand, (string?)h.EUUStID);
        var leitwegId = NullIfEmpty((string?)h.LeitwegID);

        var invoice = new
        {
            invoiceNumber = number,
            issueDate = ((DateTime)h.Belegdatum).ToString("yyyy-MM-dd"),
            dueDate = ((DateTime?)h.Faelligkeit)?.ToString("yyyy-MM-dd"),
            currency = (string)h.WKz,
            purchaseOrderReference = NullIfEmpty((string?)h.Referenznummer),
            buyerReference = leitwegId,                        // public buyers only, null otherwise
            seller = new
            {
                name = seller.Name,
                vatIdentifier = seller.VatId,
                postalAddress = new { line1 = seller.Street, city = seller.City, postCode = seller.PostCode, country = seller.Country },
                contact = new { name = "Buchhaltung", phone = seller.Phone, email = seller.Email },
                electronicAddress = new { identifier = seller.VatId, schemeId = "9930" },   // 9930 = German VAT id
            },
            buyer = new
            {
                name = (string)h.Name,
                vatIdentifier = buyerVat,
                postalAddress = new
                {
                    line1 = (string?)h.Street,
                    city = (string?)h.City,
                    postCode = (string?)h.PostCode,
                    country = IsoCountry((string?)h.Country),
                },
                electronicAddress = buyerVat is not null ? new { identifier = buyerVat, schemeId = "9930" }
                                  : leitwegId is not null ? new { identifier = leitwegId, schemeId = "0204" }
                                  : null,
            },
            paymentDetails = new { paymentAccountIdentifier = seller.Iban },
            lines = rows.Select(p => new
            {
                quantity = (decimal)p.Menge,
                unitCode = UnitCode((string?)p.Mengeneinheit),
                item = new { name = (string)p.Bezeichnung1 },
                priceDetails = new { netPrice = (decimal)p.NetPrice },
                vatInformation = new { rate = (decimal)(p.Steuersatz ?? 0m) },
            }).ToList(),
        };
        return new MappedInvoice(invoice, number, NullIfEmpty((string?)h.EMail), leitwegId is not null);
    }

    private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();

    // Sage 100 splits the VAT id into the country prefix (EULand) and the number (EUUStID)
    private static string? VatId(string? euLand, string? number)
    {
        number = NullIfEmpty(number)?.Replace(" ", "");
        if (number is null) return null;
        return char.IsLetter(number[0]) ? number : (NullIfEmpty(euLand) ?? "DE") + number;
    }

    // A0Land holds the Sage 100 country key; the API wants the ISO code
    private static string IsoCountry(string? land) => NullIfEmpty(land) switch
    {
        null or "D" => "DE",
        "A" => "AT",
        "F" => "FR",
        var key => key,
    };

    // UN/ECE Recommendation 20 codes for the Mengeneinheiten you use
    private static string UnitCode(string? unit) => NullIfEmpty(unit)?.ToLowerInvariant() switch
    {
        "stk" or "st" => "H87",
        "std" or "h" => "HUR",
        "kg" => "KGM",
        "m" => "MTR",
        "l" => "LTR",
        _ => "C62",
    };
}

The table and column names come from the Sage 100 9.0 database documentation. Check them in your installation with SQL Server Management Studio before the first run. The same goes for the country keys in A0Land and the unit names in Mengeneinheit. The queries expect net prices, which is the normal setting for B2B documents. Footer discounts (Rabatt1 to Rabatt3) must also go into the net price of the discountable lines.


Step 3: Create the ZUGFeRD invoice

The mapper returns the invoice object. One call turns it into a ZUGFeRD PDF. The method returns the bytes, so the caller decides where the file goes.

// EInvoiceService.cs: create, store, record, send
using Dapper;
using Microsoft.Data.SqlClient;

public sealed class EInvoiceService(InvoiceMapper mapper, InvoiceXmlClient api,
    SellerSettings seller, IConfiguration config)
{
    private short Mandant => config.GetValue<short>("Sage:Mandant");

    // ZUGFeRD for a business customer: a PDF/A-3 file with the XML inside
    public async Task<byte[]> CreateZugferdAsync(int belId)
    {
        var mapped = await mapper.BuildAsync(belId);
        return await api.CreateAsync("v1/create/zugferd", new { invoice = mapped.Invoice });
    }

Customers in France get Factur-X. Post the same body to v1/create/facturx when the buyer country is FR. ZUGFeRD and Factur-X are one standard with two names. The Factur-X API toolkit covers the French rules.


Step 4: Store the file and mark the invoice

The service never writes to Sage 100 tables. It keeps its own small database, EInvoice, on the same SQL Server. The table ProcessedInvoices records every invoice the service has handled. A second table stages supplier invoices for the import section. Run this script once as an administrator. It also creates the read-only access for the service account:

-- Run once on the SQL Server of the Sage 100 installation
USE master;
CREATE LOGIN [FIRMA\svc-einvoice] FROM WINDOWS;
CREATE DATABASE EInvoice;
GO
USE Sage100;   -- your Sage 100 database
CREATE USER [FIRMA\svc-einvoice] FOR LOGIN [FIRMA\svc-einvoice];
GRANT SELECT ON dbo.KHKVKBelege            TO [FIRMA\svc-einvoice];
GRANT SELECT ON dbo.KHKVKBelegePositionen  TO [FIRMA\svc-einvoice];
GRANT SELECT ON dbo.KHKVKBelegeZKD         TO [FIRMA\svc-einvoice];
GRANT SELECT ON dbo.KHKKontokorrent        TO [FIRMA\svc-einvoice];
GRANT SELECT ON dbo.KHKAdressen            TO [FIRMA\svc-einvoice];
GRANT SELECT ON dbo.KHKSteuertabelle       TO [FIRMA\svc-einvoice];
GO
USE EInvoice;
CREATE USER [FIRMA\svc-einvoice] FOR LOGIN [FIRMA\svc-einvoice];
ALTER ROLE db_owner ADD MEMBER [FIRMA\svc-einvoice];
CREATE TABLE dbo.ProcessedInvoices (
    Mandant   smallint      NOT NULL,
    BelID     int           NOT NULL,
    Status    varchar(10)   NOT NULL,     -- zugferd, xrechnung, email, error
    FilePath  nvarchar(400) NULL,
    Error     nvarchar(max) NULL,
    CreatedAt datetime2     NOT NULL DEFAULT sysutcdatetime(),
    CONSTRAINT PK_ProcessedInvoices PRIMARY KEY (Mandant, BelID)
);
CREATE TABLE dbo.InboundInvoices (
    Id          int IDENTITY  PRIMARY KEY,
    Mandant     smallint      NOT NULL,
    FileName    nvarchar(260) NOT NULL,
    Kreditor    varchar(20)   NULL,
    InvoiceJson nvarchar(max) NOT NULL,
    ReceivedAt  datetime2     NOT NULL DEFAULT sysutcdatetime()
);

The service writes each file to a folder per Mandant. Point your document archive at this folder. If the customer uses the Sage DMS, the archive configuration has a document inbox (the DocumentInbox setting in USysDMSArchiveConfiguration). Check in your DMS how files from that inbox are indexed and linked to the sales document. The file name carries the Sage 100 document number for this.

    // Choose the format by the customer, create the file, store it, record it
    public async Task ProcessAsync(int belId)
    {
        var mapped = await mapper.BuildAsync(belId);
        var (endpoint, status, extension) = mapped.IsPublicBuyer
            ? ("v1/create/xrechnung", "xrechnung", "xml")
            : ("v1/create/zugferd", "zugferd", "pdf");
        var file = await api.CreateAsync(endpoint, new { invoice = mapped.Invoice });

        var folder = Path.Combine(config["Sage:ArchiveFolder"]!, Mandant.ToString());
        Directory.CreateDirectory(folder);
        var path = Path.Combine(folder, $"Rechnung-{mapped.Number}.{extension}");
        await File.WriteAllBytesAsync(path, file);
        await MarkAsync(belId, status, path, error: null);
    }

    public Task MarkFailedAsync(int belId, string error) => MarkAsync(belId, "error", null, error);

    private async Task MarkAsync(int belId, string status, string? path, string? error)
    {
        await using var db = new SqlConnection(config["Sage:ConnectionString"]);
        await db.ExecuteAsync("""
            INSERT INTO EInvoice.dbo.ProcessedInvoices (Mandant, BelID, Status, FilePath, Error)
            VALUES (@Mandant, @BelID, @Status, @Path, @Error)
            """, new { Mandant, BelID = belId, Status = status, Path = path, Error = error });
    }

    // Saved invoices of the configured types that the service has not handled yet
    public async Task<IEnumerable<int>> FindNewInvoicesAsync()
    {
        await using var db = new SqlConnection(config["Sage:ConnectionString"]);
        return await db.QueryAsync<int>("""
            SELECT b.BelID FROM KHKVKBelege b
            WHERE b.Mandant = @Mandant
              AND b.Belegkennzeichen IN @Types   -- VFR = Rechnung
              AND b.SaveStatus = 2               -- 0 new, 1 parked, 2 saved
              AND b.Belegdatum >= @Since
              AND NOT EXISTS (SELECT 1 FROM EInvoice.dbo.ProcessedInvoices x
                              WHERE x.Mandant = b.Mandant AND x.BelID = b.BelID)
            ORDER BY b.BelID
            """, new
            {
                Mandant,
                Types = config.GetSection("Sage:InvoiceTypes").Get<string[]>(),
                Since = config.GetValue<DateTime>("Sage:StartDate"),
            });
    }
}

// Worker.cs: the polling loop
public sealed class Worker(EInvoiceService service, IConfiguration config, ILogger<Worker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(config.GetValue<int>("Sage:PollSeconds")));
        do
        {
            foreach (var belId in await service.FindNewInvoicesAsync())
            {
                try { await service.ProcessAsync(belId); }
                catch (InvoiceXmlException ex)
                {
                    // The errors array names each broken rule and the JSON path of the field
                    log.LogWarning("BelID {BelID} rejected: {Errors}", belId, ex.Message);
                    await service.MarkFailedAsync(belId, ex.Message);
                }
            }
        } while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}

// Program.cs: runs as a Windows service
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService();
builder.Services.AddSingleton(builder.Configuration.GetSection("Seller").Get<SellerSettings>()!);
builder.Services.AddSingleton<InvoiceXmlClient>();
builder.Services.AddSingleton<InvoiceMapper>();
builder.Services.AddSingleton<EInvoiceService>();
builder.Services.AddHostedService<Worker>();
builder.Build().Run();

Which documents. InvoiceTypes lists the Belegkennzeichen to process. VFR is the standard Rechnung. Add your own invoice types if you created any. SaveStatus = 2 skips parked documents. StartDate keeps older invoices out of the first run.

When to run it. The worker checks every PollSeconds seconds, so a new invoice gets its e-invoice within a minute. A rejected invoice gets the status error with the API message in the tracking table. Fix the data in Sage 100, delete the row, and the next run tries again. For a button in Sage 100, use the AppDesigner. Add a context-menu entry to the sales document list. The entry runs a macro with the command AufrufenDll. The DLL lies in the Sage 100 Shared folder of the application server. Its class derives from MacroProcessBase, and Sage 100 calls its Execute method with the fields you pass, here BelID. In Execute, call ProcessAsync for that one document. The base class comes with the Sage 100 SDK for partners.


Send the invoice by email from the API

The API can also deliver the invoice. One call creates the ZUGFeRD PDF, validates it and emails it to the customer. The file never lands on your server. For Germany, this is all the delivery you need. There is no central platform for B2B e-invoices, and an email with a ZUGFeRD PDF is a valid delivery.

First, verify your sending domain in the dashboard. Add the domain and publish the three CNAME records the dashboard shows. After that, any address on that domain can be the sender. Customers see your address, and their replies come back to you. The request body is the invoice JSON plus an email object. The recipient is the EMail field of the invoice recipient in KHKAdressen. The mapper already reads it:

    // EInvoiceService.cs, one more method: create, validate and send in one call
    public async Task SendByEmailAsync(int belId)
    {
        var mapped = await mapper.BuildAsync(belId);
        var to = mapped.Email
            ?? throw new InvalidOperationException($"BelID {belId}: the invoice recipient has no email address");

        await api.CreateAsync("v1/create/zugferd/email", new
        {
            invoice = mapped.Invoice,
            email = new
            {
                from = config["Mail:From"],   // an address on your verified sending domain
                fromName = seller.Name,
                to,
                subject = $"Rechnung {mapped.Number} von {seller.Name}",
                message = $"Guten Tag,\n\nim Anhang finden Sie unsere Rechnung {mapped.Number} als ZUGFeRD-PDF.\n\nFreundliche Grüße\n{seller.Name}",
            },
        });
        // 200 = created, validated and handed to delivery; the response holds no file
        await MarkAsync(belId, "email", path: null, error: null);
    }

Use this route when the API should send. Use the plain endpoint when Sage 100 should store the file and send it itself. In the worker, call SendByEmailAsync instead of ProcessAsync for the customers that get their invoices by email. The same route exists as /v1/create/xrechnung/email and /v1/create/facturx/email.


Import incoming supplier invoices

German companies must accept e-invoices since January 2025. Suppliers send ZUGFeRD PDFs and XRechnung XML files. The service checks each file before anyone in accounts payable sees it. A mailbox or folder watcher passes the files in. The pipeline has three steps:

  1. Validate. Post the file to POST /v1/validate/zugferd/report (PDF) or /v1/validate/xrechnung/report (XML). The form field validationLanguage=de makes the report German. The header X-Invoice-Valid says true or false. The body is the PDF report. To get the findings as JSON instead, call /v1/validate/zugferd without /report.
  2. Reply if invalid. Email the PDF report to the sender and stop. The report names every broken rule and the field it concerns. The supplier corrects the invoice and sends it again.
  3. Extract if valid. Post the file to POST /v1/extract/json. The API reads the XML and returns it as JSON in the same shape as the create request, plus the totals.
// InboundService.cs: validate, reply with the report, extract, hand over
using System.Text.Json.Nodes;

public interface IMailer   // your SMTP or Microsoft Graph wrapper
{
    Task SendAsync(string to, string subject, string text, (string Name, byte[] Content) attachment);
}

public sealed class InboundService(InvoiceXmlClient api, IMailer mailer, BookingExport booking)
{
    public async Task ProcessAsync(byte[] file, string fileName, string senderEmail)
    {
        // 1. Validate: XML is XRechnung, PDF is ZUGFeRD; the report is in German
        var reportPath = fileName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)
            ? "v1/validate/xrechnung/report"
            : "v1/validate/zugferd/report";
        var (valid, reportPdf) = await api.ValidateWithReportAsync(reportPath, file, fileName);

        // 2. Invalid: the supplier gets the report, nothing reaches Sage 100
        if (!valid)
        {
            await mailer.SendAsync(senderEmail,
                $"E-Rechnung {fileName}: Prüfung nicht bestanden",
                "Ihre E-Rechnung verletzt Regeln der EN 16931. Der Prüfbericht im Anhang nennt jede Regel und jedes Feld. Bitte senden Sie die Rechnung korrigiert erneut.",
                ($"Pruefbericht-{Path.GetFileNameWithoutExtension(fileName)}.pdf", reportPdf));
            return;
        }

        // 3. Valid: read the data and stage it for the Buchungsimport
        var extracted = await api.ExtractAsync(file, fileName);
        await booking.AddAsync(fileName, extracted["invoice"]!);
    }
}

PDFs without XML. Some suppliers still send plain PDFs. Post those to POST /v1/parse/json. The API reads typed, scanned and photographed invoices with AI. It returns the same JSON plus a confidence object with scores from 0.0 to 1.0. Stage high scores directly and route the rest to a person. Files that are not invoices are rejected with error code 4008.


Hand the supplier invoice to Sage 100

The service hands valid invoices to Sage 100 through the documented Buchungsimport of the Rechnungswesen. It does not write into Sage 100 tables. It does two things per invoice. It stores the full JSON in its own table InboundInvoices. And it adds booking lines to a daily CSV file. The supplier comes from KHKKontokorrent with KtoArt = 'K' and the VAT id from the invoice. The Buchungsimport reads single-line bookings without splits, so the file gets one line per VAT rate.

// BookingExport.cs: staging row + CSV lines for the Buchungsimport
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using Dapper;
using Microsoft.Data.SqlClient;

public sealed class BookingExport(IConfiguration config)
{
    public async Task AddAsync(string fileName, JsonNode invoice)
    {
        var mandant = config.GetValue<short>("Sage:Mandant");
        var vatId = (string?)invoice["seller"]?["vatIdentifier"];
        await using var db = new SqlConnection(config["Sage:ConnectionString"]);

        // The supplier account (KtoArt K) with the VAT id from the invoice
        var kreditor = await db.QueryFirstOrDefaultAsync<string>("""
            SELECT TOP 1 Kto FROM KHKKontokorrent
            WHERE Mandant = @Mandant AND KtoArt = 'K'
              AND (EULand + EUUStID = @VatId OR EUUStID = @VatId)
            """, new { Mandant = mandant, VatId = vatId });

        // The full JSON stays in the service's own table
        await db.ExecuteAsync("""
            INSERT INTO EInvoice.dbo.InboundInvoices (Mandant, FileName, Kreditor, InvoiceJson)
            VALUES (@Mandant, @FileName, @Kreditor, @Json)
            """, new { Mandant = mandant, FileName = fileName, Kreditor = kreditor, Json = invoice.ToJsonString() });

        // One booking line per VAT rate
        var number = (string?)invoice["invoiceNumber"];
        var total = (decimal?)invoice["totals"]?["grandTotalAmount"];
        var csv = new StringBuilder();
        foreach (var rateGroup in invoice["lines"]!.AsArray().GroupBy(l => (decimal)l!["vatInformation"]!["rate"]!))
        {
            var net = Math.Round(rateGroup.Sum(l => (decimal)l!["quantity"]! * (decimal)l["priceDetails"]!["netPrice"]!), 2);
            var gross = Math.Round(net * (1 + rateGroup.Key / 100), 2);
            var rate = rateGroup.Key.ToString("0.##", CultureInfo.InvariantCulture);
            csv.AppendLine(string.Join(';',
                (string?)invoice["issueDate"],
                number,
                kreditor ?? "UNBEKANNT",                    // no match: the import marks the line as an error
                config["Inbound:ExpenseAccount"],
                config[$"Inbound:TaxCodes:{rate}"],         // your input tax code for this rate
                net.ToString(CultureInfo.InvariantCulture),
                gross.ToString(CultureInfo.InvariantCulture),
                $"ER {number} Summe {total}"));
        }

        var path = Path.Combine(config["Sage:InboundFolder"]!, $"Eingangsrechnungen-{DateTime.Today:yyyy-MM-dd}.csv");
        if (!File.Exists(path))
            await File.WriteAllTextAsync(path, "Belegdatum;Belegnummer;Kreditor;Gegenkonto;Steuercode;Netto;Brutto;Buchungstext\n");
        await File.AppendAllTextAsync(path, csv.ToString());
    }
}

Import the file in Sage 100. Open Rechnungswesen, Buchungserfassung, Buchungsimport and click Importieren. The Buchungsimport-Assistent creates an import definition once. Choose the semicolon as separator, the point as decimal sign and the first line as column titles. Then assign the columns to the Sage 100 fields. The CSV has both net and gross amounts, so map the one your definition expects. Import the file as an Importstapel. The accountant previews the batch, checks it against the total in the booking text and posts it with Buchen. Lines with errors go to a separate file ending in InvalidLines.csv for correction.

The values in ExpenseAccount and TaxCodes are examples. Use the expense account and the input tax codes (Steuertyp = 2 in KHKSteuertabelle) of your chart of accounts. For supplier invoices that carry your article numbers, the Sage 100 API can create purchase documents instead. See versions and hosting.


XRechnung for German public buyers

Federal, state and municipal buyers in Germany need XRechnung. XRechnung is pure XML without a PDF. Portals such as ZRE and OZG-RE check it against the KoSIT rules. The service already handles it. When the customer account has a Leitweg-ID, ProcessAsync calls /v1/create/xrechnung and stores an .xml file instead of a PDF.

The Leitweg-ID is the routing id the public buyer gives you when you register as a supplier. Rule BR-DE-15 rejects any XRechnung without it. Sage 100 9.0 stores it in the customer master, in the column LeitwegID of KHKKontokorrent. The mapper copies it into buyerReference. The seller contact (name, phone, email) and the electronic addresses are required by the German rules, and the mapper always sends them. Scheme 9930 is a German VAT id. A public buyer without a VAT id gets the Leitweg-ID with scheme 0204. For Peppol delivery, use the Peppol participant id and its scheme. This query lists your public-sector customers and shows missing data before the first invoice:

-- Customers with a Leitweg-ID: VAT id and email should be filled
SELECT k.Kto, k.Matchcode, k.LeitwegID, k.EULand, k.EUUStID, a.EMail
FROM KHKKontokorrent k
JOIN KHKAdressen a ON a.Mandant = k.Mandant AND a.Adresse = k.Adresse
WHERE k.Mandant = 1 AND k.KtoArt = 'D' AND k.LeitwegID > ''
ORDER BY k.Kto;

The API returns the XML after the KoSIT checks. Deliver it the way the buyer asks: upload to ZRE or OZG-RE, send it over Peppol, or email it where the buyer accepts email. /v1/render/xrechnung/to/pdf makes a readable PDF of the XML for your archive. XRechnung is also valid between businesses, so /v1/create/xrechnung/email works too. The XRechnung API toolkit covers the format in detail.


Sage 100 versions, on-premise and hosted

Versions. This guide uses the database of Sage 100 9.0. Sage releases a service pack for 9.0 about twice a year. The KHK tables date from the Office Line years and keep their names in older releases. A newer column, such as LeitwegID, may be missing on an old release. Then add a user-defined field to the customer with the AppDesigner and read that column instead. Sage 100 and Sage 100cloud (the subscription) are the same software with the same database.

SQL Server and rights. Current 9.0 service packs support Microsoft SQL Server 2019, 2022 and 2025. The service connects with Windows authentication as its own service account. That account has SELECT on six tables in the Sage 100 database and owns the EInvoice database (script in step 4). It has no write rights on Sage 100 data. A Sage 100 database can hold several Mandanten. The Mandant column in the tables tells them apart. For more than one Mandant, add a Seller section per Mandant and run the worker loop once for each.

Hosted installations. With the Sage 100cloud hosting package or a partner data center, users work on a terminal server. The SQL Server sits in the hosting network. Install the service on a server in that network. Ask the host for the service account and the grants from step 4. The archive and import folders are shares in the same network. The service needs outbound HTTPS to api.invoicexml.com and nothing inbound.

The Sage 100 API. Sage 100 Connected has a REST API from version 9.0.4. It authenticates with OpenID Connect through Sage ID, and access from outside the network runs through Sage's gateway. The OpenAPI files are at openapi.sage100.de. The Belegerfassung resource lists sales documents with eptVKBelegSuche and a where filter, and creates purchase documents with eptEKBeleg. A purchase document needs the supplier number (a0Konto), the document type key (kennzeichen) and positions with your article numbers. Use it to create Einkauf documents from supplier invoices that use your article numbers. The address and tax data for the outgoing JSON come from the database queries in step 2.


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 Sage 100 e-invoicing integration is one Windows service: read the invoice, post the JSON, store or send the file, check what comes in. It reads Sage 100 and never writes to it. It holds no format logic. A new ZUGFeRD or XRechnung version needs no change at the customer. This guide is part of the integrations hub, which covers SAP, Microsoft Dynamics, Odoo and more platforms.

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