Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in WooCommerce: E-Invoicing with a Small Plugin and One API Call

Many German B2B shops sell with WooCommerce. E-invoices are now mandatory for them. This guide builds a small WordPress plugin that turns a paid order into a ZUGFeRD invoice with one REST API call. The call runs in the background with Action Scheduler, and the plugin is ready for HPOS. The PDF goes into the order email and the customer account. The guide also covers email delivery by the API, XRechnung for public buyers, and checking and storing supplier invoices.

This guide adds ZUGFeRD and XRechnung to WooCommerce. You build one small WordPress plugin called invoicexml-einvoice. It sends each paid order to one REST API and gets back a validated ZUGFeRD PDF. The call runs in the background with Action Scheduler, and the plugin works with HPOS. The PDF goes into the Completed order email and into My Account. The email section shows how the API delivers the invoice itself. The import section shows how to check supplier invoices and keep their data for the bookkeeping. The code is PHP 8. For the API calls outside WordPress, read the PHP guide. Agencies with Shopware clients find the same setup in the Shopware 6 guide.


Why WooCommerce needs ZUGFeRD now

German B2B shops on WooCommerce must send and receive structured e-invoices. Since January 2025, every German business must be able to receive e-invoices. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must issue them. From January 2028, every business must. WooCommerce core sends the order details as an HTML email. It creates no invoice file. The mandate asks for a structured file: a PDF for people with XML inside for software. That file is ZUGFeRD. All dates and formats are on the Germany mandate page.

Agencies maintain many shops. The plugin in this guide has five small files and no dependencies besides WooCommerce. Copy it into each client shop, enter the API key and the seller data, and the shop issues e-invoices. The order data comes from the WooCommerce CRUD classes, which are the same in every shop.

The plugin stays the same when the formats change. ZUGFeRD, XRechnung and the EN 16931 rules get new versions over time. The API uses each new version from its effective date. The plugin sends the same JSON before and after. No plugin update in the client shops.


What you need

  • WordPress 6.5 or later and PHP 8.0 or later. The plugin header uses Requires Plugins, which WordPress supports from 6.5.
  • A current WooCommerce. HPOS can be on or off. Action Scheduler is part of WooCommerce, so there is nothing extra to install.
  • The store address under WooCommerce, Settings, General. The plugin uses it as the seller address.
  • A VAT id field at checkout. Most B2B shops already collect it. The plugin reads it from the order meta. You set the meta key in the plugin settings.
  • A running WP-Cron or a server cron job, so Action Scheduler processes the queue. HTTPS from the server to api.invoicexml.com.
  • An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.

How the integration works

An order reaches the status Processing or Completed. The plugin puts a job into Action Scheduler. The job loads the order, builds a JSON body and posts it to one endpoint. The API works out the totals and the VAT. It builds the XML, places it inside a PDF/A-3 file and checks the result. Then it sends back the PDF. The plugin stores the PDF and attaches it to the customer email. Or the API emails the invoice itself. One request returns one checked 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

The four endpoints share one JSON body. The plugin folder is wp-content/plugins/invoicexml-einvoice. The listings below give you these files:

  • invoicexml-einvoice.php: the plugin header, the HPOS declaration and the settings section.
  • includes/class-api.php: the API key and the two HTTP helpers (JSON and file upload).
  • includes/class-mapper.php: turns a WC_Order into the invoice JSON.
  • includes/class-invoices.php: the background job, the protected file storage, the email attachment, the My Account button, the email route and the Leitweg-ID field.
  • includes/class-inbound.php: the upload page for supplier invoices and the private post type for their data.

Everything else is standard WooCommerce and WordPress: WC_Order, WC_Order_Item_Product, WC_Order_Item_Shipping, WC_Tax, WC_Countries, FeaturesUtil, Action Scheduler, wp_remote_post(), wp_upload_dir(), wp_mail() and register_post_type().


Step 1: Create the plugin and store the API key

The main file holds the plugin header and the HPOS declaration. It also adds an InvoiceXML section to the Advanced tab of the WooCommerce settings. WooCommerce saves each field as a WordPress option with the field id as its name. The API key field has the type password, so the page does not show it.

<?php
/**
 * Plugin Name:       InvoiceXML E-Invoicing
 * Description:       ZUGFeRD and XRechnung invoices for WooCommerce orders.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      8.0
 * Requires Plugins:  woocommerce
 * Text Domain:       invoicexml-einvoice
 */

defined( 'ABSPATH' ) || exit;
// HPOS: the plugin reads and writes orders only through the WooCommerce CRUD classes
add_action( 'before_woocommerce_init', function () {
    if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
    }
} );

foreach ( [ 'api', 'mapper', 'invoices', 'inbound' ] as $part ) {
    require_once __DIR__ . "/includes/class-$part.php";
}
InvoiceXML_Invoices::init();
InvoiceXML_Inbound::init();
// WooCommerce, Settings, Advanced, InvoiceXML
add_filter( 'woocommerce_get_sections_advanced', function ( $sections ) {
    $sections['invoicexml'] = 'InvoiceXML';
    return $sections;
} );

add_filter( 'woocommerce_get_settings_advanced', function ( $settings, $section ) {
    if ( 'invoicexml' !== $section ) {
        return $settings;
    }
    $fields = [
        'api_key'       => [ 'API key (or the constant INVOICEXML_API_KEY)', 'password' ],
        'seller_name'   => [ 'Company name', 'text' ],
        'seller_vat'    => [ 'Your VAT id', 'text' ],
        'iban'          => [ 'IBAN', 'text' ],
        'contact_name'  => [ 'Contact name (XRechnung)', 'text' ],
        'contact_phone' => [ 'Contact phone (XRechnung)', 'text' ],
        'contact_email' => [ 'Contact email (XRechnung)', 'email' ],
        'vat_meta_key'  => [ 'Order meta key of the customer VAT id', 'text' ],
        'due_days'      => [ 'Payment term in days', 'number' ],
        'send_by_api'   => [ 'Let InvoiceXML email the invoice', 'checkbox' ],
        'from_email'    => [ 'Sender address on your verified domain', 'email' ],
    ];
    $section_settings = [ [ 'title' => 'InvoiceXML', 'type' => 'title', 'id' => 'invoicexml' ] ];
    foreach ( $fields as $key => [ $title, $type ] ) {
        $section_settings[] = [ 'title' => $title, 'type' => $type, 'id' => 'invoicexml_' . $key ];
    }
    $section_settings[] = [ 'type' => 'sectionend', 'id' => 'invoicexml' ];
    return $section_settings;
}, 10, 2 );

Prefer a constant for the key. An option is stored in the wp_options table, so it ends up in every database dump and staging copy. Add define( 'INVOICEXML_API_KEY', '...' ); to wp-config.php instead and leave the field empty. The helper below uses the constant first.

The API helper has two public methods. post_json() sends the invoice JSON. post_file() uploads a file to the validation and extraction endpoints. These endpoints take multipart/form-data with one field named file. wp_remote_post() has no helper for multipart bodies. So the method builds the body by hand, with a random boundary and CRLF line endings.

<?php
// includes/class-api.php
defined( 'ABSPATH' ) || exit;

class InvoiceXML_Api {
    const BASE = 'https://api.invoicexml.com';
    public static function option( string $key ): string {
        return (string) get_option( 'invoicexml_' . $key, '' );
    }
    private static function key(): string {
        $key = defined( 'INVOICEXML_API_KEY' ) ? INVOICEXML_API_KEY : self::option( 'api_key' );
        if ( '' === $key ) {
            throw new RuntimeException( 'InvoiceXML: the API key is missing.' );
        }
        return $key;
    }
    public static function post_json( string $path, array $body ): array {
        return self::send( $path, wp_json_encode( $body ), 'application/json' );
    }

    // One file field named "file", plus optional text fields such as validationLanguage
    public static function post_file( string $path, string $filename, string $content, array $fields = [] ): array {
        $boundary = 'invoicexml' . wp_generate_password( 24, false );
        $body     = '';
        foreach ( $fields as $name => $value ) {
            $body .= "--$boundary\r\n"
                . "Content-Disposition: form-data; name=\"$name\"\r\n\r\n"
                . $value . "\r\n";
        }
        $mime  = str_ends_with( strtolower( $filename ), '.xml' ) ? 'application/xml' : 'application/pdf';
        $body .= "--$boundary\r\n"
            . 'Content-Disposition: form-data; name="file"; filename="' . sanitize_file_name( $filename ) . "\"\r\n"
            . "Content-Type: $mime\r\n\r\n"
            . $content . "\r\n"
            . "--$boundary--\r\n";
        return self::send( $path, $body, 'multipart/form-data; boundary=' . $boundary );
    }

    private static function send( string $path, string $body, string $content_type ): array {
        $response = wp_remote_post( self::BASE . $path, [
            'timeout' => 60,   // the WordPress default is 5 seconds
            'headers' => [ 'Authorization' => 'Bearer ' . self::key(), 'Content-Type' => $content_type ],
            'body'    => $body,
        ] );
        if ( is_wp_error( $response ) ) {
            throw new RuntimeException( 'InvoiceXML: ' . $response->get_error_message() );
        }
        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
            // 400 returns JSON with an errors array: one entry per violated rule, with the JSON path
            throw new RuntimeException( 'InvoiceXML: ' . wp_remote_retrieve_body( $response ) );
        }
        return $response;
    }
}

Both helpers return the full response array. The report endpoints need it, because they send the verdict in a header. Every error becomes an exception. In a background job, Action Scheduler marks the job as failed and keeps the message in its log.


Step 2: Map WooCommerce order fields to JSON

The API expects an EN 16931 invoice in JSON. The order and its items provide the invoice data. The buyer is the billing address of the order. The seller is the store address plus the plugin settings. This is the mapping:

WooCommerce sourceJSON pathNote
get_order_number()invoice.invoiceNumberBT-1. The order number is unique in the shop
get_date_created()invoice.issueDate, dueDateyyyy-MM-dd in the site time zone. Due date from the payment term setting
get_currency()invoice.currencyISO code, for example EUR
WC()->countries->get_base_address(), get_base_city(), get_base_postcode(), get_base_country()invoice.seller.postalAddressThe store address from the general settings
get_billing_company(), get_billing_address_1(), get_billing_city(), get_billing_postcode(), get_billing_country()invoice.buyerFull name when the company is empty
get_meta( $vat_meta_key )invoice.buyer.vatIdentifierThe meta key depends on the VAT field of the shop
get_items(): get_name(), get_quantity()lines[].item.name, lines[].quantityOne line per WC_Order_Item_Product
get_total() / get_quantity()lines[].priceDetails.netPriceNet unit price after coupons
get_taxes() with WC_Tax::get_rate_percent_value()lines[].vatInformation.rateThe percent of the tax rate on the item
get_items( 'shipping' )one more entry in lines[]Shipping as its own invoice line

The JSON has no totals and no VAT amounts. The API works them out from the lines. If the numbers do not add up, the API returns an error and no file. A wrong coupon or a rounding problem never reaches the customer.

<?php
// includes/class-mapper.php
defined( 'ABSPATH' ) || exit;

class InvoiceXML_Mapper {
    public static function build( WC_Order $order ): array {
        $lines = [];
        foreach ( $order->get_items() as $item ) {               // WC_Order_Item_Product
            $lines[] = self::line( $item, $item->get_name(), (float) $item->get_quantity(), 'H87' );
        }
        foreach ( $order->get_items( 'shipping' ) as $item ) {   // WC_Order_Item_Shipping
            if ( (float) $item->get_total() > 0 ) {
                $lines[] = self::line( $item, 'Versand: ' . $item->get_name(), 1, 'C62' );
            }
        }

        $countries = WC()->countries;
        $created   = $order->get_date_created()->getTimestamp();
        $due_days  = (int) InvoiceXML_Api::option( 'due_days' );
        $vat_key   = InvoiceXML_Api::option( 'vat_meta_key' );

        $invoice = [
            'invoiceNumber'  => $order->get_order_number(),
            'issueDate'      => wp_date( 'Y-m-d', $created ),
            'dueDate'        => $due_days > 0 ? wp_date( 'Y-m-d', $created + $due_days * DAY_IN_SECONDS ) : null,
            'currency'       => $order->get_currency(),
            'buyerReference' => $order->get_meta( '_invoicexml_leitweg_id' ) ?: null,   // XRechnung only
            'seller'         => [
                'name'          => InvoiceXML_Api::option( 'seller_name' ),
                'vatIdentifier' => InvoiceXML_Api::option( 'seller_vat' ),
                'postalAddress' => [
                    'line1'    => $countries->get_base_address(),
                    'city'     => $countries->get_base_city(),
                    'postCode' => $countries->get_base_postcode(),
                    'country'  => $countries->get_base_country(),
                ],
            ],
            'buyer'          => [
                'name'          => $order->get_billing_company() ?: $order->get_formatted_billing_full_name(),
                'vatIdentifier' => $vat_key ? ( $order->get_meta( $vat_key ) ?: null ) : null,
                'postalAddress' => [
                    'line1'    => $order->get_billing_address_1(),
                    'city'     => $order->get_billing_city(),
                    'postCode' => $order->get_billing_postcode(),
                    'country'  => $order->get_billing_country(),
                ],
            ],
            'paymentDetails' => [ 'paymentAccountIdentifier' => InvoiceXML_Api::option( 'iban' ) ?: null ],
            'lines'          => $lines,
        ];
        return $invoice;
    }

    // get_total() is the line total after coupons and without tax, also in shops with gross prices
    private static function line( WC_Order_Item $item, string $name, float $quantity, string $unit ): array {
        $taxes   = $item->get_taxes();
        $rate_id = array_key_first( array_filter( $taxes['total'] ?? [], fn( $amount ) => 0.0 !== (float) $amount ) );
        return [
            'quantity'       => $quantity,
            'unitCode'       => $unit,
            'item'           => [ 'name' => $name ],
            'priceDetails'   => [ 'netPrice' => round( (float) $item->get_total() / $quantity, 4 ) ],
            'vatInformation' => [ 'rate' => $rate_id ? WC_Tax::get_rate_percent_value( $rate_id ) : 0 ],
        ];
    }
}

Gross and net prices. Many B2B shops enter prices without tax. Some enter them with tax, so get_prices_include_tax() returns true. In both cases WooCommerce stores the item total get_total() without tax and after coupons. The net unit price is that total divided by the quantity. With gross prices it often has more than two decimals, so the code keeps four. Tax rates: get_taxes() returns the tax amounts per tax rate id. The code takes the rate id with an amount and reads its percent with WC_Tax::get_rate_percent_value(). A line without tax gets the rate 0. The VAT id meta key depends on the field the shop uses at checkout. Open an order with a VAT id in the admin and look at its custom fields to find the key. Fees from get_items( 'fee' ) follow the same pattern as shipping.


Step 3: Create the ZUGFeRD invoice in the background

The status hooks woocommerce_order_status_processing and woocommerce_order_status_completed fire when an order is paid or finished. The plugin does not call the API inside these hooks. It queues an async action with as_enqueue_async_action(). Action Scheduler runs it a few seconds later in its own request. The last argument true makes the action unique, so the two hooks never queue the same order twice.

<?php
// includes/class-invoices.php (part 1: hooks, queue, job, create)
defined( 'ABSPATH' ) || exit;

class InvoiceXML_Invoices {
    public static function init(): void {
        add_action( 'woocommerce_order_status_processing', [ __CLASS__, 'queue' ] );
        add_action( 'woocommerce_order_status_completed', [ __CLASS__, 'queue' ] );
        add_action( 'invoicexml_create_einvoice', [ __CLASS__, 'run' ] );
        add_filter( 'woocommerce_email_attachments', [ __CLASS__, 'attach' ], 10, 3 );
        add_filter( 'woocommerce_my_account_my_orders_actions', [ __CLASS__, 'account_button' ], 10, 2 );
        add_action( 'admin_post_invoicexml_download', [ __CLASS__, 'download' ] );
    }

    public static function queue( int $order_id ): void {
        as_enqueue_async_action( 'invoicexml_create_einvoice', [ $order_id ], 'invoicexml', true );
    }

    // Runs in Action Scheduler. An exception marks the action as failed.
    public static function run( int $order_id ): void {
        $order = wc_get_order( $order_id );
        if ( ! $order || $order->get_meta( '_invoicexml_file' ) || $order->get_meta( '_invoicexml_sent' ) ) {
            return;   // order deleted, or the e-invoice exists already
        }
        self::copy_leitweg_id( $order );   // XRechnung section
        if ( 'yes' === InvoiceXML_Api::option( 'send_by_api' ) ) {
            self::email( $order );         // email section
            return;
        }
        self::create( $order );
    }

    public static function create( WC_Order $order ): string {
        $xrechnung = (bool) $order->get_meta( '_invoicexml_leitweg_id' );
        $response  = InvoiceXML_Api::post_json(
            $xrechnung ? '/v1/create/xrechnung' : '/v1/create/zugferd',
            [ 'invoice' => InvoiceXML_Mapper::build( $order ) ]
        );
        $file = self::store( $order, wp_remote_retrieve_body( $response ), $xrechnung ? 'xml' : 'pdf' );
        $order->add_order_note( 'E-invoice created: ' . basename( $file ) );
        return $file;
    }

The order note shows the result in the order screen. A failed job shows under WooCommerce, Status, Scheduled Actions, in the group invoicexml, with the API message in its log. Fix the data, then queue the order again. French customers get Factur-X: the same body goes to /v1/create/facturx. Pick the endpoint by get_billing_country().


Step 4: Store the PDF and attach it to the order email

Invoices contain personal data, so they must not be public files. The plugin writes them to wp-content/uploads/invoicexml. The folder gets a .htaccess file that denies all requests on Apache. Each file name has a random part, so nobody can guess it. The file name goes into the order meta with update_meta_data() and save(). That works with HPOS and with the posts table.

    // includes/class-invoices.php (part 2: store, attach, My Account, download)
    private static function dir(): string {
        $dir = wp_upload_dir()['basedir'] . '/invoicexml';
        if ( ! is_dir( $dir ) ) {
            wp_mkdir_p( $dir );
            file_put_contents( $dir . '/.htaccess', "Require all denied\n" );
            file_put_contents( $dir . '/index.php', "<?php\n" );
        }
        return $dir;
    }

    private static function store( WC_Order $order, string $content, string $ext ): string {
        $name = sprintf( 'rechnung-%s-%s.%s', sanitize_file_name( $order->get_order_number() ), wp_generate_password( 16, false ), $ext );
        file_put_contents( self::dir() . '/' . $name, $content );
        $order->update_meta_data( '_invoicexml_file', $name );
        $order->save();
        return self::dir() . '/' . $name;
    }

    private static function file( WC_Order $order ): ?string {
        $name = $order->get_meta( '_invoicexml_file' );
        return $name && is_file( self::dir() . '/' . $name ) ? self::dir() . '/' . $name : null;
    }

    // Adds the e-invoice to the WooCommerce email "Completed order"
    public static function attach( $attachments, $email_id, $order ) {
        if ( 'customer_completed_order' !== $email_id || ! $order instanceof WC_Order
            || 'yes' === InvoiceXML_Api::option( 'send_by_api' ) ) {
            return $attachments;
        }
        try {
            $attachments[] = self::file( $order ) ?? self::create( $order );   // the job may not have run yet
        } catch ( Throwable $e ) {
            $order->add_order_note( 'E-invoice not attached: ' . $e->getMessage() );
        }
        return $attachments;
    }

    // An "E-Rechnung" button next to the order in My Account, Orders
    public static function account_button( $actions, $order ) {
        if ( self::file( $order ) ) {
            $url = admin_url( 'admin-post.php?action=invoicexml_download&order_id=' . $order->get_id() );
            $actions['invoicexml'] = [ 'url' => wp_nonce_url( $url, 'invoicexml_download_' . $order->get_id() ), 'name' => 'E-Rechnung' ];
        }
        return $actions;
    }

    // Serves the file only to the customer of the order and to shop managers
    public static function download(): void {
        $order_id = absint( $_GET['order_id'] ?? 0 );
        check_admin_referer( 'invoicexml_download_' . $order_id );
        $order   = wc_get_order( $order_id );
        $allowed = $order && ( current_user_can( 'manage_woocommerce' ) || $order->get_customer_id() === get_current_user_id() );
        $file    = $allowed ? self::file( $order ) : null;
        if ( ! $file ) {
            wp_die( 'Not found.', 404 );
        }
        nocache_headers();
        header( 'Content-Type: ' . ( str_ends_with( $file, '.xml' ) ? 'application/xml' : 'application/pdf' ) );
        header( 'Content-Disposition: attachment; filename="' . basename( $file ) . '"' );
        readfile( $file );
        exit;
    }

When the email goes out. WooCommerce sends the email Completed order (id customer_completed_order) right after the status change. The background job may not have run at that moment. So the filter creates the file itself when it is missing. The job then finds the file and stops. If the API returns an error, the email still goes out without the attachment, and the order note shows the reason.

The download button. The woocommerce_my_account_my_orders_actions filter adds a button to the order list in My Account. The link goes to admin-post.php, which WooCommerce leaves open for customers. The handler checks the nonce, then the owner of the order. Guest orders have no account, so their customers get the file by email only.


Send the invoice by email from the API

The shop does not have to send the invoice. With one request, the API builds the invoice, checks it and emails it to the buyer. The PDF never touches the WordPress server. In Germany this is a complete delivery. There is no central platform for B2B e-invoices, and an email with a ZUGFeRD PDF meets the mandate.

First verify your sending domain in the dashboard. Enter the domain there, then publish the three CNAME records that appear. Then any address on that domain can be the sender. The customer sees your shop address, and replies reach your mailbox. The body is the invoice JSON plus an email object. The recipient is get_billing_email(). Turn on the setting "Let InvoiceXML email the invoice", and the job from step 3 calls this method:

    // includes/class-invoices.php (part 3: the API sends the email)
    public static function email( WC_Order $order ): void {
        $number = $order->get_order_number();
        $path   = $order->get_meta( '_invoicexml_leitweg_id' ) ? '/v1/create/xrechnung/email' : '/v1/create/zugferd/email';
        InvoiceXML_Api::post_json( $path, [
            'invoice' => InvoiceXML_Mapper::build( $order ),
            'email'   => [
                'from'     => InvoiceXML_Api::option( 'from_email' ),   // an address on your verified domain
                'fromName' => get_bloginfo( 'name' ),
                'to'       => $order->get_billing_email(),
                'subject'  => "Rechnung $number",
                'message'  => "Guten Tag,\n\nanbei erhalten Sie die Rechnung zu Ihrer Bestellung $number.\n\nMit freundlichen Grüßen",
            ],
        ] );
        // 200: created, validated and handed to delivery. The response contains no file.
        $order->update_meta_data( '_invoicexml_sent', current_time( 'mysql' ) );
        $order->save();
        $order->add_order_note( 'E-invoice emailed to ' . $order->get_billing_email() );
    }

Use this route when the API should send. Use the plain endpoint when WooCommerce should store the file and send it itself. With the setting on, the attachment filter from step 4 does nothing. So the customer gets one invoice, not two. The optional fields cc, bcc and replyTo work too, for example a copy to your bookkeeping address.


Import incoming supplier invoices

Since January 2025, German businesses must be able to receive e-invoices. A shop gets them from its suppliers, its hosting company and its payment provider. The plugin adds an upload page under WooCommerce for these files. The import has three steps:

  1. Validate. Send the file to POST /v1/validate/zugferd/report (PDF) or /v1/validate/xrechnung/report (XML). The API checks the EN 16931 rules and the German rules. The response body is a PDF report. Its header X-Invoice-Valid says true or false. The form field validationLanguage=de gives a German report. The endpoint /v1/validate/zugferd without /report returns the findings as JSON.
  2. Send the report back. If the invoice is invalid, email the PDF report to the supplier and stop. The report lists every failed rule with the field and a message. The supplier corrects the invoice and sends it again.
  3. Extract. If the invoice is valid, upload the file to POST /v1/extract/json. The API returns the invoice as JSON, in the same shape as the create request, plus a totals object.

The page uses add_submenu_page() under the woocommerce menu and needs the capability manage_woocommerce. The form posts to admin-post.php with a nonce. The handler checks the capability and the nonce again before it reads the file. For an invalid invoice, the report goes to the supplier by wp_mail() and to the browser as a download.

<?php
// includes/class-inbound.php (part 1: upload page, validate, report, extract)
defined( 'ABSPATH' ) || exit;

class InvoiceXML_Inbound {
    public static function init(): void {
        add_action( 'init', [ __CLASS__, 'register_post_type' ] );
        add_action( 'admin_post_invoicexml_inbound', [ __CLASS__, 'handle' ] );
        add_action( 'admin_menu', function () {
            add_submenu_page( 'woocommerce', 'Import e-invoice', 'Import e-invoice',
                'manage_woocommerce', 'invoicexml-inbound', [ __CLASS__, 'page' ] );
        } );
    }

    public static function page(): void {
        $done = absint( $_GET['imported'] ?? 0 );
        ?>
        <div class="wrap">
            <h1>Import e-invoice</h1>
            <?php if ( $done ) : ?><div class="notice notice-success"><p>Valid invoice stored as record <?php echo esc_html( $done ); ?>.</p></div><?php endif; ?>
            <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data">
                <input type="hidden" name="action" value="invoicexml_inbound">
                <?php wp_nonce_field( 'invoicexml_inbound' ); ?>
                <p><input type="file" name="invoice" accept=".pdf,.xml" required></p>
                <p><input type="email" name="sender" class="regular-text" placeholder="Supplier email for the report"></p>
                <?php submit_button( 'Validate and import' ); ?>
            </form>
        </div>
        <?php
    }

    public static function handle(): void {
        if ( ! current_user_can( 'manage_woocommerce' ) ) {
            wp_die( 'Not allowed.', 403 );
        }
        check_admin_referer( 'invoicexml_inbound' );
        $upload = $_FILES['invoice'] ?? null;
        if ( ! $upload || ! is_uploaded_file( $upload['tmp_name'] ) ) {
            wp_die( 'No file.', 400 );
        }
        $sender = sanitize_email( wp_unslash( $_POST['sender'] ?? '' ) );
        try {
            self::import( sanitize_file_name( $upload['name'] ), file_get_contents( $upload['tmp_name'] ), $sender );
        } catch ( Throwable $e ) {
            wp_die( esc_html( $e->getMessage() ) );
        }
    }

    private static function import( string $filename, string $content, string $sender ): void {
        $is_xml = str_ends_with( strtolower( $filename ), '.xml' );

        // 1. Validate. The body is the PDF report, the verdict is in the X-Invoice-Valid header.
        $report = InvoiceXML_Api::post_file(
            $is_xml ? '/v1/validate/xrechnung/report' : '/v1/validate/zugferd/report',
            $filename, $content, [ 'validationLanguage' => 'de' ]
        );

        if ( 'true' !== wp_remote_retrieve_header( $report, 'x-invoice-valid' ) ) {
            // 2. Invalid: email the report to the supplier, offer it as a download, stop
            $pdf = wp_remote_retrieve_body( $report );
            if ( $sender ) {
                $path = get_temp_dir() . 'Pruefbericht-' . pathinfo( $filename, PATHINFO_FILENAME ) . '.pdf';
                file_put_contents( $path, $pdf );
                wp_mail( $sender, 'Prüfbericht zu Ihrer Rechnung ' . $filename,
                    "Guten Tag,\n\nIhre Rechnung hat die Prüfung nicht bestanden. Der Bericht im Anhang nennt die Fehler.\n"
                    . "Bitte senden Sie uns eine korrigierte Rechnung.", [], [ $path ] );
                unlink( $path );
            }
            nocache_headers();
            header( 'Content-Type: application/pdf' );
            header( 'Content-Disposition: attachment; filename="Pruefbericht.pdf"' );
            echo $pdf;   // binary PDF from the API
            exit;
        }

        // 3. Valid: read the data and store it for the bookkeeping
        $data = json_decode( wp_remote_retrieve_body( InvoiceXML_Api::post_file( '/v1/extract/json', $filename, $content ) ), true );
        $id   = self::store( $data, $filename );
        wp_safe_redirect( admin_url( 'admin.php?page=invoicexml-inbound&imported=' . $id ) );
        exit;
    }

Plain PDFs: some suppliers still send a PDF without XML inside. Send it to POST /v1/parse/json instead. The API reads typed, scanned and photographed invoices with AI. The response has the same JSON plus a confidence object with scores from 0.0 to 1.0. Store the record when the scores are high and mark it for review when they are low. If the file is not an invoice, the API rejects it with error code 4008. The embedded files of an e-invoice (BG-24) come from /v1/extract/attachments as one ZIP.


Store the supplier invoice as a private record

WooCommerce has no supplier or purchase invoice. So the plugin keeps the data in a private custom post type invoicexml_inbound. It is not public and has no front-end URL. Shop managers see the list under WooCommerce. The supplier VAT id, the invoice number, the date and the amount due are separate meta fields. An export can filter and sort by them. The full JSON is stored next to them.

    // includes/class-inbound.php (part 2: the private post type and the record)
    public static function register_post_type(): void {
        register_post_type( 'invoicexml_inbound', [
            'label'        => 'Supplier e-invoices',
            'public'       => false,
            'show_ui'      => true,
            'show_in_menu' => 'woocommerce',
            'supports'     => [ 'title', 'custom-fields' ],
            'capabilities' => [ 'create_posts' => 'do_not_allow' ],   // records come only from the import
            'map_meta_cap' => true,
        ] );
    }

    private static function store( array $data, string $filename ): int {
        $invoice = $data['invoice'] ?? [];
        $vat     = $invoice['seller']['vatIdentifier'] ?? '';

        // The supplier: the name of the last record with the same VAT id, else the name on the invoice
        $known = $vat ? get_posts( [ 'post_type' => 'invoicexml_inbound', 'post_status' => 'private',
            'meta_key' => '_seller_vat', 'meta_value' => $vat, 'numberposts' => 1 ] ) : [];
        $supplier = $known ? get_post_meta( $known[0]->ID, '_supplier', true ) : ( $invoice['seller']['name'] ?? '' );

        $id = wp_insert_post( [
            'post_type'   => 'invoicexml_inbound',
            'post_status' => 'private',
            'post_title'  => trim( $supplier . ' ' . ( $invoice['invoiceNumber'] ?? $filename ) ),
        ], true );
        if ( is_wp_error( $id ) ) {
            throw new RuntimeException( $id->get_error_message() );
        }
        update_post_meta( $id, '_supplier', $supplier );
        update_post_meta( $id, '_seller_vat', $vat );
        update_post_meta( $id, '_invoice_number', $invoice['invoiceNumber'] ?? '' );
        update_post_meta( $id, '_issue_date', $invoice['issueDate'] ?? '' );
        update_post_meta( $id, '_due_payable', $invoice['totals']['duePayableAmount'] ?? '' );
        update_post_meta( $id, '_invoice_json', wp_slash( wp_json_encode( $data ) ) );   // update_post_meta unslashes
        return $id;
    }
}

The VAT id is the key to the supplier. When a shop manager renames _supplier on one record, every later invoice with the same VAT id gets the new name. For the bookkeeping export, read the records with get_posts() and write the meta fields to CSV. Or use WP-CLI: wp post list --post_type=invoicexml_inbound --post_status=private --format=csv. If the shop also runs an ERP, post the stored JSON to its import interface. If you need SQL reports on many invoices, a custom table with the same columns works the same way.


XRechnung for German public buyers

Public buyers in Germany (federal, state and municipal) need XRechnung, not ZUGFeRD. XRechnung is pure XML, without a PDF. The portals ZRE and OZG-RE check each file against the KoSIT rules. The plugin creates it with the second endpoint and a few more fields. Step 3 already switches the endpoint when the order has a Leitweg-ID. These are the missing parts:

    // includes/class-invoices.php (part 4: Leitweg-ID). In init(), hook leitweg_field to 'show_user_profile'
    // and 'edit_user_profile', and save_leitweg to 'personal_options_update' and 'edit_user_profile_update'.

    public static function leitweg_field( WP_User $user ): void {
        if ( current_user_can( 'manage_woocommerce' ) ) {
            printf( '<h2>E-Rechnung</h2><table class="form-table"><tr><th><label for="leitweg_id">Leitweg-ID</label></th>'
                . '<td><input type="text" name="leitweg_id" id="leitweg_id" class="regular-text" value="%s"></td></tr></table>',
                esc_attr( get_user_meta( $user->ID, 'leitweg_id', true ) ) );
        }
    }

    // WordPress checks the nonce of the profile form before these hooks run
    public static function save_leitweg( int $user_id ): void {
        if ( current_user_can( 'manage_woocommerce' ) && current_user_can( 'edit_user', $user_id ) && isset( $_POST['leitweg_id'] ) ) {
            update_user_meta( $user_id, 'leitweg_id', sanitize_text_field( wp_unslash( $_POST['leitweg_id'] ) ) );
        }
    }

    // Copies the Leitweg-ID to the order once, so a later profile change keeps old invoices as they were
    private static function copy_leitweg_id( WC_Order $order ): void {
        $leitweg_id = get_user_meta( $order->get_customer_id(), 'leitweg_id', true );
        if ( $leitweg_id && ! $order->get_meta( '_invoicexml_leitweg_id' ) ) {
            $order->update_meta_data( '_invoicexml_leitweg_id', $leitweg_id );
            $order->save();
        }
    }

    // includes/class-mapper.php, in build() before "return $invoice;"
    if ( $invoice['buyerReference'] ) {
        $invoice['seller']['contact'] = [
            'name'  => InvoiceXML_Api::option( 'contact_name' ),
            'phone' => InvoiceXML_Api::option( 'contact_phone' ),
            'email' => InvoiceXML_Api::option( 'contact_email' ),
        ];
        $invoice['seller']['electronicAddress'] = [ 'identifier' => InvoiceXML_Api::option( 'seller_vat' ), 'schemeId' => '9930' ];
        $invoice['buyer']['electronicAddress']  = $invoice['buyer']['vatIdentifier']
            ? [ 'identifier' => $invoice['buyer']['vatIdentifier'], 'schemeId' => '9930' ]
            : [ 'identifier' => $invoice['buyerReference'], 'schemeId' => '0204' ];
    }

The Leitweg-ID is the routing id of the public buyer. The buyer gives it to you when you register as a supplier. Rule BR-DE-15 rejects an XRechnung without it. Public buyers usually order with a customer account, so the field sits in the WordPress user profile (user meta leitweg_id). Only shop managers see and edit it. The job copies it to the order meta _invoicexml_leitweg_id, and the mapper puts it into buyerReference. If a buyer needs a different Leitweg-ID per order, set the order meta in the order screen before the job runs.

The German rules also require a seller contact (name, phone, email) and electronic addresses for both sides. Scheme 9930 marks a German VAT id. A public buyer without a VAT id gets its Leitweg-ID with scheme 0204. If you deliver over Peppol, use the Peppol participant id with its scheme instead. The API returns XML that already passed the KoSIT rules. The plugin stores it as an .xml file like the PDF. Deliver 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 records. The XRechnung API toolkit explains the format in detail.


HPOS, Action Scheduler and multisite

HPOS. High-Performance Order Storage keeps orders in their own tables (wc_orders, wc_orders_meta, wc_order_addresses and wc_order_operational_data, with the table prefix). New stores have it on by default since WooCommerce 8.2. With HPOS on, these tables hold the current order data. Code that reads orders with get_post_meta() can get old data or none. The plugin uses only wc_get_order(), get_meta(), update_meta_data() and save(). These work with HPOS on, with HPOS off and in compatibility mode. The call to FeaturesUtil::declare_compatibility() tells WooCommerce this. It must run in before_woocommerce_init in the main plugin file.

Action Scheduler. WooCommerce ships Action Scheduler, so as_enqueue_async_action() is always available. The queue runs through WP-Cron, and WP-Cron runs on page requests. On a shop with little traffic, call wp-cron.php from a server cron every minute, or run wp action-scheduler run --group=invoicexml with WP-CLI. Pending, completed and failed actions show under WooCommerce, Status, Scheduled Actions. To queue an order again after a failure, run wp eval 'InvoiceXML_Invoices::queue( 1234 );' with the order id.

Multisite. In a multisite network, each site has its own table prefix. So each site has its own options, order tables and Action Scheduler queue. The InvoiceXML settings and the seller data are therefore per site. wp_upload_dir() returns the folder of the current site (uploads/sites/2/invoicexml for site 2), so files stay apart too. The constant INVOICEXML_API_KEY in wp-config.php applies to every site in the network. If the sites belong to different companies, leave the constant out and enter one key per site. WP-CLI commands take --url to pick the site.

Nginx. Nginx ignores .htaccess files. On Nginx, add a rule that denies /wp-content/uploads/invoicexml/, for example location ^~ /wp-content/uploads/invoicexml/ { deny all; }. The random file names and the download handler stay as they are.


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 plugin with five PHP files: settings, mapper, background job with storage or email, and the supplier invoice import. WooCommerce keeps the orders. The API does the formats. No PDF library and no XML code in the shop, and nothing to update when the formats change. This guide is part of the integrations hub, which also covers Shopware, SAP, Business Central 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