This guide adds ZUGFeRD and XRechnung to SelectLine Warenwirtschaft (SelectLine Auftrag). A small C# service reads finished invoices through the SelectLine API. It sends each invoice as JSON to one REST API and gets back a validated ZUGFeRD PDF. The service then stores the PDF in a journal of the invoice. Nothing is installed in the Warenwirtschaft, and no print template changes. The email section lets the API deliver the invoice to the customer. The import section checks supplier invoices and creates them as Eingangsrechnungen. The plain API calls in C# are explained in the C# guide.
Why SelectLine needs ZUGFeRD now
German companies on SelectLine must send and receive structured e-invoices. Since January 2025, every German business must be able to receive them. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must issue them. From January 2028, every business must. A plain PDF is not an e-invoice under these rules. An e-invoice carries structured data that software can read. ZUGFeRD is a PDF/A-3 file with this data inside as XML. People read the PDF, and the buyer's software reads the XML. The Germany mandate page lists the dates and formats.
SelectLine partners serve many customers. The service uses only documented routes of the SelectLine API. The same build runs at every customer. Per installation you set four values: the API address, an API user, the AppKey and the InvoiceXML API key.
The service stays the same when the formats change. ZUGFeRD, XRechnung and the EN 16931 rules get new versions. The API applies each new version on its effective date. Your service sends the same JSON before and after. There is nothing to update at your customers.
What you need
- SelectLine Warenwirtschaft with the SelectLine API. The API is set up in the SelectLine Mobile Manager and runs on IIS. Its help page on your server lists the routes of your version.
- An API user and an AppKey. The user needs read rights on Belege, Kunden and Lieferanten, and create rights on Journale. For the import it also needs create rights on Belege.
- Three settings from the Mandant: a journal status id from
CRM_Status, a non-stock cost article for imported lines, and your input tax codes (Steuerschlüssel) per VAT rate. - A place to run the service: a Windows service or a scheduled task with .NET 8. It needs HTTPS to the SelectLine API 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 finishes a Rechnung (Belegtyp R) in SelectLine. The service finds it through the Documents route. It reads the header, the positions and the customer and builds a JSON body. The API calculates the totals and the VAT, creates the XML, embeds it in a PDF/A-3 file, validates it and returns the PDF. The service attaches the PDF to a journal of the Rechnung. Or the API sends the invoice to the customer by email.
| 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. You write five classes and one table from the listings below:
- SelectLineClient: login with the AppKey, GET and POST calls, file uploads, key encoding.
- InvoiceXmlClient: the create, validate and extract calls.
- InvoiceMapper: reads the Rechnung, its positions and the customer and builds the JSON.
- EInvoiceService: finds new invoices, creates the file, stores it in a journal, sends by email.
- InboundService: checks supplier invoices, returns the report, creates the Eingangsrechnung.
- EInvoiceLog: a table in the service's own database with the processed document keys.
On the SelectLine side the service uses the API routes Login, Documents, Documents/{key}/Positions, Documents/{key}/Journals, Journals/{id}/Attachments, Customers and Suppliers. Behind them are the tables BELEG, BELEGP, DEBITOREN and KREDITOREN.
Step 1: Connect to the SelectLine API and InvoiceXML
The SelectLine API starts with a login. You send the user name, the password and the AppKey. The answer holds an access token. Every later call sends it in the header Authorization: LoginId <token>. InvoiceXML uses a bearer token instead. Keep the password, the AppKey and the API key out of the code. Read them from the Windows Credential Manager, environment variables or your secret store.
// SelectLineClient.cs: one login, then the LoginId header on every call
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 static class Json
{
// Property names as written, null values left out
public static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}
public sealed class SelectLineClient
{
private readonly HttpClient _http;
public SelectLineClient(string baseUrl) // for example https://sl-server/slmobileApi/
=> _http = new HttpClient { BaseAddress = new Uri(baseUrl) };
public async Task LoginAsync(string userName, string password, string appKey)
{
var response = await _http.PostAsJsonAsync("Login",
new { username = userName, password, AppKey = appKey }, Json.Options);
response.EnsureSuccessStatusCode();
var login = (await response.Content.ReadFromJsonAsync<JsonNode>())!; // { AccessToken, TokenType }
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("LoginId", (string)login["AccessToken"]!);
}
// Keys in the URL path: every special character becomes ~ plus its hex code ("/" -> "~2F")
public static string Key(string value) => string.Concat(value.Select(c =>
char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c.ToString() : $"~{(int)c:X2}"));
public async Task<JsonNode?> GetAsync(string path)
{
var response = await _http.GetAsync(path);
return await ReadAsync(response);
}
public async Task<JsonNode?> PostAsync(string path, object body)
{
var response = await _http.PostAsJsonAsync(path, body, Json.Options);
return await ReadAsync(response);
}
// Journal attachments: multipart/form-data, every part with a content type and a file name
public async Task PostFileAsync(string path, byte[] file, string fileName, string contentType)
{
using var form = new MultipartFormDataContent();
var part = new ByteArrayContent(file);
part.Headers.ContentType = new MediaTypeHeaderValue(contentType);
form.Add(part, "file", fileName);
await ReadAsync(await _http.PostAsync(path, form));
}
private static async Task<JsonNode?> ReadAsync(HttpResponseMessage response)
{
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"SelectLine API {(int)response.StatusCode}: {text}");
return string.IsNullOrWhiteSpace(text) ? null : JsonNode.Parse(text);
}
}
// InvoiceXmlClient.cs: JSON in and file out, or file in and result out
public sealed class InvoiceXmlClient
{
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, Json.Options);
if (!response.IsSuccessStatusCode) // 400: an errors array with rule id and JSON path
throw new InvalidOperationException(await response.Content.ReadAsStringAsync());
return await response.Content.ReadAsByteArrayAsync();
}
// Upload endpoints take one form field named "file"
private static MultipartFormDataContent Upload(byte[] file, string fileName)
{
var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(file), "file", fileName);
return form;
}
public async Task<(bool Valid, byte[] ReportPdf)> ValidateWithReportAsync(string path, byte[] file, string fileName)
{
using var form = Upload(file, fileName);
form.Add(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 = Upload(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>())!;
}
}
Document keys and numbers go into the URL path. There the SelectLine API expects special characters encoded with a tilde, for example ~2F for a slash. The Key helper does this. The InvoiceXML report endpoints answer with a PDF and send the verdict in the X-Invoice-Valid header, so the method returns both.
Step 2: Map SelectLine fields to JSON
The data comes from three routes: GET Documents/{key} (table BELEG), GET Documents/{key}/Positions (table BELEGP) and GET Customers/{number}. The document key is the Belegtyp plus the Belegnummer, so a Rechnung has a key that starts with R. The seller is your company. Its data comes from the service configuration.
| SelectLine API field (database column) | JSON path | Note |
|---|---|---|
Number (BELEG.Belegnummer) | invoice.invoiceNumber | BT-1 |
Date (BELEG.Datum) | invoice.issueDate | First 10 characters |
Date + Payment.PaymentPeriod (BELEG.Zahlungsziel) | invoice.dueDate | Payment period in days |
CurrencyCode (BELEG.Waehrungscode) | invoice.currency | ISO code |
BusinessPartner.BusinessPartnerContract (BELEG.IhrAuftrag) | invoice.purchaseOrderReference | The customer's order number, BT-13 |
BusinessPartner.Company, Address, TaxIdentifierNumber (BELEG.UStID) | invoice.buyer | Country as ISO code |
Name (BELEGP.Bezeichnung) | lines[].item.name | Per position |
Quantity, QuantityUnit (Menge, Mengeneinheit) | lines[].quantity, lines[].unitCode | UN/ECE code: H87 pieces, HUR hours |
NetAmountLessDiscount (BELEGP.RNetto) | lines[].priceDetails.netPrice | Divided by the quantity |
TaxRate (BELEGP.Steuerprozent) | lines[].vatInformation.rate | For example 19 or 7 |
BuyerReferenceIdentifier (DEBITORENDETAILS.LeitwegID) | invoice.buyerReference | XRechnung only |
| Configuration | invoice.seller, paymentDetails | Name, VAT id, address, IBAN |
You do not send totals or VAT amounts. The API calculates them from the lines. If the numbers do not add up, the API returns an error and no file. Problems with rounding or discounts show up before the customer gets the invoice.
// InvoiceMapper.cs: BELEG + BELEGP + customer -> InvoiceXML JSON
public sealed class InvoiceMapper
{
// Position kinds with a price: A = article, E = shipping, F = document surcharge
private static readonly HashSet<string> PricedKinds = new() { "A", "E", "F" };
private readonly SelectLineClient _sl;
private readonly SellerSettings _seller; // your company: name, VAT id, address, IBAN, contact
public InvoiceMapper(SelectLineClient sl, SellerSettings seller) => (_sl, _seller) = (sl, seller);
// Returns the "invoice" object and the customer record (for the email address)
public async Task<(object Invoice, JsonNode Customer)> BuildAsync(string documentKey)
{
var key = SelectLineClient.Key(documentKey);
var doc = (await _sl.GetAsync($"Documents/{key}"))!;
var positions = (await _sl.GetAsync($"Documents/{key}/Positions?Items=0"))!.AsArray();
var partner = doc["BusinessPartner"]!;
var address = partner["Address"]!;
var customer = (await _sl.GetAsync($"Customers/{SelectLineClient.Key((string)address["Number"]!)}"))!;
var issueDate = ((string)doc["Date"]!)[..10]; // "2026-11-06T00:00:00+01:00" -> "2026-11-06"
var days = (int?)doc["Payment"]?["PaymentPeriod"] ?? 0;
var lines = positions
.Where(p => PricedKinds.Contains((string)p!["PositionKind"]!) && (decimal)p["Quantity"]! != 0)
.Select(p => new
{
quantity = (decimal)p!["Quantity"]!,
unitCode = UnitCode((string?)p["QuantityUnit"]),
item = new { name = (string?)p["Name"] },
// RNetto is the net line amount after the line discounts, in the document currency
priceDetails = new { netPrice = Math.Round((decimal)p["NetAmountLessDiscount"]! / (decimal)p["Quantity"]!, 4) },
vatInformation = new { rate = (decimal)p["TaxRate"]! },
})
.ToList();
var invoice = new
{
invoiceNumber = (string)doc["Number"]!,
issueDate,
dueDate = DateOnly.Parse(issueDate).AddDays(days).ToString("yyyy-MM-dd"),
currency = (string?)doc["CurrencyCode"],
purchaseOrderReference = (string?)partner["BusinessPartnerContract"],
buyerReference = (string?)customer["BuyerReferenceIdentifier"], // Leitweg-ID, XRechnung only
seller = new
{
name = _seller.Name,
vatIdentifier = _seller.VatId,
postalAddress = new { line1 = _seller.Street, city = _seller.City, postCode = _seller.PostCode, country = "DE" },
},
buyer = new
{
name = (string?)partner["Company"],
vatIdentifier = (string?)doc["TaxIdentifierNumber"],
postalAddress = new
{
line1 = (string?)address["Street"],
city = (string?)address["City"],
postCode = (string?)address["ZipCode"],
country = IsoCountry((string?)address["CountryId"]),
},
},
paymentDetails = new { paymentAccountIdentifier = _seller.Iban },
lines,
};
return (invoice, customer);
}
// The country code comes from the Laender table of the Mandant; the API needs ISO 3166 codes
private static string IsoCountry(string? land) => land?.Trim().ToUpperInvariant() switch
{
null or "" or "D" => "DE",
"A" => "AT",
var code => code,
};
// UN/ECE Recommendation 20 codes for your units (Mengeneinheiten)
private static string UnitCode(string? unit) => unit?.Trim() switch
{
"Stk" or "Stück" => "H87",
"Std" => "HUR",
"kg" => "KGM",
"m" => "MTR",
"Pausch" => "LS",
_ => "C62",
};
}
The positions route returns 10 positions per page by default. Items=0 returns all of them. Comments, page breaks and subtotals have other position kinds, so the filter skips them. Check the country codes and units in your Mandant once and extend the two switch blocks to match. Some invoices use a document discount (DiscountAmount, BELEG.Belegrabatt). Compare one such test invoice with the SelectLine total before you go live.
Step 3: Create the ZUGFeRD invoice
The create method maps the Rechnung and posts the body. The answer is the ZUGFeRD PDF as a byte array.
// EInvoiceService.cs: create, store in a journal, send by email, find new invoices
public sealed class EInvoiceService
{
private readonly SelectLineClient _sl;
private readonly InvoiceXmlClient _api;
private readonly InvoiceMapper _mapper;
private readonly EInvoiceLog _log; // the processed keys, in the service's own database
private readonly ServiceSettings _settings; // journal status id, interval, look-back days
public EInvoiceService(SelectLineClient sl, InvoiceXmlClient api, InvoiceMapper mapper,
EInvoiceLog log, ServiceSettings settings)
=> (_sl, _api, _mapper, _log, _settings) = (sl, api, mapper, log, settings);
public async Task<byte[]> CreateZugferdAsync(string documentKey)
{
var (invoice, _) = await _mapper.BuildAsync(documentKey);
return await _api.CreateAsync("v1/create/zugferd", new { invoice });
}
A French customer needs Factur-X. Post the same body to v1/create/facturx and choose the endpoint by the customer's country. Factur-X and ZUGFeRD follow the same standard. The Factur-X API toolkit covers the French details.
Step 4: Store the PDF in the journal of the Rechnung
SelectLine keeps notes and files for a Beleg in journals. The API creates a journal on the document with POST Documents/{key}/Journals. A second call adds the PDF to it with POST Journals/{id}/Attachments. SelectLine stores the attachment in the database. Your users find the ZUGFeRD file in the journal of the Rechnung.
public async Task AttachAsync(string documentKey, byte[] file, string fileName, string contentType)
{
// 1. A journal on the Beleg. Date, StatusId, Label and Text are required.
var journal = (await _sl.PostAsync($"Documents/{SelectLineClient.Key(documentKey)}/Journals", new
{
Date = DateTime.Now,
StatusId = _settings.JournalStatusId, // an Id from CRM_Status
Cycle = "None",
Label = "E-Rechnung",
Text = $"{fileName} erstellt und geprüft",
}))!;
// 2. The file as attachment of that journal
var journalId = (string)journal["JournalIdentifier"]!;
await _sl.PostFileAsync($"Journals/{journalId}/Attachments", file, fileName, contentType);
}
// Started by a timer: every finished Rechnung without an e-invoice
public async Task ProcessNewInvoicesAsync()
{
var since = DateTime.Today.AddDays(-_settings.LookBackDays).ToString("yyyy-MM-dd");
var invoices = (await _sl.GetAsync(
$"Documents?SelectDocumentKindFlag=R&SelectFromDate={since}&Items=0"))!.AsArray();
foreach (var doc in invoices)
{
if ((string?)doc!["StatusFlag"] is "5" or "6") continue; // 5 = in Bearbeitung, 6 = geparkt
var number = (string)doc["Number"]!;
var documentKey = "R" + number;
if (await _log.ContainsAsync(documentKey)) continue;
var pdf = await CreateZugferdAsync(documentKey);
await AttachAsync(documentKey, pdf, $"{number}-zugferd.pdf", "application/pdf");
await _log.AddAsync(documentKey, "zugferd");
}
}
// EInvoiceLog table (service database, not the Mandant database):
// CREATE TABLE EInvoiceLog (DocumentKey nvarchar(12) PRIMARY KEY,
// Route nvarchar(20) NOT NULL, ProcessedAt datetime2 NOT NULL)
When to run it. A timer starts ProcessNewInvoicesAsync every few minutes. The interval and the look-back days come from the configuration. Invoices in status 5 (in Bearbeitung) and 6 (geparkt) wait for the next run. The EInvoiceLog table makes sure no invoice is sent twice. The service writes to SelectLine only through the API.
A button in SelectLine. With the Toolbox you can add a button to a mask that starts an external program. Let it start the service with the document key as an argument. Users can then create the e-invoice for one Rechnung on demand.
Archive. If your archive reads files from a folder, also write the PDF to that folder in AttachAsync.
Send the invoice by email from the API
The API can also deliver the invoice. It creates the ZUGFeRD PDF, validates it and emails it to the customer in one call. The file never passes through your service. Germany has no central platform for B2B e-invoices. An email with a ZUGFeRD PDF is therefore a complete delivery.
Verify your sending domain once in the dashboard. Add the domain and publish the three CNAME records it shows. After that, any address on the domain can send. Customers see your address, and their replies reach your mailbox. The request is the invoice JSON plus an email object. The recipient is EMail1 from the contact data of the customer:
public async Task SendByEmailAsync(string documentKey)
{
var (invoice, customer) = await _mapper.BuildAsync(documentKey);
var number = documentKey[1..]; // the key without the Belegtyp
await _api.CreateAsync("v1/create/zugferd/email", new
{
invoice,
email = new
{
from = "[email protected]", // an address on your verified domain
to = (string)customer["Contact"]!["EMail1"]!,
subject = $"Rechnung {number}",
message = $"Guten Tag,\n\nim Anhang finden Sie die Rechnung {number} als ZUGFeRD-PDF.\n\nFreundliche Grüße",
},
});
// 200: created, validated and handed to delivery. The answer has no file.
await _log.AddAsync(documentKey, "zugferd-email");
}
}
Use this route when the API should send. Use the plain endpoint when SelectLine should store the file and send it itself. The same route exists for XRechnung (/v1/create/xrechnung/email) and Factur-X.
Import incoming supplier invoices
Since January 2025, German businesses must accept e-invoices. Suppliers send ZUGFeRD PDFs and XRechnung XML files. The import works in three steps. A mailbox or folder watcher hands each file to the service.
- Validate. Post the file to
POST /v1/validate/zugferd/report(PDF) or/v1/validate/xrechnung/report(XML) withvalidationLanguage=de. The API checks it against EN 16931 and the German rules. The headerX-Invoice-Validholds the result. The body is a PDF report in German. For the findings as JSON, call/v1/validate/zugferdwithout/report. - Return invalid invoices. If the invoice is invalid, email the PDF report to the sender and stop. The report names every failed rule and the field. The supplier can correct the invoice without calling your team.
- Extract. If the invoice is valid, post the file to
POST /v1/extract/json. The answer is the invoice as JSON, in the same shape as the create body, plus the totals.
// InboundService.cs: validate, return the report or extract, create the Eingangsrechnung
public sealed class InboundService
{
private readonly SelectLineClient _sl;
private readonly InvoiceXmlClient _api;
private readonly EInvoiceService _einvoice; // for AttachAsync
private readonly ImportSettings _settings; // cost article, input tax codes
private readonly IMailer _mailer; // your SMTP helper
public InboundService(SelectLineClient sl, InvoiceXmlClient api, EInvoiceService einvoice,
ImportSettings settings, IMailer mailer)
=> (_sl, _api, _einvoice, _settings, _mailer) = (sl, api, einvoice, settings, mailer);
public async Task ProcessAsync(byte[] file, string fileName, string senderEmail)
{
// 1. Validate: XML files are XRechnung, PDF files are ZUGFeRD
var isXml = fileName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
var (valid, report) = await _api.ValidateWithReportAsync(
isXml ? "v1/validate/xrechnung/report" : "v1/validate/zugferd/report", file, fileName);
// 2. Invalid: the report goes back to the sender
if (!valid)
{
await _mailer.SendAsync(senderEmail,
subject: $"Rechnung {fileName}: Prüfung nicht bestanden",
text: "Im Anhang finden Sie den Prüfbericht. Bitte senden Sie uns eine korrigierte Rechnung.",
attachment: ($"Pruefbericht-{fileName}.pdf", report));
return;
}
// 3. Valid: read the data, create the Eingangsrechnung, keep the original in its journal
var extracted = await _api.ExtractAsync(file, fileName);
var documentKey = await CreateIncomingInvoiceAsync(extracted["invoice"]!);
await _einvoice.AttachAsync(documentKey, file, fileName, isXml ? "application/xml" : "application/pdf");
}
Plain PDFs. Some suppliers still send PDFs without XML. Post them to POST /v1/parse/json. The API reads typed, scanned and photographed invoices with AI. It returns the same JSON and a confidence object with scores from 0.0 to 1.0. Create the Eingangsrechnung for high scores and send the rest to a person. Files that are not invoices are rejected with error code 4008.
Create the Eingangsrechnung from the JSON
Both endpoints return the same JSON shape, so one method creates the SelectLine document. It finds the supplier by the VAT id from the invoice. Then it creates a Beleg of type E (Eingangsrechnung) with POST Documents. The API answers with 201 and the new DocumentKey. Each invoice line becomes an article position on a non-stock cost article. A non-stock article needs no warehouse data in the API call.
private async Task<string> CreateIncomingInvoiceAsync(JsonNode invoice)
{
// The supplier: KREDITOREN.SteueridentNr matches the seller's VAT id
var vatId = (string)invoice["seller"]!["vatIdentifier"]!;
var filter = Uri.EscapeDataString($"Payment.TaxIdentifierNumber EQ '{vatId}'");
var suppliers = (await _sl.GetAsync($"Suppliers?Filter={filter}&Items=1"))!.AsArray();
var supplierNumber = (string?)suppliers.FirstOrDefault()?["Number"]
?? throw new InvalidOperationException($"Kein Lieferant mit USt-IdNr. {vatId}");
// Belegtyp E = Eingangsrechnung; SelectLine assigns the Belegnummer
var created = (await _sl.PostAsync("Documents", new
{
KindFlag = "E",
Date = (string?)invoice["issueDate"],
PriceKindFlag = "N", // net prices
CurrencyCode = (string?)invoice["currency"],
BusinessPartner = new
{
Address = new { Number = supplierNumber },
BusinessPartnerReferenceNumber = (string?)invoice["invoiceNumber"],
},
HeaderText = $"E-Rechnung {invoice["invoiceNumber"]}, Gesamtbetrag {invoice["totals"]!["grandTotalAmount"]}",
}))!;
var documentKey = (string)created["DocumentKey"]!;
// One position per invoice line, with the input tax code for its VAT rate
foreach (var line in invoice["lines"]!.AsArray())
{
await _sl.PostAsync($"Documents/{SelectLineClient.Key(documentKey)}/ArticleItem", new
{
ArticleNumber = _settings.CostArticleNumber,
Name = (string?)line!["item"]!["name"],
CalculatedQuantityValue = (decimal)line["quantity"]!,
UnitPrice = (decimal)line["priceDetails"]!["netPrice"]!,
TaxCode = _settings.InputTaxCodes[(decimal)line["vatInformation"]!["rate"]!],
});
}
return documentKey;
}
}
The supplier's invoice number goes into BusinessPartnerReferenceNumber (BELEG.IhrZeichen). If your team keeps it in another field, change that line. InputTaxCodes maps each VAT rate to an input tax code (Steuerschlüssel) of your Mandant. The header text holds the total from the e-invoice. The clerk compares it with the SelectLine total and checks the account before posting. The original file sits in the journal of the new Eingangsrechnung.
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 same service creates it. It needs the Leitweg-ID, a seller contact and electronic addresses:
// In BuildAsync, when the customer has a Leitweg-ID (BuyerReferenceIdentifier is not empty):
seller = new
{
name = _seller.Name,
vatIdentifier = _seller.VatId,
postalAddress = new { /* as in step 2 */ },
contact = new { name = _seller.ContactName, phone = _seller.Phone, email = _seller.InvoiceEmail },
electronicAddress = new { identifier = _seller.VatId, schemeId = "9930" }, // 9930 = German VAT id
},
buyer = new
{
/* as in step 2 */
electronicAddress = new { identifier = (string?)doc["TaxIdentifierNumber"], schemeId = "9930" },
},
// In ProcessNewInvoicesAsync, pick the endpoint by the Leitweg-ID:
// string.IsNullOrEmpty(leitwegId) ? "v1/create/zugferd" : "v1/create/xrechnung"
// The XRechnung answer is XML. Attach it as {number}-xrechnung.xml with "application/xml".
The Leitweg-ID is the routing id the public buyer gives you. Rule BR-DE-15 rejects an XRechnung without it. SelectLine has a Leitweg-ID field in the customer master data. The API returns it as BuyerReferenceIdentifier, and the database column is DEBITORENDETAILS.LeitwegID. The mapper copies it into buyerReference. The seller contact and the electronic addresses are required by the German rules. Scheme 9930 stands for a German VAT id. For Peppol, use the Peppol participant id and its scheme.
The API returns the XML after the KoSIT validation. 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. For your own records, /v1/render/xrechnung/to/pdf turns the XML into a readable PDF. The XRechnung API toolkit explains the format in more detail.
SelectLine versions, API server and SQL access
The API server. The SelectLine API is installed together with SelectLine Mobile, MDE and BDE. You set it up in the SelectLine Mobile Manager, which configures IIS and the database connection. Each installation has its own API help at https://<server>/slmobileApi/help. It matches the installed version and includes a Swagger page for test calls. The API update help of SelectLine lists the route changes between versions. Check the routes of this guide there before you roll out to an older installation.
License and login. Each application that calls the API needs an AppKey in your SelectLine license. You enter the license data under Hilfe, Programm, API Lizenzierung. Your SelectLine partner knows which license your installation has. A LoginId stays valid for 14 days by default (LoginIdValidityPeriod in the Web.config of the Webapi folder). After five wrong passwords, SelectLine locks the user for 24 hours. So the service should stop and alert after a failed login, not retry.
Read-only SQL access. SelectLine stores each Mandant in a Microsoft SQL Server database. If you prefer to read invoices with SQL, create a SQL login with read rights only (db_datareader) on the Mandant database. The header is in BELEG, the positions are in BELEGP:
-- Finished invoices since a date, with their positions
SELECT b.Belegnummer, b.Datum, b.Zahlungsziel, b.Waehrungscode, b.IhrAuftrag,
b.Adressnummer, b.Firma, b.Strasse, b.Plz, b.Ort, b.Land, b.UStID,
p.Posnummer, p.Zeilentyp, p.Bezeichnung, p.Menge, p.Mengeneinheit,
p.RNetto, p.Steuerprozent
FROM BELEG b
JOIN BELEGP p ON p.Belegtyp = b.Belegtyp AND p.Belegnummer = b.Belegnummer
WHERE b.Belegtyp = 'R'
AND b.Status NOT IN ('5', '6')
AND b.Datum >= :Seit
ORDER BY b.Belegnummer, p.Posnummer;
The :Seit parameter is the syntax of SelectLine API macros. Store the query under a name in the table APIMACRO of the Mandant database. Then call it with POST Macros/{name} and the parameter in the body. The answer has the column names and the rows, and the same mapping applies. API macros run without a permission check, so keep them to SELECT statements. Create Belege and journals only through the API routes.
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 integration is the five classes in this guide and one log table. The service connects, maps, creates the file and stores it in a journal or emails it. It also checks supplier invoices and creates them as Eingangsrechnungen. The Warenwirtschaft stays unchanged, and the service holds no format rules. Format updates need no new release. More platforms, such as SAP Business One, Dynamics NAV and Odoo, are in the integrations hub.
Related guides:
- SAP Business One ZUGFeRD: the same service pattern with the Service Layer.
- ZUGFeRD and XRechnung in C#: the API client in more detail.
- ZUGFeRD API toolkit: create, validate, extract and embed ZUGFeRD files.
- XRechnung API toolkit: Leitweg-ID, KoSIT validation, PDF preview.