Business Central runs invoicing for hundreds of thousands of European SMBs, and the e-invoicing mandates have quietly outgrown what it prints. A posted sales invoice with a clean report layout was the deliverable for decades. Since January 2025, German business customers can no longer be served with a plain PDF; France follows in September 2026; and the NAV-turned-BC installed base is concentrated in exactly those two markets. What the mandates want is a hybrid document: the PDF your customer opens, carrying machine-readable CII XML their software books automatically.
This guide adds that capability to Business Central with a small AL extension and one REST API. ZUGFeRD for German customers and Factur-X for French ones are covered together, deliberately, because they are the same technical format under two national brands: the request body is identical and only the endpoint differs, so a single codeunit serves both markets. The XRechnung section covers German public sector buyers, and the intake section handles the other direction: supplier e-invoices arriving in your AP inbox.
If your team prefers building flows to writing AL, the same API works from the Business Central connector in Power Automate; the Power Automate section points at the companion guide to e-invoicing in Power Automate.
The Business Central e-invoicing gap
Three things collide in a typical BC shop:
The mandates are format-specific. Germany requires that B2B recipients accept structured e-invoices now, with issuance mandatory from 2027; ZUGFeRD profiles EN 16931 and above qualify, a report-layout PDF does not. France's reform makes structured e-invoices mandatory from September 2026, delivered through registered platforms (PDPs) that validate documents on acceptance. German public sector buyers have required XRechnung since 2020.
BC's document flow ends at the layout. Posting produces the Sales Invoice Header and Lines; the report layout renders a PDF; dispatch emails it. Nothing in that chain produces a PDF/A-3 container with an embedded, rule-compliant factur-x.xml attachment. The e-document framework in recent BC versions centres on UBL-style documents and access-point services, and what it covers varies by version and localization; the hybrid formats German and French trading partners actually ask for generally need an addition.
The compliance depth is invisible until a rejection. Roughly 200 EN 16931 business rules, VAT breakdowns that must reconcile to the cent, PDF/A-3 conformance with exact XMP metadata, and national overlays on top, all moving on the regulators' calendars. The technical overview of ZUGFeRD automation walks the layers; the short version is that this is not a report-layout problem.
The approach here keeps all of that server-side: BC sends the invoice data it already has, and receives a finished, validated document back.
ZUGFeRD and Factur-X: one integration
ZUGFeRD 2.3 and Factur-X 1.0.7 are the same specification, published jointly by the German FeRD and the French FNFE-MPE: identical CII XML, identical PDF/A-3 container, identical embedded file name. The branding differs; the bytes barely do. For a BC extension this collapses the two mandates into one routing decision:
| Customer | Format | Endpoint | Deliverable |
|---|---|---|---|
| German business (B2B) | ZUGFeRD EN 16931 | /v1/create/zugferd | Hybrid PDF/A-3 |
| French business | Factur-X EN 16931 | /v1/create/facturx | Hybrid PDF/A-3 |
| German authority (B2G) | XRechnung 3.0 | /v1/create/xrechnung | Pure XML + Leitweg-ID |
| Peppol recipient | Peppol BIS 3.0 | /v1/create/ubl | UBL XML |
Every row consumes the same JSON body, so the extension routes on Country/Region Code (and a public-sector flag) rather than maintaining per-format logic.
Three ways to integrate
An AL extension calling the API directly is the main path in this guide: the invoice never leaves your flow, the hybrid PDF lands as a document attachment on the posted invoice, and dispatch stays inside BC. This is the cleanest fit for per-tenant extensions and ISVs embedding compliance into their vertical.
Power Automate with the Business Central connector suits teams already automating with flows: trigger on a posted sales invoice, call the API with the HTTP action, store and send the result. Covered below via the companion guide.
The e-document framework in recent BC versions can be pointed at custom service integrations; wiring the API in as the format and validation service is an option for teams standardising on that framework. The AL surface below is the same either way, so nothing in this guide locks you out of it.
The AL extension: setup
Create a free InvoiceXML account to get an API key with 100 free credits, no credit card needed. Store the key with Isolated Storage rather than in a setup field users can read:
codeunit 50100 "InvoiceXML Setup"
{
Access = Internal;
procedure SetApiKey(ApiKey: Text)
begin
IsolatedStorage.Set('InvoiceXmlApiKey', ApiKey, DataScope::Company);
end;
procedure GetApiKey() ApiKey: Text
begin
if not IsolatedStorage.Get('InvoiceXmlApiKey', DataScope::Company, ApiKey) then
Error('The InvoiceXML API key has not been configured.');
end;
procedure BaseUrl(): Text
begin
exit('https://api.invoicexml.com');
end;
}
Mapping BC fields to invoice JSON
The API accepts a nested EN 16931 invoice document; everything it needs already exists on the posted invoice and the company card. The mapping worth pinning to the wall:
| Business Central source | JSON path | Note |
|---|---|---|
Sales Invoice Header."No." | invoice.invoiceNumber | BT-1 |
"Posting Date" | invoice.issueDate | Format as yyyy-MM-dd |
"Currency Code" | invoice.currency | Blank means LCY; substitute from General Ledger Setup |
| Company Information (Name, VAT Reg. No., address) | invoice.seller | Seller VAT id is mandatory |
"Sell-to Customer Name" + sell-to address fields | invoice.buyer | Country as ISO code (DE, FR) |
| Company Information IBAN | invoice.paymentDetails.paymentAccountIdentifier | Recommended for B2B |
Sales Invoice Line.Description | lines[].item.name | Per line |
Quantity / "Unit Price" | lines[].quantity / lines[].priceDetails.netPrice | Net prices; the API computes totals |
"VAT %" | lines[].vatInformation.rate | Per-rate breakdown is computed server-side |
"Your Reference" or a custom field | invoice.buyerReference | Carries the Leitweg-ID for XRechnung |
Totals, rounding, and the VAT breakdown are deliberately absent from the mapping: the API computes them from the lines and refuses to produce a document whose numbers do not reconcile, which turns a whole class of BC rounding-and-discount edge cases into a structured error instead of a rejected invoice.
In AL, the mapping is a JsonObject builder:
local procedure BuildInvoiceJson(SalesInvHeader: Record "Sales Invoice Header") Body: JsonObject
var
SalesInvLine: Record "Sales Invoice Line";
CompanyInfo: Record "Company Information";
GLSetup: Record "General Ledger Setup";
Invoice: JsonObject;
Seller: JsonObject;
SellerAddress: JsonObject;
Buyer: JsonObject;
BuyerAddress: JsonObject;
Payment: JsonObject;
Lines: JsonArray;
LineObj: JsonObject;
Item: JsonObject;
Price: JsonObject;
Vat: JsonObject;
CurrencyCode: Code[10];
begin
CompanyInfo.Get();
GLSetup.Get();
Invoice.Add('invoiceNumber', SalesInvHeader."No.");
Invoice.Add('issueDate', Format(SalesInvHeader."Posting Date", 0, '<Year4>-<Month,2>-<Day,2>'));
CurrencyCode := SalesInvHeader."Currency Code";
if CurrencyCode = '' then
CurrencyCode := GLSetup."LCY Code";
Invoice.Add('currency', CurrencyCode);
Seller.Add('name', CompanyInfo.Name);
Seller.Add('vatIdentifier', CompanyInfo."VAT Registration No.");
SellerAddress.Add('line1', CompanyInfo.Address);
SellerAddress.Add('city', CompanyInfo.City);
SellerAddress.Add('postCode', CompanyInfo."Post Code");
SellerAddress.Add('country', CompanyInfo."Country/Region Code");
Seller.Add('postalAddress', SellerAddress);
Invoice.Add('seller', Seller);
Buyer.Add('name', SalesInvHeader."Sell-to Customer Name");
BuyerAddress.Add('line1', SalesInvHeader."Sell-to Address");
BuyerAddress.Add('city', SalesInvHeader."Sell-to City");
BuyerAddress.Add('postCode', SalesInvHeader."Sell-to Post Code");
BuyerAddress.Add('country', SalesInvHeader."Sell-to Country/Region Code");
Buyer.Add('postalAddress', BuyerAddress);
Invoice.Add('buyer', Buyer);
if CompanyInfo.IBAN <> '' then begin
Payment.Add('paymentAccountIdentifier', CompanyInfo.IBAN);
Invoice.Add('paymentDetails', Payment);
end;
SalesInvLine.SetRange("Document No.", SalesInvHeader."No.");
SalesInvLine.SetFilter(Type, '<>%1', SalesInvLine.Type::" ");
SalesInvLine.SetFilter(Quantity, '<>0');
if SalesInvLine.FindSet() then
repeat
Clear(LineObj);
Clear(Item);
Clear(Price);
Clear(Vat);
LineObj.Add('quantity', SalesInvLine.Quantity);
Item.Add('name', SalesInvLine.Description);
LineObj.Add('item', Item);
Price.Add('netPrice', SalesInvLine."Unit Price");
LineObj.Add('priceDetails', Price);
Vat.Add('rate', SalesInvLine."VAT %");
LineObj.Add('vatInformation', Vat);
Lines.Add(LineObj);
until SalesInvLine.Next() = 0;
Invoice.Add('lines', Lines);
Body.Add('invoice', Invoice);
end;
Line discounts, item references, and units extend the same way (priceDetails, item, and unitCode fields in the create reference); the skeleton above is a compliant EN 16931 invoice as it stands.
Create the hybrid invoice from AL
One codeunit turns the posted invoice into the finished hybrid, routing between the German and French brand on the customer's country:
codeunit 50101 "InvoiceXML Create"
{
procedure CreateHybridInvoice(SalesInvHeader: Record "Sales Invoice Header"; var TempBlob: Codeunit "Temp Blob")
var
Setup: Codeunit "InvoiceXML Setup";
Client: HttpClient;
Content: HttpContent;
ContentHeaders: HttpHeaders;
Response: HttpResponseMessage;
Body: JsonObject;
BodyText: Text;
ErrorText: Text;
PdfOutStream: OutStream;
ResponseInStream: InStream;
Endpoint: Text;
begin
Body := BuildInvoiceJson(SalesInvHeader);
Body.WriteTo(BodyText);
// Same format, two brands: route on the buyer's country
if SalesInvHeader."Sell-to Country/Region Code" = 'FR' then
Endpoint := '/v1/create/facturx'
else
Endpoint := '/v1/create/zugferd';
Content.WriteFrom(BodyText);
Content.GetHeaders(ContentHeaders);
ContentHeaders.Remove('Content-Type');
ContentHeaders.Add('Content-Type', 'application/json');
Client.DefaultRequestHeaders.Add('Authorization', 'Bearer ' + Setup.GetApiKey());
if not Client.Post(Setup.BaseUrl() + Endpoint, Content, Response) then
Error('The InvoiceXML API could not be reached.');
if not Response.IsSuccessStatusCode() then begin
Response.Content.ReadAs(ErrorText);
Error('E-invoice generation failed:\%1', ErrorText);
end;
Response.Content.ReadAs(ResponseInStream);
TempBlob.CreateOutStream(PdfOutStream);
CopyStream(PdfOutStream, ResponseInStream);
end;
}
The response is a finished document: PDF/A-3 container, embedded factur-x.xml, XMP metadata declaring the EN 16931 profile, already validated against the official Schematron before it left the server. If the data cannot produce a compliant invoice, the HTTP 400 body lists the violated rules as structured findings, which is why the error path reads the response instead of discarding it: the message names the exact field a user needs to fix.
Attach, validate, and send
Attach the result to the posted invoice so it lives where BC users expect documents, then let your existing dispatch pick it up:
procedure AttachToPostedInvoice(SalesInvHeader: Record "Sales Invoice Header"; var TempBlob: Codeunit "Temp Blob")
var
DocumentAttachment: Record "Document Attachment";
RecRef: RecordRef;
DocInStream: InStream;
begin
TempBlob.CreateInStream(DocInStream);
RecRef.GetTable(SalesInvHeader);
DocumentAttachment.SaveAttachmentFromStream(
DocInStream, RecRef, SalesInvHeader."No." + '-facturx.pdf');
end;
Wire the two calls into an action on the Posted Sales Invoice page, or subscribe to the posting event to generate automatically. Because every created document is validated server-side before delivery, no separate pre-send check is required; when you also receive documents from other systems, POST /v1/validate/zugferd accepts any hybrid PDF as a multipart upload and returns the same structured verdict, useful as an acceptance gate on files you did not generate. The online validator runs the identical check interactively.
Incoming supplier e-invoices
The receiving side of the German mandate arrived first: since January 2025 you cannot refuse a compliant e-invoice, so supplier documents landing in AP will increasingly be ZUGFeRD hybrids, plus the plain PDFs from suppliers who have not modernised. Two endpoints turn both into data a purchase document can be built from:
Structured documents: POST /v1/extract/json parses the embedded XML of a hybrid PDF (or standalone CII/UBL XML) deterministically into normalized InvoiceDocument JSON, no AI involved: what the supplier declared is what you book. AL walks the result with the same JsonObject types used above and fills a purchase invoice or a staging table for review.
Plain PDFs: POST /v1/parse/json reads typed, scanned, and photographed invoices with AI and returns the same JSON shape plus a confidence object (overall and per-area scores between 0.0 and 1.0), so your extension can auto-fill from certain reads and route uncertain ones to a review page instead of a posted document. Non-invoices are rejected with error code 4008 before they reach your logic.
procedure ExtractIncomingInvoice(var TempBlob: Codeunit "Temp Blob") Extracted: JsonObject
var
Setup: Codeunit "InvoiceXML Setup";
Client: HttpClient;
Content: HttpContent;
ContentHeaders: HttpHeaders;
Response: HttpResponseMessage;
FileInStream: InStream;
ResponseText: Text;
begin
TempBlob.CreateInStream(FileInStream);
Content.WriteFrom(FileInStream);
Content.GetHeaders(ContentHeaders);
ContentHeaders.Remove('Content-Type');
ContentHeaders.Add('Content-Type', 'application/octet-stream');
Client.DefaultRequestHeaders.Add('Authorization', 'Bearer ' + Setup.GetApiKey());
if not Client.Post(Setup.BaseUrl() + '/v1/extract/json', Content, Response) then
Error('The InvoiceXML API could not be reached.');
Response.Content.ReadAs(ResponseText);
if not Response.IsSuccessStatusCode() then
Error('Extraction failed:\%1', ResponseText);
Extracted.ReadFrom(ResponseText);
end;
When an e-invoice carries embedded supporting documents (delivery notes or timesheets in the EN 16931 BG-24 group, common on German B2G documents), POST /v1/extract/attachments returns the original files as one ZIP; see the attachment extraction reference.
XRechnung for German B2G
Invoices to German federal, state, and municipal buyers are not ZUGFeRD but XRechnung: pure XML, no PDF layer, validated against the KoSIT rules by portals such as ZRE and OZG-RE. The same extension covers it with a third endpoint and two data additions:
The Leitweg-ID goes in buyerReference. The public sector buyer assigns this routing identifier during onboarding, and rule BR-DE-15 rejects documents without it. It never appears on any document, so store it on the customer card (a dedicated field beats overloading Your Reference) and map it into the request.
Seller contact details are mandatory. XRechnung requires the seller contact group (name, phone, email) and electronic addresses for both parties, fields base EN 16931 leaves optional; the company card holds all of them.
Call POST /v1/create/xrechnung with the extended body and the response is XRechnung 3.0 XML, already validated against the EN 16931 Schematron and the current KoSIT rule set. Because pure XML gives the accounting team nothing to look at, POST /v1/render/xrechnung/to/pdf produces a human-readable preview worth attaching alongside the XML; only the XML goes to the portal. The full B2G walkthrough, including the validation layers and error handling, is in the XRechnung API toolkit.
The Power Automate route
Everything above also works without AL. Power Automate's Business Central connector triggers on posted sales invoices and reads header and line records; an HTTP action POSTs the same JSON body to the same endpoints; and the returned document flows to SharePoint, email, or back into BC as an attachment. The companion guide to e-invoicing in Power Automate walks the flow structure, the line-mapping Select action, and the per-format configuration in detail; only the trigger and data source differ for Business Central.
The practical split: the AL route keeps everything inside BC's permission model and document flow and suits per-tenant extensions and ISV verticals; the flow route wins when the people who own invoicing automation live in Power Platform rather than in AL.
Why not an AppSource add-on?
The conventional answer to a BC feature gap is an AppSource app, and for e-invoicing that choice carries a cost profile worth seeing clearly. An add-on means per-tenant licensing, a vendor release cycle layered on top of Microsoft's, and a dependency that must stay compatible across BC's twice-yearly major upgrades. Most importantly, the formats themselves move: FeRD, FNFE-MPE, and KoSIT all ship new versions on their own calendars, and with an installed app you wait for the vendor's update, schedule the app upgrade, and retest your customisations, every time, for a function that is not your business.
The REST API inverts that. Your AL surface is a couple of hundred lines that never need to change when a specification does, because the current rule sets are live server-side before their effective dates: new ZUGFeRD, Factur-X, and XRechnung versions arrive with nothing to monitor, no app update, and no redeploy. There is no per-tenant licensing, no upgrade-compatibility matrix, and no format logic inside your tenant at all; validation, PDF/A-3 conformance, extraction, and AI parsing all run behind the same key. Behind the endpoints sits a team that does European e-invoicing compliance full time, with professional support and missing features integrated on request. That is the difference between installing a product and plugging into a complete compliance service.
Data handling is stateless by architecture: documents are processed in memory and purged when the response ships. Nothing is stored, nothing is logged, and no invoice data trains any model, so VAT numbers, IBANs, and customer relationships never persist outside your tenant.
Endpoint reference
| Operation | Endpoint | Input | Output |
|---|---|---|---|
| Create ZUGFeRD | POST /v1/create/zugferd | JSON | PDF/A-3 binary |
| Create Factur-X | POST /v1/create/facturx | JSON | PDF/A-3 binary |
| Create XRechnung | POST /v1/create/xrechnung | JSON | XRechnung 3.0 XML |
| Validate ZUGFeRD / Factur-X | POST /v1/validate/zugferd, /v1/validate/facturx | Hybrid PDF | Validation JSON |
| Validate XRechnung | POST /v1/validate/xrechnung | XRechnung XML | Validation JSON |
| Extract as JSON | POST /v1/extract/json | Hybrid PDF or CII/UBL XML | Structured JSON |
| Parse PDF with AI | POST /v1/parse/json | Any invoice PDF (typed, scanned, photo) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Hybrid PDF or CII/UBL XML | ZIP archive |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi | Interactive API explorer: api.invoicexml.com/v1/scalar
Get started
The whole integration is the three codeunits in this guide: configure the key, build the JSON, call the endpoint, attach the result. No dependencies beyond the AL base application, and nothing to revisit when the formats move.
Create a free InvoiceXML account → get 100 credits for free, no credit card required.
Related resources:
- E-invoicing in Power Automate: ZUGFeRD, XRechnung, Factur-X and UBL from Dynamics 365
- E-invoicing in Salesforce: generate e-invoices from Flow and Apex
- ZUGFeRD API: the complete toolkit for German B2B
- Factur-X API: the complete toolkit
- XRechnung API: the complete toolkit for German B2G
- Factur-X in C# / .NET: complete guide
- E-invoicing mandate deadlines by country
- Full API documentation
InvoiceXML is a REST API for European e-invoice compliance covering ZUGFeRD, Factur-X, XRechnung, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and callable from any Microsoft stack: Business Central AL extensions, Power Automate flows, Azure Functions, or ASP.NET Core services.