This guide adds ZUGFeRD and XRechnung to Dynamics AX 2012 and Dynamics 365 Finance and Operations. You need a few X++ classes and one REST API. The code reads the posted customer invoice, sends it as JSON and stores the returned PDF as an attachment. The email section shows how the API delivers the invoice to the customer by email. The import section shows how to check supplier invoices and turn them into pending vendor invoices. The listings are D365 X++. The side by side section shows what changes in AX 2012. If you run Dynamics NAV, read the Dynamics NAV guide instead. It shows the same integration in C/AL.
Why Dynamics AX and D365 Finance need ZUGFeRD now
German companies on Dynamics AX or Dynamics 365 Finance must send and receive structured e-invoices. Receiving e-invoices is mandatory for every German business since January 2025. Sending starts in January 2027 for businesses with more than EUR 800,000 turnover in the previous year, and in January 2028 for all others. The SSRS invoice report produces a plain PDF, and a plain PDF is not an e-invoice. The required file is a PDF with an XML copy of the invoice embedded inside. People read the PDF, software reads the XML. That file is ZUGFeRD. The Germany mandate page has the full timeline.
Print management ends with a report. Posting creates CustInvoiceJour and CustInvoiceTrans. Print management runs the SalesInvoice report and emails or prints the result. The ZUGFeRD PDF and the XRechnung XML are created by the API from the data that is already in these tables. One HTTP call per posted invoice.
Format updates need no code change. New versions of ZUGFeRD, XRechnung and the EN 16931 rules appear every year or two. The API switches to them on the day they take effect. Your X++ keeps sending the same JSON. There is no package to rebuild and no deployment to schedule.
What you need
- Dynamics 365 Finance and Operations (any current version) with a development environment and your own model. Or Dynamics AX 2012 R2 or R3 with .NET Framework 4.5 or later on the AOS.
- HTTPS from the server to
api.invoicexml.com. In D365 this works out of the box. In AX 2012, the AOS service account needs outbound HTTPS. - Three small schema changes: a parameters table, a Leitweg-ID field on
CustTableand a flag field onCustInvoiceJour. In D365 these are table extensions. In AX 2012 they are fields in your layer. - A house bank account (
BankAccountTable) whose IBAN goes on the invoice. - An InvoiceXML account. Create one. The account comes with an API key and 100 free credits, without a credit card.
How the integration works
A customer invoice is posted. Your class reads CustInvoiceJour and its lines, builds a JSON body and posts it to one endpoint. On the server, the API works out the totals and the VAT, writes the XML, embeds it in a PDF/A-3 file and checks the result against all rules. The response is the finished PDF. Your class attaches it to the posted invoice, or the API emails it to the customer for you. One call in, one validated file out.
| 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 create these objects in your own model (D365) or your own layer (AX 2012). None of them is a standard object. The listings in this guide are the complete source.
- Table InvxParameters: the API key, the base URL, the house bank, the sender address and the procurement category for imported lines.
- Field CustTable.InvxLeitwegId and field CustInvoiceJour.InvxEInvoiceCreated: the Leitweg-ID of public buyers and the done flag.
- Contract classes InvxRequestContract, InvxInvoiceContract, InvxLineContract and a few small ones: the JSON body as X++ classes.
- Class InvxClient: the HTTP calls (JSON body and file upload).
- Class InvxInvoiceMapper: reads the posted invoice and fills the contracts.
- Class InvxCreate: creates the file, attaches it, sends it by email. Plus an event handler or a batch service that calls it.
- Class InvxImport: validates incoming invoices, sends the report back, reads the data and creates the pending vendor invoice.
Everything else is standard: CustInvoiceJour, CustInvoiceTrans, TaxTrans, CustTable, CompanyInfo, LogisticsPostalAddress, BankAccountTable, VendTable, VendInvoiceInfoTable, VendInvoiceInfoLine and the document management tables.
Step 1: Store the API key and call the API
Store the key in a parameters table. Make the field a container and encrypt it with an edit method. This is the same pattern that Email parameters use for the SMTP password (SysEmailParameters, method editSMTPPassword). The form never shows the key. In D365 you can also keep the key in Azure Key Vault through Key Vault parameters and read it with the KeyVaultCertificateHelper class. The client class is a thin wrapper around System.Net.Http.HttpClient: postJson for the create endpoints and postFile for the upload endpoints. The mapper, the create class and the import class only ever call these two.
// Table InvxParameters (one record, like every other parameters table)
// ApiKey container encrypted, see editApiKey
// BaseUrl str 250 default https://api.invoicexml.com/
// BankAccountId BankAccountId the house bank whose IBAN goes on the invoice
// FromEmail Email sender address for the email endpoint
// ProcurementCategory RecId category for imported invoice lines
// Edit method on the table, same pattern as the SMTP password
public edit str editApiKey(boolean _set, str _value)
{
return Global::editEncryptedField(this, _set, _value, fieldNum(InvxParameters, ApiKey));
}
// Class InvxClient: one method for JSON bodies, one for file uploads
class InvxClient
{
public static System.Net.Http.HttpResponseMessage postJson(str _path, str _json)
{
System.Net.Http.HttpClient client = InvxClient::newClient();
System.Net.Http.StringContent content =
new System.Net.Http.StringContent(_json, System.Text.Encoding::UTF8, 'application/json');
return client.PostAsync(_path, content).Result;
}
// The upload endpoints take a multipart form with one field named "file"
public static System.Net.Http.HttpResponseMessage postFile(
str _path, System.Byte[] _file, str _fileName, str _language = '')
{
System.Net.Http.HttpClient client = InvxClient::newClient();
System.Net.Http.MultipartFormDataContent form = new System.Net.Http.MultipartFormDataContent();
form.Add(new System.Net.Http.ByteArrayContent(_file), 'file', _fileName);
if (_language)
{
// Language of the validation report (en, de, fr)
form.Add(new System.Net.Http.StringContent(_language), 'validationLanguage');
}
return client.PostAsync(_path, form).Result;
}
public static str readText(System.Net.Http.HttpResponseMessage _response)
{
return _response.Content.ReadAsStringAsync().Result;
}
// The validation report endpoints put the verdict in the X-Invoice-Valid header
public static boolean headerIsTrue(System.Net.Http.HttpResponseMessage _response, str _name)
{
System.Collections.IEnumerable values = _response.Headers.GetValues(_name);
System.Collections.IEnumerator enumerator = values.GetEnumerator();
enumerator.MoveNext();
return System.Convert::ToString(enumerator.Current) == 'true';
}
private static System.Net.Http.HttpClient newClient()
{
InvxParameters parameters = InvxParameters::find();
System.Net.Http.HttpClient client = new System.Net.Http.HttpClient();
client.BaseAddress = new System.Uri(parameters.BaseUrl);
client.DefaultRequestHeaders.Add('Authorization', 'Bearer ' + parameters.editApiKey(false, ''));
return client;
}
}
The paths in this guide are relative (v1/create/zugferd), so the base URL must end with a slash. HttpClient is thread safe and meant to be reused. In a batch job that processes many invoices, create it once and keep it in a static field. The AX 2012 version of this class is in the side by side section.
Step 2: Map Dynamics fields to JSON
The API takes an EN 16931 invoice as JSON. All the data is on the posted invoice, the customer and the legal entity. CustInvoiceJour keeps the invoicing name, the VAT number and the invoice address as they were at posting time, so the e-invoice matches the printed one even if the customer moved later. This is the mapping:
| Dynamics field | JSON path | Note |
|---|---|---|
CustInvoiceJour.InvoiceId | invoice.invoiceNumber | BT-1 |
CustInvoiceJour.InvoiceDate, DueDate | invoice.issueDate, dueDate | Format as yyyy-MM-dd |
CustInvoiceJour.CurrencyCode | invoice.currency | ISO code, as stored |
CustInvoiceJour.PurchaseOrder | invoice.purchaseOrderReference | The customer's reference, BT-13 |
CompanyInfo (Name, VATNum, postal address) | invoice.seller | The legal entity. The seller VAT id is required |
CustInvoiceJour.InvoicingName, VATNum, InvoicePostalAddress | invoice.buyer | Address via LogisticsPostalAddress; country as two-letter ISO code |
BankAccountTable.BankIBAN | invoice.paymentDetails.paymentAccountIdentifier | The house bank from the parameters |
CustInvoiceTrans.Name | lines[].item.name | Per line |
CustInvoiceTrans.Qty, LineAmount | lines[].quantity, lines[].priceDetails.netPrice | LineAmount / Qty is the net price after the line discount |
CustInvoiceTrans.SalesUnit | lines[].unitCode | UN/ECE code: H87 for pieces, HUR for hours |
TaxTrans.TaxValue of the line | lines[].vatInformation.rate | The rate that posting applied to the line |
CustTable.InvxLeitwegId | invoice.buyerReference | XRechnung only |
You do not send totals, rounding or VAT amounts. The API calculates them from the lines. When the lines do not add up to a consistent invoice, the API answers with an error and no file. A rounding or discount problem is found by your code, not by the customer.
Two details are specific to Dynamics. LogisticsPostalAddress.CountryRegionId holds three-letter codes (DEU, AUT, FRA). The API needs two letters, and LogisticsAddressCountryRegion.ISOcode has them. And the VAT rate of a posted line is in TaxTrans, linked by SourceTableId and SourceRecId. TaxData holds the setup rate per tax code and date. Tax::calcTaxAmount and the tax classes calculate amounts, but you do not need them: the API calculates the amounts from the rate.
In X++, the JSON body is a set of data contract classes. FormJsonSerializer turns them into JSON. The DataMemberAttribute name is the JSON field name. The same classes read the JSON back when you import supplier invoices, so you define them once.
// Contract classes. Each has one parm method per field, like the ones shown here.
// InvxAddressContract line1, city, postCode, country
// InvxContactContract name, phone, email (XRechnung only)
// InvxEAddressContract identifier, schemeId (XRechnung only)
// InvxPartyContract name, vatIdentifier, postalAddress, contact, electronicAddress
// InvxPaymentContract paymentAccountIdentifier
// InvxItemContract name
// InvxPriceContract netPrice (real)
// InvxVatContract rate (real)
// InvxTotalsContract grandTotalAmount, duePayableAmount (real) filled by the API on extraction
// InvxEmailContract from, to, subject, message (email endpoints only)
[DataContractAttribute]
class InvxLineContract
{
real quantity;
str unitCode;
InvxItemContract item;
InvxPriceContract priceDetails;
InvxVatContract vatInformation;
[DataMemberAttribute('quantity')]
public real parmQuantity(real _value = quantity) { quantity = _value; return quantity; }
[DataMemberAttribute('unitCode')]
public str parmUnitCode(str _value = unitCode) { unitCode = _value; return unitCode; }
[DataMemberAttribute('item')]
public InvxItemContract parmItem(InvxItemContract _value = item) { item = _value; return item; }
[DataMemberAttribute('priceDetails')]
public InvxPriceContract parmPriceDetails(InvxPriceContract _value = priceDetails) { priceDetails = _value; return priceDetails; }
[DataMemberAttribute('vatInformation')]
public InvxVatContract parmVatInformation(InvxVatContract _value = vatInformation) { vatInformation = _value; return vatInformation; }
}
[DataContractAttribute]
class InvxInvoiceContract
{
str invoiceNumber, issueDate, dueDate, currency, buyerReference, purchaseOrderReference;
InvxPartyContract seller, buyer;
InvxPaymentContract paymentDetails;
InvxTotalsContract totals;
List lines;
[DataMemberAttribute('invoiceNumber')]
public str parmInvoiceNumber(str _value = invoiceNumber) { invoiceNumber = _value; return invoiceNumber; }
// issueDate, dueDate, currency, buyerReference, purchaseOrderReference,
// seller, buyer, paymentDetails and totals: same pattern
[DataMemberAttribute('lines'), DataCollectionAttribute(Types::Class, classStr(InvxLineContract))]
public List parmLines(List _value = lines) { lines = _value; return lines; }
}
[DataContractAttribute]
class InvxRequestContract
{
InvxInvoiceContract invoice;
InvxEmailContract email;
[DataMemberAttribute('invoice')]
public InvxInvoiceContract parmInvoice(InvxInvoiceContract _value = invoice) { invoice = _value; return invoice; }
[DataMemberAttribute('email')]
public InvxEmailContract parmEmail(InvxEmailContract _value = email) { email = _value; return email; }
}
The mapper reads the posted invoice and fills the contracts:
class InvxInvoiceMapper
{
public static InvxInvoiceContract build(CustInvoiceJour _jour)
{
CustInvoiceTrans trans;
TaxTrans taxTrans;
CustTable custTable = CustTable::find(_jour.InvoiceAccount);
CompanyInfo companyInfo = CompanyInfo::find();
InvxParameters parameters = InvxParameters::find();
InvxInvoiceContract invoice = new InvxInvoiceContract();
InvxPaymentContract payment = new InvxPaymentContract();
List lines = new List(Types::Class);
invoice.parmInvoiceNumber(_jour.InvoiceId);
invoice.parmIssueDate(InvxInvoiceMapper::isoDate(_jour.InvoiceDate));
invoice.parmDueDate(InvxInvoiceMapper::isoDate(_jour.DueDate));
invoice.parmCurrency(_jour.CurrencyCode);
invoice.parmPurchaseOrderReference(_jour.PurchaseOrder); // the customer's reference, BT-13
invoice.parmBuyerReference(custTable.InvxLeitwegId); // XRechnung only, empty otherwise
// Seller: the legal entity
invoice.parmSeller(InvxInvoiceMapper::party(
companyInfo.Name(), companyInfo.VATNum, companyInfo.postalAddress()));
// Buyer: name, VAT id and address as they were at posting time
invoice.parmBuyer(InvxInvoiceMapper::party(
_jour.InvoicingName, _jour.VATNum, LogisticsPostalAddress::findRecId(_jour.InvoicePostalAddress)));
payment.parmPaymentAccountIdentifier(BankAccountTable::find(parameters.BankAccountId).BankIBAN);
invoice.parmPaymentDetails(payment);
// One line per invoice line. Text lines have no quantity and are skipped.
while select trans
where trans.SalesId == _jour.SalesId
&& trans.InvoiceId == _jour.InvoiceId
&& trans.InvoiceDate == _jour.InvoiceDate
&& trans.NumberSequenceGroup == _jour.NumberSequenceGroup
&& trans.Qty != 0
{
InvxLineContract line = new InvxLineContract();
InvxItemContract item = new InvxItemContract();
InvxPriceContract price = new InvxPriceContract();
InvxVatContract vat = new InvxVatContract();
// The posted tax transaction holds the rate that was applied to this line
select firstonly TaxValue from taxTrans
where taxTrans.SourceTableId == tableNum(CustInvoiceTrans)
&& taxTrans.SourceRecId == trans.RecId;
item.parmName(trans.Name);
price.parmNetPrice(trans.LineAmount / trans.Qty); // net, after the line discount
vat.parmRate(taxTrans.TaxValue);
line.parmQuantity(trans.Qty);
line.parmUnitCode(InvxInvoiceMapper::unitCode(trans.SalesUnit));
line.parmItem(item);
line.parmPriceDetails(price);
line.parmVatInformation(vat);
lines.addEnd(line);
}
invoice.parmLines(lines);
return invoice;
}
private static InvxPartyContract party(str _name, VATNum _vatNum, LogisticsPostalAddress _address)
{
InvxPartyContract party = new InvxPartyContract();
InvxAddressContract address = new InvxAddressContract();
party.parmName(_name);
party.parmVatIdentifier(_vatNum);
address.parmLine1(_address.Street);
address.parmCity(_address.City);
address.parmPostCode(_address.ZipCode);
// Dynamics stores DEU, AUT, FRA. The API needs the two-letter ISO code.
address.parmCountry(LogisticsAddressCountryRegion::find(_address.CountryRegionId).ISOcode);
party.parmPostalAddress(address);
return party;
}
private static str isoDate(date _date)
{
return date2Str(_date, 321, DateDay::Digits2, DateSeparator::Hyphen,
DateMonth::Digits2, DateSeparator::Hyphen, DateYear::Digits4, DateFlags::FormatAll);
}
// UN/ECE Recommendation 20 codes for the units your items use
private static str unitCode(UnitOfMeasureSymbol _unit)
{
switch (_unit)
{
case 'Stk', 'ea', 'pcs': return 'H87';
case 'Std', 'h': return 'HUR';
case 'kg': return 'KGM';
case 'm': return 'MTR';
default: return 'C62';
}
}
}
The API treats null and empty fields as not sent. So buyerReference, the contact and the electronic addresses stay out of a B2B invoice, and the email object only matters on the email endpoints. If a line has no tax transaction (a tax exempt line), taxTrans.TaxValue is 0, which is the correct rate for it.
Step 3: Create the ZUGFeRD invoice
One method builds the body, posts it and returns the PDF as a stream. The caller can attach it, save it or send it.
class InvxCreate
{
public static System.IO.Stream createHybrid(CustInvoiceJour _jour)
{
InvxRequestContract request = new InvxRequestContract();
request.parmInvoice(InvxInvoiceMapper::build(_jour));
// Same format, two names: pick the endpoint by the buyer's country
str path = request.parmInvoice().parmBuyer().parmPostalAddress().parmCountry() == 'FR'
? 'v1/create/facturx'
: 'v1/create/zugferd';
System.Net.Http.HttpResponseMessage response =
InvxClient::postJson(path, FormJsonSerializer::serializeClass(request));
if (!response.IsSuccessStatusCode)
{
// 400 returns JSON with an errors array: one entry per violated rule,
// with the rule id and the JSON path of the field
throw error(strFmt("E-invoice generation failed: %1", InvxClient::readText(response)));
}
return response.Content.ReadAsStreamAsync().Result;
}
The stream holds the complete file: a PDF/A-3 container, the factur-x.xml inside it and the XMP metadata for the EN 16931 profile. The server validates the file before it answers. The error path shows the API message in the Infolog. The message names the rule and the field, so the user knows what to fix on the customer or the invoice. Factur-X is the French name of the same standard, so the country check in createHybrid is all it takes for French customers. The Factur-X API toolkit explains the French requirements.
Step 4: Attach the PDF to the posted invoice
Store the PDF as an attachment on the posted invoice. It then shows in the attachments of the Customer invoice journal, next to the invoice. DocumentManagement::attachFile takes the stream and stores the file in the configured document storage. Then set the flag field, so the invoice is not processed twice.
public static void createAndAttach(CustInvoiceJour _jour)
{
System.IO.Stream pdf = InvxCreate::createHybrid(_jour);
str fileName = _jour.InvoiceId + '-zugferd.pdf';
ttsbegin;
// Attached to the posted invoice, visible in the attachments of the invoice journal
DocumentManagement::attachFile(_jour.RecId, _jour.TableId, _jour.DataAreaId,
'File', pdf, fileName, 'application/pdf', fileName);
_jour.selectForUpdate(true);
_jour.InvxEInvoiceCreated = NoYes::Yes;
_jour.update();
ttscommit;
}
}
// Option A: right after posting. A post-event handler on the invoice posting class.
class InvxCreateEvents
{
[PostHandlerFor(classStr(SalesInvoiceJournalPost), methodStr(SalesInvoiceJournalPost, postJournalPost))]
public static void SalesInvoiceJournalPost_Post_postJournalPost(XppPrePostArgs _args)
{
SalesInvoiceJournalPost post = _args.getThis() as SalesInvoiceJournalPost;
CustInvoiceJour jour = post.parmJournalRecord();
try
{
InvxCreate::createAndAttach(jour);
}
catch
{
// Never block posting. The batch job below picks the invoice up later.
warning(strFmt("E-invoice for %1 will be created by the batch job.", jour.InvoiceId));
}
}
}
// Option B: a batch job. The service picks up every posted invoice without an e-invoice.
class InvxCreateService extends SysOperationServiceBase
{
public void run()
{
CustInvoiceJour jour;
while select jour
where jour.InvxEInvoiceCreated == NoYes::No
{
InvxCreate::createAndAttach(jour);
}
}
}
When to run it. Option A creates the file as soon as the invoice is posted. Option B runs every few minutes as a batch job (SysOperationServiceController) and catches everything the event missed. Most companies use both. CustInvoiceJour.onInserted is too early: the lines and the tax transactions are written after the header. Print management is not involved. It prints and emails the SSRS report as before. If you want no X++ at all, a Power Automate flow can read the posted invoice through the data entities and call the same endpoint.
Sending from Dynamics. If your users send invoices from Dynamics, change the print management email to attach the hybrid PDF from the attachments instead of the SSRS PDF. The hybrid PDF looks like a normal invoice when opened. The customer receives one file that works for people and for software.
Send the invoice by email from the API
Dynamics does not have to send anything. One call to the email endpoint creates the invoice, validates it and delivers it to the customer. The file never passes through Dynamics. In Germany this counts as a complete delivery: there is no central platform for B2B e-invoices, so an email with a ZUGFeRD PDF fulfils the obligation.
Set up 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 works as sender. The customer sees your company address, and a reply lands in your mailbox. The body is the same JSON plus an email object. The recipient is the customer's primary email address:
public static void sendByEmail(CustInvoiceJour _jour)
{
InvxParameters parameters = InvxParameters::find();
InvxRequestContract request = new InvxRequestContract();
InvxEmailContract email = new InvxEmailContract();
request.parmInvoice(InvxInvoiceMapper::build(_jour));
email.parmFrom(parameters.FromEmail); // an address on your verified sending domain
email.parmTo(CustTable::find(_jour.InvoiceAccount).email()); // primary email of the customer
email.parmSubject(strFmt("Rechnung %1", _jour.InvoiceId));
email.parmMessage(strFmt("Guten Tag,\n\nanbei erhalten Sie unsere Rechnung %1 als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen", _jour.InvoiceId));
request.parmEmail(email);
System.Net.Http.HttpResponseMessage response =
InvxClient::postJson('v1/create/zugferd/email', FormJsonSerializer::serializeClass(request));
if (!response.IsSuccessStatusCode)
{
throw error(strFmt("E-invoice email failed: %1", InvxClient::readText(response)));
}
// 200 means: created, validated and handed to delivery. The response has no file.
ttsbegin;
_jour.selectForUpdate(true);
_jour.InvxEInvoiceCreated = NoYes::Yes;
_jour.update();
ttscommit;
}
Use this route when the API should send. Use the plain endpoint from step 3 when Dynamics should store the file and send it itself. XRechnung (/v1/create/xrechnung/email) and Factur-X have the same email route.
Import incoming supplier invoices
Accepting e-invoices is mandatory in Germany since January 2025, so a growing share of supplier invoices now arrives as ZUGFeRD PDFs or XRechnung XML. The inbound pipeline has three steps: validate the file, return the report if it fails, extract the data if it passes. A mailbox watcher or a file share delivers the files to the class. In D365, a custom service or a batch job over a storage container hands them in.
- Validate. Send the file to
POST /v1/validate/zugferd/reportor/v1/validate/xrechnung/report. The file is checked against EN 16931 and the German rules. The body of the response is a PDF validation report, theX-Invoice-Validheader holds the verdict. ThevalidationLanguageform field (dehere) sets the report language./v1/validate/zugferdwithout/reportreturns the same findings as JSON instead. - Reply with the report. When the header says
false, mail the PDF report to the sender and stop. The report names each broken rule, the field and the message. The supplier fixes the invoice on their side, with no phone call to your accounts payable team. - Extract. When the header says
true, post the file toPOST /v1/extract/json. The API reads the embedded XML (or the XRechnung XML) and returns it as JSON with the same shape as the create request.FormJsonSerializerreads it into the contract classes from step 2.
class InvxImport
{
public static void processInbound(System.Byte[] _file, str _fileName, Email _sender)
{
// 1. Validate. PDF files are ZUGFeRD, XML files are XRechnung.
str path = strEndsWith(strLwr(_fileName), '.xml')
? 'v1/validate/xrechnung/report'
: 'v1/validate/zugferd/report';
System.Net.Http.HttpResponseMessage response = InvxClient::postFile(path, _file, _fileName, 'de');
if (!response.IsSuccessStatusCode)
{
throw error(strFmt("Validation failed: %1", InvxClient::readText(response)));
}
// 2. Invalid: send the PDF report to the sender and stop
if (!InvxClient::headerIsTrue(response, 'X-Invoice-Valid'))
{
InvxImport::replyWithReport(_sender, _fileName, response.Content.ReadAsStreamAsync().Result);
return;
}
// 3. Valid: read the data and create the pending vendor invoice
response = InvxClient::postFile('v1/extract/json', _file, _fileName);
if (!response.IsSuccessStatusCode)
{
throw error(strFmt("Extraction failed: %1", InvxClient::readText(response)));
}
InvxRequestContract extracted = FormJsonSerializer::deserializeObject(
classNum(InvxRequestContract), InvxClient::readText(response));
InvxImport::createPendingInvoice(extracted.parmInvoice());
}
private static void replyWithReport(Email _sender, str _fileName, System.IO.Stream _report)
{
SysMailerMessageBuilder builder = new SysMailerMessageBuilder();
builder.setFrom(InvxParameters::find().FromEmail)
.addTo(_sender)
.setSubject(strFmt("Ihre Rechnung %1 konnte nicht verarbeitet werden", _fileName))
.setBody("Die angehängte Prüfung zeigt die Fehler. Bitte senden Sie eine korrigierte Rechnung.")
.addAttachment(_report, strFmt("Pruefbericht-%1.pdf", _fileName));
SysMailerFactory::sendNonInteractive(builder.getMessage());
}
Plain PDFs: a supplier without e-invoicing still sends a PDF with no XML. For those, use POST /v1/parse/json. It reads typed, scanned and photographed invoices with AI. The result is the same JSON plus a confidence object with scores from 0.0 to 1.0. Create the pending invoice when the score is high, and park the file for review when it is low. A file that is not an invoice comes back with error code 4008.
Create the pending vendor invoice from the JSON
The JSON from both endpoints has the same shape, so one method creates the Dynamics document. It finds the vendor by the VAT id from the invoice and creates a pending vendor invoice: a VendInvoiceInfoTable header and one VendInvoiceInfoLine per invoice line. A pending invoice is the right target. Accounts payable sees it in Pending vendor invoices, checks the category and the dimensions, and posts it. Nothing is posted without a look. Outside X++, the VendorInvoiceHeaders and VendorInvoiceLines data entities create the same records.
private static void createPendingInvoice(InvxInvoiceContract _invoice)
{
VendTable vendTable;
VendInvoiceInfoTable header;
VendInvoiceInfoLine line;
InvxParameters parameters = InvxParameters::find();
ListEnumerator lines;
InvxLineContract item;
int lineNum;
// The vendor comes from the VAT id the API read from the invoice
select firstonly vendTable
where vendTable.VATNum == _invoice.parmSeller().parmVatIdentifier();
if (!vendTable)
{
throw error(strFmt("No vendor with VAT id %1", _invoice.parmSeller().parmVatIdentifier()));
}
ttsbegin;
// A pending vendor invoice, not a posted one: accounts payable checks it and posts it
header.initValue();
header.ParmId = NumberSeq::newGetNum(PurchParameters::numRefParmId()).num();
header.TableRefId = NumberSeq::newGetNum(PurchParameters::numRefTableRefId()).num();
header.InvoiceAccount = vendTable.AccountNum;
header.OrderAccount = vendTable.AccountNum;
header.Num = _invoice.parmInvoiceNumber();
header.InvoiceDate = str2Date(_invoice.parmIssueDate(), 321);
header.FixedDueDate = str2Date(_invoice.parmDueDate(), 321);
header.CurrencyCode = _invoice.parmCurrency();
header.Description = strFmt("Imported from e-invoice, total %1", _invoice.parmTotals().parmGrandTotalAmount());
header.insert();
lines = _invoice.parmLines().getEnumerator();
while (lines.moveNext())
{
item = lines.current();
lineNum++;
line.clear();
line.initValue();
line.ParmId = header.ParmId;
line.TableRefId = header.TableRefId;
line.LineNum = lineNum;
line.Name = item.parmItem().parmName();
line.ProcurementCategory = parameters.ProcurementCategory;
line.ReceiveNow = item.parmQuantity();
line.PurchUnit = InvxImport::purchUnit(item.parmUnitCode());
line.PurchPrice = item.parmPriceDetails().parmNetPrice();
line.LineAmount = item.parmQuantity() * item.parmPriceDetails().parmNetPrice();
line.TaxGroup = vendTable.TaxGroup;
line.TaxItemGroup = InvxImport::taxItemGroup(item.parmVatInformation().parmRate());
line.insert();
}
ttscommit;
}
}
purchUnit is the reverse of unitCode from step 2. taxItemGroup maps the VAT rate from the invoice to your item sales tax group (19 to the full rate group, 7 to the reduced rate group, 0 to the exempt group in a German setup). The vendor's sales tax group comes from the vendor record. For invoices that match a purchase order, read the open PurchLine records for the purchase order in purchaseOrderReference and link the lines to them instead of using a procurement category. The grand total from the invoice goes into the description, so the clerk can compare it with the pending invoice.
XRechnung for German public buyers
German public buyers (federal, state and municipal) accept XRechnung only, not ZUGFeRD. XRechnung is pure XML without a PDF. The portals (ZRE, OZG-RE and the state portals) check every file against the KoSIT rules. The classes from this guide create it too. What changes is the endpoint and a few extra fields:
// In InvxInvoiceMapper::build, for public-sector customers (custTable.InvxLeitwegId is filled).
// eAddress is a two-line helper that fills an InvxEAddressContract.
InvxPartyContract seller = invoice.parmSeller();
InvxPartyContract buyer = invoice.parmBuyer();
InvxContactContract contact = new InvxContactContract();
contact.parmName('Buchhaltung');
contact.parmPhone(companyInfo.phone());
contact.parmEmail(companyInfo.email());
seller.parmContact(contact);
seller.parmElectronicAddress(InvxInvoiceMapper::eAddress(companyInfo.VATNum, '9930')); // 9930 = German VAT id
buyer.parmElectronicAddress(InvxInvoiceMapper::eAddress(_jour.VATNum, '9930'));
// Then post to v1/create/xrechnung instead of v1/create/zugferd.
// The response is XRechnung XML, not a PDF. Attach it the same way, as {InvoiceId}-xrechnung.xml.
The Leitweg-ID is the routing id the public buyer gives you when you register as a supplier. Without it, rule BR-DE-15 fails and the portal rejects the invoice. Store it in the InvxLeitwegId field on CustTable (a table extension in D365, a field in your layer in AX 2012) and map it into buyerReference. The mapper already does this. The seller contact (name, phone, email) and the electronic addresses of both parties are mandatory in XRechnung. Scheme 9930 stands for a German VAT id. If you deliver over Peppol, put the Peppol participant id and its scheme there instead.
The response is the XRechnung XML, already checked against the KoSIT rules. Deliver it the way the buyer asks for: an upload to ZRE or OZG-RE, Peppol, or email where the buyer allows it. For your own records, /v1/render/xrechnung/to/pdf turns the XML into a readable PDF. XRechnung is also accepted between businesses, so the /email route works for it as well. The XRechnung API toolkit goes deeper into the format.
AX 2012 and D365 Finance side by side
The JSON body, the endpoints, the mapping table and the import flow are identical on both versions. The tables and fields are the same too. What differs is the plumbing around the call:
| Topic | Dynamics 365 Finance | Dynamics AX 2012 |
|---|---|---|
| Where the code lives | Your own model with extensions: table extensions for the fields, event handlers for the trigger | Your layer (CUS or VAR): fields added to the tables, code added in the posting class |
| .NET interop | Property syntax (client.BaseAddress = ...), generic results via .Result | get_ and set_ calls, no generic types. Use HttpWebRequest, which is synchronous. RunOn = Server on the class |
| JSON | Contract classes and FormJsonSerializer | Newtonsoft JObject and JArray (reference the assembly from the AOS bin folder) or System.Text.StringBuilder. Read the extraction result with JObject::Parse and SelectToken |
| Attachments | DocumentManagement::attachFile with a stream | Write the file to the archive directory, then insert DocuValue and DocuRef |
| Trigger | Post-event handler on SalesInvoiceJournalPost, or a SysOperation batch service | Call at the end of SalesFormLetter_Invoice.run, or a RunBaseBatch job over CustInvoiceJour with the flag field |
| Email of the report | SysMailerMessageBuilder and SysMailerFactory | SysMailer with attachments().add() |
| API key | Encrypted field or Key Vault parameters | Encrypted field |
This is the AX 2012 version of the create call and the attachment. It writes the PDF to the document archive directory and links it to the posted invoice:
// AX 2012: class InvxClient, property RunOn = Server. .NET properties are get_ and set_ calls.
// HttpWebRequest is synchronous and has no generic types, which AX 2012 X++ cannot declare.
static server FilePath postJsonToFile(str _path, str _json, FilePath _targetFile)
{
InvxParameters parameters = InvxParameters::find();
System.Net.HttpWebRequest request;
System.Net.HttpWebResponse response;
System.Net.WebHeaderCollection headers;
System.Net.WebException webException;
System.IO.StreamWriter writer;
System.IO.StreamReader reader;
System.IO.Stream body;
System.IO.FileStream file;
;
new InteropPermission(InteropKind::ClrInterop).assert();
new FileIOPermission(_targetFile, 'w').assert();
request = System.Net.WebRequest::Create(parameters.BaseUrl + _path);
request.set_Method('POST');
request.set_ContentType('application/json');
headers = request.get_Headers();
headers.Add('Authorization', 'Bearer ' + parameters.editApiKey(false, ''));
writer = new System.IO.StreamWriter(request.GetRequestStream());
writer.Write(_json);
writer.Close();
try
{
response = request.GetResponse();
}
catch (Exception::CLRError)
{
// A 400 arrives as a WebException. Its response body is the errors array.
webException = CLRInterop::getLastException().get_InnerException();
reader = new System.IO.StreamReader(webException.get_Response().GetResponseStream());
throw error(strFmt("E-invoice generation failed: %1", reader.ReadToEnd()));
}
// Write the PDF straight into the document archive directory
body = response.GetResponseStream();
file = new System.IO.FileStream(_targetFile, System.IO.FileMode::Create);
body.CopyTo(file);
file.Close();
response.Close();
return _targetFile;
}
// Then link the file as a document on the posted invoice
static server void attachFile(CustInvoiceJour _jour, FilePath _file)
{
DocuRef docuRef;
DocuValue docuValue;
;
ttsbegin;
docuValue.FileName = _jour.InvoiceId + '-zugferd';
docuValue.FileType = 'pdf';
docuValue.OriginalFileName = _jour.InvoiceId + '-zugferd.pdf';
docuValue.Path = DocuParameters::find().ArchivePath;
docuValue.insert();
docuRef.RefCompanyId = _jour.dataAreaId;
docuRef.RefTableId = _jour.TableId;
docuRef.RefRecId = _jour.RecId;
docuRef.TypeId = 'File';
docuRef.Name = docuValue.OriginalFileName;
docuRef.ValueRecId = docuValue.RecId;
docuRef.insert();
ttscommit;
}
The target file is the archive directory from Document management parameters plus the file name. The document type File must exist in Document types with the archive directory as its location. The JSON for AX 2012 comes from the same mapping as step 2, built with Newtonsoft or a string builder. Quotes and line breaks in the text fields must be escaped. When you later move to Dynamics 365 Finance, the mapping, the fields and the import flow carry over. Only this class and the trigger change.
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 whole integration is the classes in this guide: call the API, map the posted invoice, create and attach or email the file, check and import what comes in. No add-on. No format logic in your model. Nothing to change when the formats change or when you move from AX 2012 to Dynamics 365 Finance. This guide is part of the integrations hub, which also covers Business Central, Dynamics NAV, Power Automate, SAP and other platforms.
Related guides:
- Dynamics NAV 2018 ZUGFeRD: the same integration in C/AL, for the small business product.
- Business Central e-invoicing: the same integration as an AL extension.
- Power Automate e-invoicing: the same endpoints from a flow, without X++.
- ZUGFeRD API toolkit: every ZUGFeRD endpoint and the format itself.