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.
| Customer | Format | Endpoint | Result |
|---|---|---|---|
| German business (B2B) | ZUGFeRD EN 16931 | /v1/create/zugferd | Hybrid PDF/A-3 |
| German business, delivered by the API | ZUGFeRD EN 16931 | /v1/create/zugferd/email | Email with the PDF attached |
| German public buyer (B2G) | XRechnung | /v1/create/xrechnung | XML with Leitweg-ID |
| French business | Factur-X EN 16931 | /v1/create/facturx | Hybrid 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 field | JSON path | Note |
|---|---|---|
KHKVKBelege.Belegnummer | invoice.invoiceNumber | BT-1. BelID is the internal key |
KHKVKBelege.Belegdatum | invoice.issueDate | Format yyyy-MM-dd |
KHKVKBelegeZKD.Faelligkeit | invoice.dueDate | Row with IstStandard = -1, the main payment term |
KHKVKBelege.WKz | invoice.currency | Currency key, for example EUR |
KHKVKBelege.Referenznummer | invoice.purchaseOrderReference | "Ihre Belegnummer" in the document, BT-13 |
| Configuration | invoice.seller | Name, VAT id, address, IBAN, contact |
A0Name1, A0Strasse, A0PLZ, A0Ort, A0Land | invoice.buyer | The A2 fields when the invoice goes to a different recipient |
KHKKontokorrent.EULand + EUUStID | invoice.buyer.vatIdentifier | Account from KHKVKBelege.Rechnungsempfaenger |
KHKVKBelegePositionen.Bezeichnung1 | lines[].item.name | Only PosType = 1 (article lines) |
Menge, Mengeneinheit | lines[].quantity, lines[].unitCode | UN/ECE code: H87 pieces, HUR hours |
Einzelpreis, Preiseinheit, Rabatt | lines[].priceDetails.netPrice | Price per unit after the line discount |
Steuercode to KHKSteuertabelle.Steuersatz | lines[].vatInformation.rate | 19, 7 or 0 |
KHKKontokorrent.LeitwegID | invoice.buyerReference | XRechnung 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:
- Validate. Post the file to
POST /v1/validate/zugferd/report(PDF) or/v1/validate/xrechnung/report(XML). The form fieldvalidationLanguage=demakes the report German. The headerX-Invoice-Validsaystrueorfalse. The body is the PDF report. To get the findings as JSON instead, call/v1/validate/zugferdwithout/report. - 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.
- 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
| Purpose | Endpoint | Input | Output |
|---|---|---|---|
| Create ZUGFeRD | POST /v1/create/zugferd | Invoice JSON | Hybrid PDF/A-3 |
| Create and email ZUGFeRD | POST /v1/create/zugferd/email | Invoice JSON + email object | Email sent from your domain |
| Create XRechnung | POST /v1/create/xrechnung | Invoice JSON with Leitweg-ID | XRechnung XML |
| Create and email XRechnung | POST /v1/create/xrechnung/email | Invoice JSON + email object | Email sent from your domain |
| Create Factur-X | POST /v1/create/facturx | Invoice JSON | Hybrid PDF/A-3 |
| Validate with PDF report | POST /v1/validate/zugferd/report, /xrechnung/report | PDF or XML file | PDF report, X-Invoice-Valid header |
| Validate as JSON | POST /v1/validate/zugferd, /xrechnung | PDF or XML file | Findings per rule |
| Extract structured data | POST /v1/extract/json | Hybrid PDF or XML file | Invoice JSON |
| Parse plain PDF | POST /v1/parse/json | Any PDF or image | Invoice JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Hybrid PDF or XML file | ZIP of embedded files |
| Render XRechnung | POST /v1/render/xrechnung/to/pdf | XRechnung XML | Readable 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:
- SAP Business One ZUGFeRD: the same external C# service, with the Service Layer instead of SQL.
- ZUGFeRD and XRechnung in C#: the API calls in .NET in more depth.
- ZUGFeRD API toolkit: profiles, validation and the hybrid PDF.
- XRechnung API toolkit: Leitweg-ID, KoSIT rules and the PDF preview.