Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in Dynamics AX 2012 and Dynamics 365 Finance: E-Invoicing from X++ with One API Call

Many German companies run Dynamics AX 2012 or Dynamics 365 Finance and Operations. They must send and receive e-invoices now. This guide shows how to create ZUGFeRD invoices from posted customer invoices with a few X++ classes and one REST API. It also covers email delivery by the API, XRechnung for public buyers, and checking and importing supplier invoices as pending vendor invoices. The code runs on both versions.

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 CustTable and a flag field on CustInvoiceJour. 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.

CustomerFormatEndpointResult
German business (B2B)ZUGFeRD EN 16931/v1/create/zugferdHybrid PDF/A-3
German business, delivered by the APIZUGFeRD EN 16931/v1/create/zugferd/emailEmail with the PDF attached
German public buyer (B2G)XRechnung/v1/create/xrechnungXML with Leitweg-ID
French businessFactur-X EN 16931/v1/create/facturxHybrid PDF/A-3

Every endpoint takes the same JSON body. 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 fieldJSON pathNote
CustInvoiceJour.InvoiceIdinvoice.invoiceNumberBT-1
CustInvoiceJour.InvoiceDate, DueDateinvoice.issueDate, dueDateFormat as yyyy-MM-dd
CustInvoiceJour.CurrencyCodeinvoice.currencyISO code, as stored
CustInvoiceJour.PurchaseOrderinvoice.purchaseOrderReferenceThe customer's reference, BT-13
CompanyInfo (Name, VATNum, postal address)invoice.sellerThe legal entity. The seller VAT id is required
CustInvoiceJour.InvoicingName, VATNum, InvoicePostalAddressinvoice.buyerAddress via LogisticsPostalAddress; country as two-letter ISO code
BankAccountTable.BankIBANinvoice.paymentDetails.paymentAccountIdentifierThe house bank from the parameters
CustInvoiceTrans.Namelines[].item.namePer line
CustInvoiceTrans.Qty, LineAmountlines[].quantity, lines[].priceDetails.netPriceLineAmount / Qty is the net price after the line discount
CustInvoiceTrans.SalesUnitlines[].unitCodeUN/ECE code: H87 for pieces, HUR for hours
TaxTrans.TaxValue of the linelines[].vatInformation.rateThe rate that posting applied to the line
CustTable.InvxLeitwegIdinvoice.buyerReferenceXRechnung 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.

  1. Validate. Send the file to POST /v1/validate/zugferd/report or /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, the X-Invoice-Valid header holds the verdict. The validationLanguage form field (de here) sets the report language. /v1/validate/zugferd without /report returns the same findings as JSON instead.
  2. 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.
  3. Extract. When the header says true, post the file to POST /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. FormJsonSerializer reads 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:

TopicDynamics 365 FinanceDynamics AX 2012
Where the code livesYour own model with extensions: table extensions for the fields, event handlers for the triggerYour layer (CUS or VAR): fields added to the tables, code added in the posting class
.NET interopProperty syntax (client.BaseAddress = ...), generic results via .Resultget_ and set_ calls, no generic types. Use HttpWebRequest, which is synchronous. RunOn = Server on the class
JSONContract classes and FormJsonSerializerNewtonsoft JObject and JArray (reference the assembly from the AOS bin folder) or System.Text.StringBuilder. Read the extraction result with JObject::Parse and SelectToken
AttachmentsDocumentManagement::attachFile with a streamWrite the file to the archive directory, then insert DocuValue and DocuRef
TriggerPost-event handler on SalesInvoiceJournalPost, or a SysOperation batch serviceCall at the end of SalesFormLetter_Invoice.run, or a RunBaseBatch job over CustInvoiceJour with the flag field
Email of the reportSysMailerMessageBuilder and SysMailerFactorySysMailer with attachments().add()
API keyEncrypted field or Key Vault parametersEncrypted 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

PurposeEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdInvoice JSONHybrid PDF/A-3
Create and email ZUGFeRDPOST /v1/create/zugferd/emailInvoice JSON + email objectEmail sent from your domain
Create XRechnungPOST /v1/create/xrechnungInvoice JSON with Leitweg-IDXRechnung XML
Create and email XRechnungPOST /v1/create/xrechnung/emailInvoice JSON + email objectEmail sent from your domain
Create Factur-XPOST /v1/create/facturxInvoice JSONHybrid PDF/A-3
Validate with PDF reportPOST /v1/validate/zugferd/report, /xrechnung/reportPDF or XML filePDF report, X-Invoice-Valid header
Validate as JSONPOST /v1/validate/zugferd, /xrechnungPDF or XML fileFindings per rule
Extract structured dataPOST /v1/extract/jsonHybrid PDF or XML fileInvoice JSON
Parse plain PDFPOST /v1/parse/jsonAny PDF or imageInvoice JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsHybrid PDF or XML fileZIP of embedded files
Render XRechnungPOST /v1/render/xrechnung/to/pdfXRechnung XMLReadable PDF

Get started

The 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:

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded