Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in Odoo: E-Invoicing with a Small Custom Module and One API Call

Many German companies run Odoo. E-invoices are mandatory for them now. This guide shows how to create ZUGFeRD invoices with one small Python module that inherits account.move and calls one REST API. It works on Odoo 12 to 17, Community and Enterprise, on-premise and on Odoo.sh. It also covers email delivery by the API, XRechnung for public buyers, and checking and importing supplier invoices as vendor bills.

This guide adds ZUGFeRD and XRechnung to Odoo. You write one small custom module in Python. It inherits account.move, depends only on the account module and calls one REST API. It works on Odoo 12 to 17, Community and Enterprise, on-premise and on Odoo.sh. In the email section, the API sends the invoice to the customer itself. In the import section, supplier invoices are checked and turned into draft vendor bills. If you want the API calls in plain Python first, outside Odoo, read the Python guide. This guide does not repeat it.


Why Odoo needs ZUGFeRD now

German companies on Odoo must send and receive structured e-invoices. Every German business has had to accept e-invoices since January 2025. Businesses with more than EUR 800,000 turnover must issue them from January 2027. All other businesses follow in January 2028. The PDF from the QWeb invoice report is a plain PDF. The mandate asks for a hybrid file: a PDF for people, with XML inside for software. That file is ZUGFeRD. The Germany mandate page lists the dates and formats.

One module for every Odoo version. Odoo also ships accounting EDI modules, and their scope depends on the version and the edition. The module in this guide does not depend on them. It reads standard fields of account.move and posts JSON to one endpoint. The same code runs on Odoo 12 to 17 with a few field renames on the oldest versions.

Format updates do not touch the module. New versions of ZUGFeRD, XRechnung and the EN 16931 rules come out regularly. The API switches to each new version on its effective date. Your module sends the same JSON before and after. No module update, no migration, no new release.


What you need

  • Odoo 12 to 17, Community or Enterprise, with the account module installed. On-premise or Odoo.sh, because both take custom modules. For Odoo Online, see versions and hosting.
  • Python 3 and requests. Both are part of every Odoo install. No extra package.
  • A place for the module: a folder in the addons path, or the repository of your Odoo.sh project.
  • Complete company data in Odoo: name, VAT id, address and a bank account under Settings, Companies. The module reads them as the seller.
  • An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.

How the integration works

A customer invoice is posted in Odoo. The module reads the account.move record, the partner and the company, builds a JSON body and posts it to one endpoint. The API works out the totals and the VAT, builds the XML, embeds it in a PDF/A-3 file, checks it and returns the PDF. The module stores the PDF as an attachment on the invoice, or lets the API email it to the customer. One call, one validated file.

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

All endpoints take the same JSON body. The module is called invoicexml_einvoice. You create these files from the listings in this guide:

  • __manifest__.py and __init__.py: the module header, depends on account.
  • models/res_config_settings.py: the API key, the sender address and the default expense account as settings.
  • models/res_partner.py: the Leitweg-ID field for public buyers.
  • models/account_move.py: the API helpers, the mapping, create, attach, email, import and the vendor bill.
  • models/invoicexml_inbox.py: the mail hook that receives supplier invoices.
  • views/res_config_settings_views.xml and security/ir.model.access.csv: the settings page and the access line for the inbox model.

Everything else is standard Odoo: account.move, account.move.line, res.partner, res.company, account.tax, uom.uom, ir.attachment, ir.config_parameter, mail.mail and fetchmail.server.


Step 1: Store the API key and call the API

Store the key as a system parameter (ir.config_parameter) with the key invoicexml.api_key. A field on res.config.settings with config_parameter writes it there from the settings page. The sender address for the email route and the expense account for imported bills are stored the same way. The helper methods on account.move read the key and call the API. One method sends JSON, one uploads a file. Every other method in this guide calls these two.

# __manifest__.py
{
    "name": "InvoiceXML E-Invoicing",
    "version": "1.0",
    "depends": ["account"],
    "data": [
        "security/ir.model.access.csv",
        "views/res_config_settings_views.xml",
    ],
    "installable": True,
}

# __init__.py
from . import models

# models/__init__.py
from . import res_config_settings, res_partner, account_move, invoicexml_inbox

# models/res_config_settings.py
from odoo import fields, models


class ResConfigSettings(models.TransientModel):
    _inherit = "res.config.settings"

    invoicexml_api_key = fields.Char(
        string="InvoiceXML API key", config_parameter="invoicexml.api_key")
    invoicexml_from_email = fields.Char(
        string="Sender address", config_parameter="invoicexml.from_email")
    invoicexml_expense_account = fields.Char(
        string="Expense account for imported bills", config_parameter="invoicexml.expense_account")

# models/res_partner.py
from odoo import fields, models


class ResPartner(models.Model):
    _inherit = "res.partner"

    leitweg_id = fields.Char(string="Leitweg-ID", help="Routing id of a German public buyer")

# models/account_move.py (part 1: the API helpers)
import base64
import logging

import requests

from odoo import api, fields, models, _
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)
BASE_URL = "https://api.invoicexml.com"


class AccountMove(models.Model):
    _inherit = "account.move"

    invoicexml_done = fields.Boolean(string="E-invoice created", copy=False)

    def _invoicexml_param(self, key):
        return self.env["ir.config_parameter"].sudo().get_param("invoicexml." + key)

    def _invoicexml_headers(self):
        api_key = self._invoicexml_param("api_key")
        if not api_key:
            raise UserError(_("Enter the InvoiceXML API key in Settings, Invoicing."))
        return {"Authorization": "Bearer " + api_key}

    def _invoicexml_post_json(self, path, body):
        response = requests.post(
            BASE_URL + path, headers=self._invoicexml_headers(), json=body, timeout=120)
        if response.status_code != 200:
            # 400 returns JSON with an errors array: one entry per violated rule
            raise UserError(_("InvoiceXML: %s") % response.text)
        return response.content

    # The upload endpoints take a multipart form with one field named "file"
    def _invoicexml_post_file(self, path, filename, data, language=None):
        form = {"validationLanguage": language} if language else None
        response = requests.post(
            BASE_URL + path, headers=self._invoicexml_headers(),
            files={"file": (filename, data)}, data=form, timeout=120)
        if response.status_code != 200:
            raise UserError(_("InvoiceXML: %s") % response.text)
        return response

The settings view adds the three fields to the Invoicing settings page. This markup works on Odoo 12 to 16. Odoo 17 also accepts the newer app, block and setting tags; there, replace the xpath with //app[@name='account'] and wrap the fields in one setting element.

<?xml version="1.0" encoding="utf-8"?>
<!-- views/res_config_settings_views.xml -->
<odoo>
  <record id="res_config_settings_view_form_invoicexml" model="ir.ui.view">
    <field name="name">res.config.settings.view.form.inherit.invoicexml</field>
    <field name="model">res.config.settings</field>
    <field name="inherit_id" ref="account.res_config_settings_view_form"/>
    <field name="arch" type="xml">
      <xpath expr="//div[@data-key='account']" position="inside">
        <h2>InvoiceXML</h2>
        <div class="row mt16 o_settings_container">
          <div class="col-12 col-lg-6 o_setting_box">
            <div class="o_setting_right_pane">
              <label for="invoicexml_api_key"/>
              <field name="invoicexml_api_key" password="True"/>
              <label for="invoicexml_from_email"/>
              <field name="invoicexml_from_email"/>
              <label for="invoicexml_expense_account"/>
              <field name="invoicexml_expense_account"/>
            </div>
          </div>
        </div>
      </xpath>
    </field>
  </record>
</odoo>

The validation report endpoints answer with a PDF and put the verdict in the X-Invoice-Valid header, so _invoicexml_post_file returns the whole response. Every API error ends in a UserError. The user sees the message in Odoo, and the transaction is rolled back.


Step 2: Map Odoo fields to JSON

The API takes an EN 16931 invoice as JSON. The invoice data comes from account.move and its lines. The buyer is the partner. The seller is the company of the invoice. This is the mapping:

Odoo fieldJSON pathNote
account.move.nameinvoice.invoiceNumberBT-1. Set when the invoice is posted
invoice_date, invoice_date_dueinvoice.issueDate, dueDateDate fields, written as yyyy-MM-dd
currency_id.nameinvoice.currencyISO code
refinvoice.purchaseOrderReferenceThe customer's order number, BT-13
company_id.name, vat, partner_id addressinvoice.sellerStreet, city, zip and country_id.code of the company partner
partner_id.name, vat, street, city, zip, country_id.codeinvoice.buyerCountry as ISO code (DE, AT, FR)
partner_bank_id.acc_numberinvoice.paymentDetails.paymentAccountIdentifierThe IBAN of the company bank account on the invoice
invoice_line_ids.namelines[].item.namePer line. Section and note lines are skipped by display_type
quantity, price_unit, discountlines[].quantity, lines[].priceDetails.netPriceNet price after the line discount
product_uom_idlines[].unitCodeUN/ECE code: H87 for units, HUR for hours
tax_ids.amountlines[].vatInformation.rateThe percent tax on the line
partner_id.leitweg_idinvoice.buyerReferenceXRechnung only

The body has no totals and no VAT amounts. The API calculates both from the lines. When the numbers do not add up, the API answers with an error, not with a file. So a wrong discount or a rounding problem in Odoo never reaches the customer.

    # models/account_move.py (part 2: the mapping)
    def _invoicexml_unit_codes(self):
        # UN/ECE Recommendation 20 codes, keyed by the standard units of measure
        ref = self.env.ref
        return {
            ref("uom.product_uom_unit").id: "H87",
            ref("uom.product_uom_hour").id: "HUR",
            ref("uom.product_uom_kgm").id: "KGM",
            ref("uom.product_uom_meter").id: "MTR",
        }

    def _invoicexml_address(self, partner):
        return {
            "line1": partner.street,
            "city": partner.city,
            "postCode": partner.zip,
            "country": partner.country_id.code,
        }

    # Returns the "invoice" object; the callers wrap it as {invoice} or {invoice, email}
    def _invoicexml_build_invoice(self):
        self.ensure_one()
        company = self.company_id
        buyer = self.partner_id
        units = self._invoicexml_unit_codes()

        lines = []
        for line in self.invoice_line_ids:
            if line.display_type in ("line_section", "line_note"):
                continue
            tax = line.tax_ids.filtered(lambda t: t.amount_type == "percent")[:1]
            net_price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
            lines.append({
                "quantity": line.quantity,
                "unitCode": units.get(line.product_uom_id.id, "C62"),
                "item": {"name": line.name},
                "priceDetails": {"netPrice": round(net_price, 4)},
                "vatInformation": {"rate": tax.amount if tax else 0},
            })

        return {
            "invoiceNumber": self.name,
            "issueDate": fields.Date.to_string(self.invoice_date),
            "dueDate": fields.Date.to_string(self.invoice_date_due) if self.invoice_date_due else None,
            "currency": self.currency_id.name,
            "purchaseOrderReference": self.ref or None,
            "buyerReference": buyer.leitweg_id or None,   # XRechnung only, None otherwise
            "seller": {
                "name": company.name,
                "vatIdentifier": company.vat,
                "postalAddress": self._invoicexml_address(company.partner_id),
            },
            "buyer": {
                "name": buyer.name,
                "vatIdentifier": buyer.vat or None,
                "postalAddress": self._invoicexml_address(buyer),
            },
            "paymentDetails": {"paymentAccountIdentifier": self.partner_bank_id.acc_number or None},
            "lines": lines,
        }

The API ignores fields with None, so buyerReference stays out of B2B invoices. The unit codes are looked up by the XML ids of the standard units, so the mapping works in every language. Add your own units to the dict. Lines with a unit that is not in the dict get C62, the generic code.


Step 3: Create the ZUGFeRD invoice

One method builds the body, posts it and gets the PDF as bytes. It then hands the PDF to the attach method from step 4 and marks the invoice as done.

    # models/account_move.py (part 3: create)
    def action_invoicexml_create_zugferd(self):
        for move in self:
            body = {"invoice": move._invoicexml_build_invoice()}
            pdf = move._invoicexml_post_json("/v1/create/zugferd", body)
            move._invoicexml_attach(move.name + "-zugferd.pdf", pdf, "application/pdf")
            move.invoicexml_done = True
        return True

For French customers, post the same body to /v1/create/facturx. Factur-X is the French name of the same standard. Pick the endpoint by partner_id.country_id.code. To give users a button, add a server action on account.move under Settings, Technical, Server Actions. Its code is records.action_invoicexml_create_zugferd(). Add it to the Action menu of the invoice list.


Step 4: Attach the PDF to the invoice

Store the PDF as an ir.attachment linked to the invoice. Then post it in the chatter with message_post. The file shows in the attachment box of the invoice form and in the chatter. Users can download it or send it with the standard email dialog.

    # models/account_move.py (part 4: attach and trigger)
    def _invoicexml_attach(self, filename, data, mimetype):
        self.ensure_one()
        attachment = self.env["ir.attachment"].create({
            "name": filename,
            "res_model": "account.move",
            "res_id": self.id,
            "datas": base64.b64encode(data),
            "mimetype": mimetype,
        })
        self.message_post(
            body=_("E-invoice created: %s") % filename,
            attachment_ids=[attachment.id])
        return attachment

    # Trigger 1: right after posting
    def action_post(self):
        result = super().action_post()
        for move in self.filtered(lambda m: m.move_type == "out_invoice" and not m.invoicexml_done):
            move.action_invoicexml_create_zugferd()
        return result

    # Trigger 2: a scheduled action for posted invoices that have no e-invoice yet
    @api.model
    def _invoicexml_cron(self):
        moves = self.search([
            ("move_type", "=", "out_invoice"),
            ("state", "=", "posted"),
            ("invoicexml_done", "=", False),
        ], limit=50)
        for move in moves:
            try:
                with self.env.cr.savepoint():
                    move.action_invoicexml_create_zugferd()
            except Exception:
                _logger.exception("InvoiceXML: invoice %s failed", move.name)

When to run it. The action_post override creates the e-invoice when the user clicks Confirm. If the API returns an error, the posting is rolled back and the user sees the message. The invoice stays in draft until the data is fixed. If posting should never wait for the API, leave the override out and use the scheduled action. Create it under Settings, Technical, Scheduled Actions: model account.move, code model._invoicexml_cron(), every 5 minutes. A failed invoice is logged and skipped. The next run tries it again.

Sending from Odoo. If your users send invoices with the Send & Print button, change the email template. It should attach the ZUGFeRD PDF from the invoice instead of the report PDF. The hybrid PDF is also the readable invoice. The customer gets one file that opens in any PDF viewer and that their software can read.


Send the invoice by email from the API

Odoo does not have to send the invoice. One call lets the API create the invoice, validate it and email it to the customer. The PDF never passes through your module. In Germany this is a complete delivery: there is no central platform for B2B e-invoices, and an email with a ZUGFeRD PDF fulfils the mandate.

Verify your sending domain once in the dashboard. Add the domain and publish the three CNAME records the dashboard shows. After that, any address on the domain can be the sender. The customer sees your address, and a reply lands in your mailbox. The request body is the invoice JSON plus an email object. The recipient is partner_id.email. The sender comes from the settings:

    # models/account_move.py (part 5: email)
    def action_invoicexml_email_zugferd(self):
        for move in self:
            if not move.partner_id.email:
                raise UserError(_("Partner %s has no email address.") % move.partner_id.name)
            move._invoicexml_post_json("/v1/create/zugferd/email", {
                "invoice": move._invoicexml_build_invoice(),
                "email": {
                    "from": move._invoicexml_param("from_email"),   # an address on your verified sending domain
                    "to": move.partner_id.email,
                    "subject": "Rechnung %s" % move.name,
                    "message": "Guten Tag,\n\nim Anhang finden Sie unsere Rechnung %s als ZUGFeRD-PDF.\n\nFreundliche Grüße" % move.name,
                },
            })
            # 200 means: created, validated and handed to delivery
            move.invoicexml_done = True
            move.message_post(body=_("ZUGFeRD invoice emailed to %s") % move.partner_id.email)
        return True

Use this route when the API should send. Use the plain endpoint from step 3 when Odoo should store the file and send it itself. Call this method from action_post or from the scheduled action instead of action_invoicexml_create_zugferd. XRechnung and Factur-X have the same email route: /v1/create/xrechnung/email and /v1/create/facturx/email.


Import incoming supplier invoices

Accepting e-invoices has been mandatory in Germany since January 2025. Supplier invoices now arrive as ZUGFeRD PDFs and XRechnung XML files. The import has three steps. First validate. Then, for an invalid file, send the report back. For a valid file, extract the data. An incoming mail server in Odoo passes the attachments to the module.

  1. Validate. Upload the file to POST /v1/validate/zugferd/report (PDF) or /v1/validate/xrechnung/report (XML). The API runs the EN 16931 rules and the German rules. The body of the response is a PDF validation report. The header X-Invoice-Valid is true or false. The form field validationLanguage picks the report language; de gives a German report. For the findings as JSON instead, call /v1/validate/zugferd without /report.
  2. Send the report back. For an invalid invoice, email the PDF report to the sender and stop. Every violated rule is in the report, with the field and a message. The supplier fixes the invoice on their side.
  3. Extract. For a valid invoice, upload the file to POST /v1/extract/json. The API reads the XML and returns the invoice as JSON. The shape is the same as the create request.

The mail side is one small model with a message_new hook. Create an incoming mail server under Settings, Technical, Incoming Mail Servers. It reads a mailbox such as [email protected] by IMAP or POP. Set "Create a New Record" to this model. Odoo fetches the mailbox every 5 minutes and calls message_new for every email. If your Odoo has a catchall domain, a mail alias on the same model works too. The model needs one line in security/ir.model.access.csv for the internal user group.

# models/invoicexml_inbox.py: receives the email, hands every PDF or XML to the import
from odoo import api, fields, models, _
from odoo.tools import email_split


class InvoiceXmlInbox(models.Model):
    _name = "invoicexml.inbox"
    _description = "InvoiceXML inbox"
    _inherit = ["mail.thread"]

    name = fields.Char()
    sender = fields.Char()
    move_id = fields.Many2one("account.move", string="Vendor bill")

    @api.model
    def message_new(self, msg_dict, custom_values=None):
        sender = (email_split(msg_dict.get("email_from") or "") or [""])[0]
        values = dict(custom_values or {}, name=msg_dict.get("subject") or _("E-invoice"), sender=sender)
        record = super().message_new(msg_dict, custom_values=values)
        for attachment in msg_dict.get("attachments") or []:
            filename, content = attachment[0], attachment[1]
            if not filename.lower().endswith((".pdf", ".xml")):
                continue
            record.move_id = self.env["account.move"]._invoicexml_import(filename, content, sender)
        return record


    # models/account_move.py (part 6: validate, send the report back, extract)
    @api.model
    def _invoicexml_import(self, filename, content, sender_email):
        # 1. Validate. PDF files are ZUGFeRD, XML files are XRechnung.
        path = ("/v1/validate/xrechnung/report" if filename.lower().endswith(".xml")
                else "/v1/validate/zugferd/report")
        response = self._invoicexml_post_file(path, filename, content, language="de")
        valid = response.headers.get("X-Invoice-Valid") == "true"

        # 2. Invalid: send the PDF report to the sender and stop
        if not valid:
            report = self.env["ir.attachment"].create({
                "name": "Pruefbericht-%s.pdf" % filename,
                "datas": base64.b64encode(response.content),
                "mimetype": "application/pdf",
            })
            self.env["mail.mail"].create({
                "email_from": self._invoicexml_param("from_email"),
                "email_to": sender_email,
                "subject": "Rechnung %s: Prüfbericht" % filename,
                "body_html": "<p>Die Rechnung im Anhang hat die Prüfung nicht bestanden. "
                             "Der Bericht zeigt die Fehler. Bitte senden Sie eine korrigierte Rechnung.</p>",
                "attachment_ids": [(6, 0, [report.id])],
            }).send()
            return self.browse()

        # 3. Valid: read the data and create the vendor bill
        extracted = self._invoicexml_post_file("/v1/extract/json", filename, content).json()
        return self._invoicexml_create_vendor_bill(extracted["invoice"], extracted["invoice"].get("totals") or {})

Plain PDFs: a PDF without XML inside goes to POST /v1/parse/json instead. The API reads it with AI. Typed, scanned and photographed invoices all work. The response is the same JSON plus a confidence object. Its scores run from 0.0 to 1.0. Create the bill when the score is high, and leave the inbox record for review when it is low. A file that is not an invoice comes back with error code 4008.


Create the vendor bill from the JSON

The JSON from both endpoints has the same shape, so one method creates the Odoo document. It finds the vendor by the VAT id from the invoice and creates an account.move with move_type in_invoice. The bill stays in draft. Accounts payable sees it in the vendor bill list, checks the account and the analytic data, and confirms it. Nothing is posted without a look.

    # models/account_move.py (part 7: the draft vendor bill)
    @api.model
    def _invoicexml_create_vendor_bill(self, inv, totals):
        company = self.env.user.company_id

        # The vendor comes from the VAT id the API read from the invoice
        vat = (inv.get("seller") or {}).get("vatIdentifier")
        partner = self.env["res.partner"].search([("vat", "=", vat)], limit=1).commercial_partner_id
        if not partner:
            raise UserError(_("No vendor with VAT id %s") % vat)

        # The default expense account from the settings, by account code
        account = self.env["account.account"].search([
            ("code", "=", self._invoicexml_param("expense_account")),
            ("company_id", "=", company.id),
        ], limit=1)
        currency = self.env["res.currency"].search([("name", "=", inv.get("currency"))], limit=1)

        lines = []
        for line in inv.get("lines") or []:
            rate = (line.get("vatInformation") or {}).get("rate") or 0
            # The purchase tax with the same rate, for example 19% or 7%
            tax = self.env["account.tax"].search([
                ("type_tax_use", "=", "purchase"),
                ("amount_type", "=", "percent"),
                ("amount", "=", rate),
                ("company_id", "=", company.id),
            ], limit=1)
            lines.append((0, 0, {
                "name": line["item"]["name"],
                "quantity": line.get("quantity") or 1,
                "price_unit": line["priceDetails"]["netPrice"],
                "account_id": account.id,
                "tax_ids": [(6, 0, tax.ids)],
            }))

        # A draft, not a posted bill: accounts payable checks it and confirms it
        bill = self.create({
            "move_type": "in_invoice",
            "partner_id": partner.id,
            "ref": inv.get("invoiceNumber"),
            "invoice_date": inv.get("issueDate"),
            "invoice_date_due": inv.get("dueDate"),
            "currency_id": currency.id or company.currency_id.id,
            "invoice_line_ids": lines,
        })
        bill.message_post(body=_("Imported from e-invoice. Total on the invoice: %s %s")
                          % (totals.get("grandTotalAmount"), inv.get("currency")))
        return bill

The total from the invoice goes into the chatter, so the clerk can compare it with the draft. For invoices that match a purchase order, look up the open purchase.order of the vendor by purchaseOrderReference. Then link the bill lines with purchase_line_id instead of plain lines. If the vendor is unknown, create the partner from the seller object first. Or raise, as the code does, and let the inbox record show the error.


XRechnung for German public buyers

A German public buyer (federal, state or municipal) needs XRechnung, not ZUGFeRD. XRechnung is XML only. There is no PDF. The portals ZRE and OZG-RE check every file against the KoSIT rules. Your module creates it with one more endpoint and a few more fields:

    # In _invoicexml_build_invoice, before the return, for partners with a Leitweg-ID:
    if buyer.leitweg_id:
        invoice["seller"]["contact"] = {
            "name": company.name, "phone": company.phone, "email": company.email}
        invoice["seller"]["electronicAddress"] = {"identifier": company.vat, "schemeId": "9930"}
        invoice["buyer"]["electronicAddress"] = {"identifier": buyer.vat, "schemeId": "9930"}

    # One method that picks the format by the partner
    def action_invoicexml_create(self):
        for move in self:
            body = {"invoice": move._invoicexml_build_invoice()}
            if move.partner_id.leitweg_id:
                xml = move._invoicexml_post_json("/v1/create/xrechnung", body)
                move._invoicexml_attach(move.name + "-xrechnung.xml", xml, "application/xml")
            else:
                pdf = move._invoicexml_post_json("/v1/create/zugferd", body)
                move._invoicexml_attach(move.name + "-zugferd.pdf", pdf, "application/pdf")
            move.invoicexml_done = True
        return True

The Leitweg-ID is a routing id. The public buyer assigns it when you register as a supplier. Rule BR-DE-15 rejects an invoice without it. Keep it in the leitweg_id field on res.partner from step 1. The mapping copies it into buyerReference. Without a module, a Studio field x_leitweg_id on the partner holds it just as well. The seller contact (name, phone, email) and the electronic addresses are mandatory under the German rules. Scheme 9930 stands for a German VAT id. A buyer without a VAT id gets the Leitweg-ID with scheme 0204. For Peppol delivery, use the Peppol participant id and its scheme.

The API answers with the XML. It is already checked against the KoSIT rules. Send it the way the buyer asks for: upload to ZRE or OZG-RE, Peppol, or email if the buyer accepts it. /v1/render/xrechnung/to/pdf turns the XML into a readable PDF for your own files. XRechnung is also allowed for B2B invoices, so the /email route works for it too. The XRechnung API toolkit explains the format in depth.


Odoo versions and hosting

Odoo 13 to 17. The code runs as written on 14 to 17. On Odoo 13, the invoice type field is type instead of move_type. Change every place where it appears. Everything else, including action_post, invoice_date, display_type and the vendor bill fields, is the same.

Odoo 12. The invoice model is account.invoice, so inherit that model instead of account.move. Rename these fields: type for move_type, number for name, date_invoice and date_due for the dates, reference for ref, uom_id for product_uom_id and invoice_line_tax_ids for tax_ids. Override action_invoice_open instead of action_post. The attachment needs datas_fname next to name. The API calls, the JSON body and the inbox model do not change.

On-premise. Copy the module folder into the addons path, restart Odoo, turn on developer mode, update the apps list and install InvoiceXML E-Invoicing. Or start Odoo once with -i invoicexml_einvoice. The server needs HTTPS to api.invoicexml.com.

Odoo.sh. Commit the module folder to the branch of your project. Odoo.sh builds the branch and installs the module on the next update. requests is in Odoo's own requirements.txt, so no extra dependency is needed. Set the API key once on the production branch and once on each staging branch you test with.

Odoo Online. Odoo Online cannot install custom modules. On Odoo Online, run the same calls from an external script against the XML-RPC/JSON-RPC API. The script reads posted invoices, builds the same JSON body and writes the PDF back as an attachment. The mapping from step 2 moves into the script. It reads the fields from search_read results instead of records. Use an API key of an Odoo user as the password. Users create one under Preferences, Account Security, on Odoo 14 and later:

import base64, xmlrpc.client, requests

URL, DB, USER, KEY = "https://firma.odoo.com", "firma", "[email protected]", "odoo-api-key"
uid = xmlrpc.client.ServerProxy(URL + "/xmlrpc/2/common").authenticate(DB, USER, KEY, {})
odoo = xmlrpc.client.ServerProxy(URL + "/xmlrpc/2/object")
HEADERS = {"Authorization": "Bearer " + INVOICEXML_API_KEY}

def call(model, method, *args, **kwargs):
    return odoo.execute_kw(DB, uid, KEY, model, method, list(args), kwargs)

domain = [["move_type", "=", "out_invoice"], ["state", "=", "posted"], ["invoicexml_done", "=", False]]
for move in call("account.move", "search_read", domain, fields=["name", "invoice_date", "partner_id"]):
    body = {"invoice": build_invoice(move)}   # the mapping from step 2, fed by search_read
    pdf = requests.post("https://api.invoicexml.com/v1/create/zugferd", headers=HEADERS, json=body, timeout=120).content
    call("ir.attachment", "create", {"name": move["name"] + "-zugferd.pdf", "res_model": "account.move",
         "res_id": move["id"], "datas": base64.b64encode(pdf).decode(), "mimetype": "application/pdf"})

On Odoo Online, invoicexml_done is a Studio boolean x_invoicexml_done on the invoice, and the script writes it with call("account.move", "write", [move["id"]], {"x_invoicexml_done": True}) after the attachment. Run the script every few minutes as a cron job or a scheduled task on any server.


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 one module with five Python files and one view: store the key, map, create and attach or email, check and import what comes in. No format logic in Odoo. Nothing to change in the module when the formats change. This guide is part of the integrations hub, which also covers SAP, Business Central, Dynamics NAV 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