Automation MCP Server Blog Pricing
Automation

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

Many German B2B shops run on Shopware 6. They must send and receive e-invoices now. This guide shows how to create ZUGFeRD invoices with a small plugin and one REST API. The hybrid PDF becomes the invoice document the customer downloads from the account. It also covers email delivery by the API, XRechnung for public buyers, and checking and storing the supplier invoices the shop receives.

This guide adds ZUGFeRD and XRechnung to Shopware 6. You need a small self-hosted plugin and one REST API. The plugin reads the order, posts a JSON body and stores the returned PDF as the invoice document. Business customers get a ZUGFeRD PDF in their account. Public buyers get XRechnung XML. The email section shows how the API sends the invoice to the customer. The import section shows how to check the supplier invoices your shop receives and store the data for the bookkeeping. The code is PHP 8 and Symfony, like every Shopware plugin. The same calls work from an app server. The PHP guide covers the API calls outside Shopware, with Guzzle and Laravel.


Why Shopware needs ZUGFeRD now

German B2B shops on Shopware must send and receive structured e-invoices. Since January 2025, every German business must accept e-invoices. From January 2027, businesses above EUR 800,000 turnover must send them. From 2028, all businesses must. Shopware renders the invoice PDF from the Twig template documents/invoice.html.twig. That file is a plain PDF without XML. The law wants a hybrid file: a PDF the customer can open, with XML inside that their software can read. This file is ZUGFeRD. The Germany mandate page lists the dates and formats.

Agencies serve many shops. One plugin, built once, works for every Shopware installation. The API key and the seller data are plugin configuration, per sales channel if needed. The order data comes from the DAL. Install the plugin, fill in the configuration, done.

The integration does not change when the formats change. ZUGFeRD, XRechnung and the EN 16931 rule sets get new versions. The API applies them on their effective dates. Your plugin sends the same JSON before and after. Nothing to update in the shop.


What you need

  • Shopware 6.4, 6.5 or 6.6, self-hosted, on PHP 8.1 or newer. The code uses constructor promotion and attributes.
  • An invoice document per order. Shopware creates it with the Flow Builder action "Generate document" or by hand in the order. The plugin works on orders that have one.
  • Two custom fields. Create a custom field set "InvoiceXML" under Settings, System, Custom fields: invoicexml_done (checkbox) assigned to orders and invoicexml_leitweg_id (text) assigned to customers.
  • A running message queue. The scheduled task runs through the admin worker or through bin/console messenger:consume and scheduled-task:run in a cron job.
  • HTTPS from the web 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 gets an invoice document in Shopware. A scheduled task finds it, reads the order, builds a JSON body and posts it to one endpoint. The API calculates the totals and the VAT, creates the XML, embeds it in a PDF/A-3 file, validates it and returns the PDF. The plugin saves the PDF as media and links it to the invoice document, 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 plugin is named InvoiceXmlEInvoice. Its composer.json has the type shopware-platform-plugin and names the plugin class in extra.shopware-plugin-class. These are the files you create from the listings in this guide:

  • src/Resources/config/config.xml: the plugin configuration (API key, sender address, seller data).
  • src/Resources/config/services.xml: the service registration.
  • src/Service/ApiClient.php: the API calls (JSON body and file upload).
  • src/Service/OrderMapper.php: reads the order and builds the JSON.
  • src/Service/EInvoiceService.php: creates the file, stores it, sends it by email.
  • src/ScheduledTask/CreateEInvoicesTask.php and its handler: finds orders with an invoice document and no e-invoice.
  • src/Controller/InboundController.php and src/Service/InboundService.php: validates incoming invoices, sends the report back, reads the data and stores it.
  • src/Entity/InboundInvoiceDefinition.php and one migration: the custom entity for incoming invoices.

Everything else is standard Shopware: the order, document, customer and media entities, SystemConfigService, MediaService, MailService and the Symfony HTTP client.


Step 1: Store the API key in the plugin configuration

The API key and the seller data are plugin configuration. The merchant fills them in under Extensions, My extensions, and the plugin reads them with SystemConfigService. The key field has the type password, so the administration hides it. A value set on a sales channel overrides the default, which covers shops with more than one company.

<!-- src/Resources/config/config.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/platform/trunk/src/Core/System/SystemConfig/Schema/config.xsd">
    <card>
        <title>InvoiceXML</title>
        <input-field type="password">
            <name>apiKey</name>
            <label>API key</label>
        </input-field>
        <input-field>
            <name>fromEmail</name>
            <label>Sender address on the verified sending domain</label>
        </input-field>
        <input-field type="int">
            <name>dueDays</name>
            <label>Payment term in days</label>
            <defaultValue>14</defaultValue>
        </input-field>
    </card>
    <card>
        <title>Seller</title>
        <input-field><name>sellerName</name><label>Company name</label></input-field>
        <input-field><name>sellerVatId</name><label>VAT id</label></input-field>
        <!-- same pattern for sellerStreet, sellerPostCode, sellerCity, sellerCountry (ISO code), sellerIban,
             sellerContactName, sellerContactPhone and sellerContactEmail (the last three for XRechnung) -->
    </card>
</config>

<!-- src/Resources/config/services.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <service id="InvoiceXmlEInvoice\Service\ApiClient">
            <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
        </service>
        <service id="InvoiceXmlEInvoice\Service\OrderMapper">
            <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
        </service>
        <service id="InvoiceXmlEInvoice\Service\EInvoiceService">
            <argument type="service" id="InvoiceXmlEInvoice\Service\ApiClient"/>
            <argument type="service" id="InvoiceXmlEInvoice\Service\OrderMapper"/>
            <argument type="service" id="Shopware\Core\Content\Media\MediaService"/>
            <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
            <argument type="service" id="order.repository"/>
            <argument type="service" id="document.repository"/>
        </service>
        <service id="InvoiceXmlEInvoice\ScheduledTask\CreateEInvoicesTask">
            <tag name="shopware.scheduled.task"/>
        </service>
        <service id="InvoiceXmlEInvoice\ScheduledTask\CreateEInvoicesTaskHandler">
            <argument type="service" id="scheduled_task.repository"/>
            <argument type="service" id="logger"/>
            <argument type="service" id="document.repository"/>
            <argument type="service" id="InvoiceXmlEInvoice\Service\EInvoiceService"/>
            <tag name="messenger.message_handler"/>
        </service>
        <service id="InvoiceXmlEInvoice\Service\InboundService">
            <argument type="service" id="InvoiceXmlEInvoice\Service\ApiClient"/>
            <argument type="service" id="Shopware\Core\Content\Mail\Service\MailService"/>
            <argument type="service" id="invoicexml_inbound_invoice.repository"/>
        </service>
        <service id="InvoiceXmlEInvoice\Controller\InboundController" public="true">
            <argument type="service" id="InvoiceXmlEInvoice\Service\InboundService"/>
        </service>
        <service id="InvoiceXmlEInvoice\Entity\InboundInvoiceDefinition">
            <tag name="shopware.entity.definition"/>
        </service>
    </services>
</container>

The API client wraps the Symfony HTTP client. It has one method for JSON bodies and one for file uploads. The upload endpoints take a multipart form with one field named file. The client builds that form with FormDataPart from the Mime component. Shopware ships it already.

<?php declare(strict_types=1);
// src/Service/ApiClient.php
namespace InvoiceXmlEInvoice\Service;

use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

class ApiClient
{
    private HttpClientInterface $http;

    public function __construct(private readonly SystemConfigService $config)
    {
        $this->http = HttpClient::create(['base_uri' => 'https://api.invoicexml.com', 'timeout' => 120]);
    }

    // JSON body in, file out (PDF for ZUGFeRD, XML for XRechnung)
    public function postJson(string $path, array $body, ?string $salesChannelId): string
    {
        $response = $this->http->request('POST', $path, [
            'headers' => $this->headers($salesChannelId),
            'json'    => $body,
        ]);
        if ($response->getStatusCode() !== 200) {
            // 400 returns JSON with an errors array: one entry per violated rule
            throw new \RuntimeException($response->getContent(false));
        }

        return $response->getContent();
    }

    // The report endpoints answer with a PDF and put the verdict in the X-Invoice-Valid header
    public function validateReport(string $path, string $file, string $fileName, ?string $salesChannelId): array
    {
        $response = $this->upload($path, $file, $fileName, ['validationLanguage' => 'de'], $salesChannelId);
        $valid = ($response->getHeaders()['x-invoice-valid'][0] ?? 'false') === 'true';

        return [$valid, $response->getContent()];
    }

    public function extractJson(string $file, string $fileName, ?string $salesChannelId): array
    {
        return $this->upload('/v1/extract/json', $file, $fileName, [], $salesChannelId)->toArray();
    }

    // The upload endpoints take a multipart form with one field named "file"
    private function upload(string $path, string $file, string $fileName, array $fields, ?string $salesChannelId): ResponseInterface
    {
        $form = new FormDataPart(['file' => new DataPart($file, $fileName)] + $fields);
        $response = $this->http->request('POST', $path, [
            'headers' => array_merge($this->headers($salesChannelId), $form->getPreparedHeaders()->toArray()),
            'body'    => $form->bodyToIterable(),
        ]);
        if ($response->getStatusCode() !== 200) {
            throw new \RuntimeException($response->getContent(false));
        }

        return $response;
    }

    private function headers(?string $salesChannelId): array
    {
        $key = (string) $this->config->get('InvoiceXmlEInvoice.config.apiKey', $salesChannelId);

        return ['Authorization' => 'Bearer ' . $key];
    }
}

getContent(false) returns the error body without throwing, so the message from the API ends up in the exception. The sales channel id selects the configuration of the shop the order belongs to.


Step 2: Map Shopware order fields to JSON

The API takes an EN 16931 invoice as JSON. The invoice data comes from the order entity and the invoice document. The seller data comes from the plugin configuration. This is the mapping:

Shopware fieldJSON pathNote
document.config.documentNumberinvoice.invoiceNumberBT-1. The invoice number from the number range, not the order number
document.config.documentDateinvoice.issueDateFirst 10 characters of the ISO value
Issue date + dueDays from the configurationinvoice.dueDatePayment term
order.currency.isoCodeinvoice.currencyISO code
order.orderNumberinvoice.purchaseOrderReferenceThe order number the customer sees in the account, BT-13
Plugin configurationinvoice.sellerName, VAT id, address, IBAN of the shop
order.billingAddress.company, street, zipcode, city, country.isoinvoice.buyerFirst and last name when company is empty
order.orderCustomer.vatIds[0]invoice.buyer.vatIdentifierThe VAT id the customer entered at registration
lineItems[].label, quantitylines[].item.name, lines[].quantityLines of type product and custom
lineItems[].price.totalPrice, calculatedTaxeslines[].priceDetails.netPriceNet orders: unitPrice is net. Gross orders: net price = unitPrice / (1 + rate / 100)
lineItems[].price.taxRules[0].taxRatelines[].vatInformation.rate19 or 7 in Germany
order.shippingCostsOne extra lineQuantity 1, same net logic
customer.customFields.invoicexml_leitweg_idinvoice.buyerReferenceXRechnung only

You do not send totals, rounding or VAT amounts. The API calculates them from the lines. If the numbers do not add up, the API returns an error instead of a file. This catches rounding and discount problems before the customer sees them. The taxStatus of the order decides if the line prices include VAT. The mapper reads the net line total from the calculated price and divides it by the quantity.

<?php declare(strict_types=1);
// src/Service/OrderMapper.php: order + invoice document + configuration -> InvoiceXML JSON
namespace InvoiceXmlEInvoice\Service;

use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice;
use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice;
use Shopware\Core\Checkout\Document\DocumentEntity;
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\System\SystemConfig\SystemConfigService;

class OrderMapper
{
    public function __construct(private readonly SystemConfigService $config)
    {
    }

    // Returns the "invoice" object; the callers wrap it as ['invoice' => ...] or add 'email'
    public function build(OrderEntity $order, DocumentEntity $document): array
    {
        $sc        = $order->getSalesChannelId();
        $address   = $order->getBillingAddress();
        $customer  = $order->getOrderCustomer();
        $issueDate = new \DateTimeImmutable(substr($document->getConfig()['documentDate'] ?? 'now', 0, 10));
        $dueDays   = (int) ($this->cfg('dueDays', $sc) ?? 14);

        $lines = [];
        foreach ($order->getLineItems() as $item) {
            if (!in_array($item->getType(), ['product', 'custom'], true)) {
                continue;   // promotion lines are already applied to the product prices
            }
            $lines[] = $this->line($item->getLabel(), $item->getQuantity(), $item->getPrice(), $order->getTaxStatus());
        }
        if ($order->getShippingCosts()->getTotalPrice() > 0) {
            $lines[] = $this->line('Versandkosten', 1, $order->getShippingCosts(), $order->getTaxStatus());
        }

        return [
            'invoiceNumber'          => $document->getConfig()['documentNumber'],
            'issueDate'              => $issueDate->format('Y-m-d'),
            'dueDate'                => $issueDate->modify("+{$dueDays} days")->format('Y-m-d'),
            'currency'               => $order->getCurrency()->getIsoCode(),
            'purchaseOrderReference' => $order->getOrderNumber(),
            'seller' => [
                'name'          => $this->cfg('sellerName', $sc),
                'vatIdentifier' => $this->cfg('sellerVatId', $sc),
                'postalAddress' => [
                    'line1'    => $this->cfg('sellerStreet', $sc),
                    'city'     => $this->cfg('sellerCity', $sc),
                    'postCode' => $this->cfg('sellerPostCode', $sc),
                    'country'  => $this->cfg('sellerCountry', $sc),
                ],
            ],
            'buyer' => [
                'name'          => $address->getCompany() ?: $address->getFirstName() . ' ' . $address->getLastName(),
                'vatIdentifier' => $customer->getVatIds()[0] ?? null,
                'postalAddress' => [
                    'line1'    => $address->getStreet(),
                    'city'     => $address->getCity(),
                    'postCode' => $address->getZipcode(),
                    'country'  => $address->getCountry()->getIso(),
                ],
            ],
            'paymentDetails' => ['paymentAccountIdentifier' => $this->cfg('sellerIban', $sc)],
            'lines' => $lines,
        ];
    }

    private function line(string $name, int $quantity, CalculatedPrice $price, string $taxStatus): array
    {
        $rate = $price->getTaxRules()->first()?->getTaxRate() ?? 0.0;

        // Gross orders: totalPrice includes VAT, so subtract the calculated taxes.
        // Net orders: totalPrice is the net amount already.
        $lineNet = $taxStatus === CartPrice::TAX_STATE_GROSS
            ? $price->getTotalPrice() - $price->getCalculatedTaxes()->getAmount()
            : $price->getTotalPrice();

        return [
            'quantity'       => $quantity,
            'unitCode'       => 'H87',   // pieces; map your product units if you sell hours or kilograms
            'item'           => ['name' => $name],
            'priceDetails'   => ['netPrice' => round($lineNet / $quantity, 4)],
            'vatInformation' => ['rate' => $rate],
        ];
    }

    private function cfg(string $key, ?string $salesChannelId): mixed
    {
        return $this->config->get('InvoiceXmlEInvoice.config.' . $key, $salesChannelId);
    }
}

The mapper needs the order with the associations lineItems, billingAddress.country, orderCustomer.customer and currency. Step 4 loads them. A null value, such as a missing VAT id, is left out of the JSON by the API.


Step 3: Create the ZUGFeRD invoice from the Shopware order

One method builds the body and posts it. The result is the PDF as a string. The caller stores it, sends it or saves it.

<?php declare(strict_types=1);
// src/Service/EInvoiceService.php: create, store, email
namespace InvoiceXmlEInvoice\Service;

use Shopware\Core\Checkout\Document\DocumentEntity;
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\Content\Media\MediaService;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\System\SystemConfig\SystemConfigService;

class EInvoiceService
{
    public function __construct(
        private readonly ApiClient $api,
        private readonly OrderMapper $mapper,
        private readonly MediaService $mediaService,
        private readonly SystemConfigService $config,
        private readonly EntityRepository $orderRepository,
        private readonly EntityRepository $documentRepository,
    ) {
    }

    public function createZugferd(OrderEntity $order, DocumentEntity $document): string
    {
        $invoice = $this->mapper->build($order, $document);

        return $this->api->postJson('/v1/create/zugferd', ['invoice' => $invoice], $order->getSalesChannelId());
    }

For French customers, post the same body to /v1/create/facturx. ZUGFeRD and Factur-X are the same standard under two names. Pick the endpoint by the country of the billing address. The Factur-X API toolkit covers the French side. On Shopware 6.4, the repository type is EntityRepositoryInterface.


Step 4: Store the PDF as the invoice document

Shopware keeps one media file per document in document.documentMediaFileId. When the field is set, Shopware serves that file instead of rendering the Twig template. The plugin saves the hybrid PDF with MediaService as private media in the document folder. Then it writes the media id into the invoice document. From then on the customer account, the Documents tab in the administration and the document email all deliver the ZUGFeRD PDF. The order gets the invoicexml_done flag, so the task does not process it twice.

    public function processOrder(string $orderId, Context $context): void
    {
        $criteria = new Criteria([$orderId]);
        $criteria->addAssociations(['lineItems', 'billingAddress.country', 'orderCustomer.customer', 'currency', 'documents.documentType']);
        $order = $this->orderRepository->search($criteria, $context)->first();

        // The newest invoice document of the order
        $document = $order->getDocuments()
            ->filter(fn (DocumentEntity $d) => $d->getDocumentType()->getTechnicalName() === 'invoice')
            ->last();
        if ($document === null || ($order->getCustomFields()['invoicexml_done'] ?? false)) {
            return;   // no invoice yet, or already done
        }

        $pdf = $this->createZugferd($order, $document);
        $this->storeAsInvoiceDocument($order, $document, $pdf, $context);
    }

    public function storeAsInvoiceDocument(OrderEntity $order, DocumentEntity $document, string $pdf, Context $context): void
    {
        $fileName = 'Rechnung-' . $document->getConfig()['documentNumber'] . '-zugferd';

        // Private media in the "document" folder, like Shopware's own invoice PDFs
        $mediaId = $this->mediaService->saveFile($pdf, 'pdf', 'application/pdf', $fileName, $context, 'document', null, true);

        // The invoice document now points to the hybrid PDF
        $this->documentRepository->update([['id' => $document->getId(), 'documentMediaFileId' => $mediaId]], $context);
        $this->orderRepository->update([['id' => $order->getId(), 'customFields' => ['invoicexml_done' => true]]], $context);
    }
}

// src/ScheduledTask/CreateEInvoicesTask.php
namespace InvoiceXmlEInvoice\ScheduledTask;

use Shopware\Core\Framework\MessageQueue\ScheduledTask\ScheduledTask;

class CreateEInvoicesTask extends ScheduledTask
{
    public static function getTaskName(): string
    {
        return 'invoicexml.create_einvoices';
    }

    public static function getDefaultInterval(): int
    {
        return 60;   // seconds
    }
}

// src/ScheduledTask/CreateEInvoicesTaskHandler.php
namespace InvoiceXmlEInvoice\ScheduledTask;

use InvoiceXmlEInvoice\Service\EInvoiceService;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\MessageQueue\ScheduledTask\ScheduledTaskHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler(handles: CreateEInvoicesTask::class)]
class CreateEInvoicesTaskHandler extends ScheduledTaskHandler
{
    public function __construct(
        EntityRepository $scheduledTaskRepository,
        LoggerInterface $logger,
        private readonly EntityRepository $documentRepository,
        private readonly EInvoiceService $service,
    ) {
        parent::__construct($scheduledTaskRepository, $logger);
    }

    public function run(): void
    {
        $context = Context::createDefaultContext();

        // Invoice documents whose order has no e-invoice yet
        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('documentType.technicalName', 'invoice'));
        $criteria->addFilter(new EqualsFilter('order.customFields.invoicexml_done', null));
        $criteria->setLimit(20);

        foreach ($this->documentRepository->search($criteria, $context) as $document) {
            try {
                $this->service->processOrder($document->getOrderId(), $context);
            } catch (\Throwable $e) {
                // The message from the API names the rule and the field. Fix the order or the configuration.
                $this->logger->error('InvoiceXML: ' . $e->getMessage(), ['orderId' => $document->getOrderId()]);
            }
        }
    }
}

When to run it. The task runs every minute through the message queue. Register it once with bin/console scheduled-task:register. On Shopware 6.4, add a static getHandledMessages() method that returns the task class instead of the attribute. The Flow Builder section shows how to call processOrder from a flow instead, right after Shopware generates the invoice.

Sending from Shopware. If a flow sends the invoice email right after "Generate document", it attaches the file that exists at that moment. Put the email on a later trigger, or let the task run first. Then the attachment is the hybrid PDF. The customer gets one PDF that opens normally and that their software can also read.


Send the invoice by email from the API

You can skip the document email in Shopware. The API creates the invoice, validates it and emails it to the customer in one call. Your plugin never handles the file. This is enough for Germany: there is no central platform for B2B e-invoices, and an email with a ZUGFeRD PDF is a complete delivery.

Set up your sending domain once in the dashboard: add the domain and publish the three CNAME records it shows. From then on, every address on that domain can send. The customer sees your address, and replies reach you. The request is the same JSON body plus an email object. The recipient is the email of the order customer:

    public function sendByEmail(OrderEntity $order, DocumentEntity $document, Context $context): void
    {
        $sc     = $order->getSalesChannelId();
        $number = $document->getConfig()['documentNumber'];

        $this->api->postJson('/v1/create/zugferd/email', [
            'invoice' => $this->mapper->build($order, $document),
            'email'   => [
                'from'    => $this->config->get('InvoiceXmlEInvoice.config.fromEmail', $sc),   // an address on your verified sending domain
                'to'      => $order->getOrderCustomer()->getEmail(),
                'subject' => "Rechnung {$number} zu Ihrer Bestellung {$order->getOrderNumber()}",
                'message' => "Guten Tag,\n\nanbei erhalten Sie die Rechnung {$number} zu Ihrer Bestellung als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen",
            ],
        ], $sc);

        // 200 means: created, validated and handed to delivery. The response has no file.
        $this->orderRepository->update([['id' => $order->getId(), 'customFields' => ['invoicexml_done' => true]]], $context);
    }

Use this route when the API should send. Use the plain endpoint from step 3 when Shopware should store the file and send it itself. The email endpoint also exists for XRechnung (/v1/create/xrechnung/email) and Factur-X.


Import incoming supplier invoices

Since January 2025, German companies must accept e-invoices. The shop receives invoices too: from the hosting provider, the payment provider, the agency, the parcel service. More and more of them arrive as ZUGFeRD PDFs and XRechnung XML files. The pipeline has three steps: validate, send the report back if the invoice is invalid, extract the data if it is valid. Shopware has no mailbox, so the plugin exposes an admin API route. Your mail tooling or a script calls it with the file and the sender address.

  1. Validate. Send the file to POST /v1/validate/zugferd/report or /v1/validate/xrechnung/report. The API checks the file against EN 16931 and the German rules. The response is a PDF validation report, and the header X-Invoice-Valid says if the invoice passed. The validationLanguage field sets the language of the report, here German. If you only want the findings as JSON, use /v1/validate/zugferd without /report.
  2. Reply with the report. If the invoice is invalid, email the PDF report to the sender with MailService and stop. The report lists every violated rule with the field and the message. The supplier can fix the invoice without a call to your office.
  3. Extract. If the invoice is valid, send the file to POST /v1/extract/json. The API reads the embedded XML, or the XRechnung XML, and returns it as JSON in the same shape as the create request.
<?php declare(strict_types=1);
// src/Controller/InboundController.php: POST /api/_action/invoicexml/inbound with the fields file, senderEmail, salesChannelId
namespace InvoiceXmlEInvoice\Controller;

use InvoiceXmlEInvoice\Service\InboundService;
use Shopware\Core\Framework\Context;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

#[Route(defaults: ['_routeScope' => ['api']])]
class InboundController
{
    public function __construct(private readonly InboundService $inbound)
    {
    }

    #[Route(path: '/api/_action/invoicexml/inbound', name: 'api.action.invoicexml.inbound', methods: ['POST'])]
    public function inbound(Request $request, Context $context): JsonResponse
    {
        $upload = $request->files->get('file');

        $result = $this->inbound->process(
            file_get_contents($upload->getPathname()),
            $upload->getClientOriginalName(),
            (string) $request->request->get('senderEmail'),
            (string) $request->request->get('salesChannelId'),
            $context,
        );

        return new JsonResponse($result);
    }
}

// src/Service/InboundService.php: validate, send the report back, extract, store
namespace InvoiceXmlEInvoice\Service;

use Shopware\Core\Content\Mail\Service\AbstractMailService;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\Uuid\Uuid;

class InboundService
{
    public function __construct(
        private readonly ApiClient $api,
        private readonly AbstractMailService $mailService,
        private readonly EntityRepository $inboundRepository,   // invoicexml_inbound_invoice.repository
    ) {
    }

    public function process(string $file, string $fileName, string $senderEmail, string $salesChannelId, Context $context): array
    {
        // 1. Validate. PDF files are ZUGFeRD, XML files are XRechnung.
        $path = str_ends_with(strtolower($fileName), '.xml')
            ? '/v1/validate/xrechnung/report'
            : '/v1/validate/zugferd/report';
        [$valid, $report] = $this->api->validateReport($path, $file, $fileName, $salesChannelId);

        // 2. Invalid: email the PDF report to the sender and stop
        if (!$valid) {
            $this->mailService->send([
                'recipients'     => [$senderEmail => $senderEmail],
                'senderName'     => 'Buchhaltung',
                'salesChannelId' => $salesChannelId,   // the sender address comes from this sales channel
                'subject'        => "Ihre Rechnung {$fileName} konnte nicht verarbeitet werden",
                'contentPlain'   => 'Der angehängte Prüfbericht zeigt die Fehler. Bitte senden Sie eine korrigierte Rechnung.',
                'contentHtml'    => '<p>Der angehängte Prüfbericht zeigt die Fehler. Bitte senden Sie eine korrigierte Rechnung.</p>',
                'binAttachments' => [[
                    'content'  => $report,
                    'fileName' => "Pruefbericht-{$fileName}.pdf",
                    'mimeType' => 'application/pdf',
                ]],
            ], $context);

            return ['valid' => false];
        }

        // 3. Valid: read the data and store the record
        $data = $this->api->extractJson($file, $fileName, $salesChannelId);
        $this->store($data, $fileName, $context);

        return ['valid' => true, 'invoiceNumber' => $data['invoice']['invoiceNumber']];
    }

The route needs an admin API token. Create an integration under Settings, System, Integrations and call the route with its access key. Plain PDFs: some suppliers still send PDFs without XML. Send those to POST /v1/parse/json. The API reads typed, scanned and photographed invoices with AI and returns the same JSON plus a confidence object with scores between 0.0 and 1.0. Store records with high scores and flag the rest for review. Files that are not invoices are rejected with error code 4008.


Store the invoice record in a custom entity

Shopware is the shop, not the bookkeeping. So the plugin keeps the incoming invoice as a record in a custom entity, invoicexml_inbound_invoice. The record holds the invoice number, the seller VAT id, the total and the full JSON. Your ERP export or your accountant reads the records from there, through the Admin API or a database view, and sets exported when done. The seller VAT id in the record is the key to match the supplier in the ERP.

<?php declare(strict_types=1);
// src/Entity/InboundInvoiceDefinition.php
namespace InvoiceXmlEInvoice\Entity;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\DateField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class InboundInvoiceDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'invoicexml_inbound_invoice';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new PrimaryKey(), new Required()),
            (new StringField('invoice_number', 'invoiceNumber'))->addFlags(new Required()),
            new StringField('seller_vat_id', 'sellerVatId'),
            new DateField('issue_date', 'issueDate'),
            new FloatField('grand_total', 'grandTotal'),
            new StringField('file_name', 'fileName'),
            (new JsonField('data', 'data'))->addFlags(new Required()),   // the full JSON from /v1/extract/json
            new BoolField('exported', 'exported'),                       // set by your ERP export
        ]);
    }
}

// src/Migration/Migration1793836800InboundInvoice.php
namespace InvoiceXmlEInvoice\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1793836800InboundInvoice extends MigrationStep
{
    public function getCreationTimestamp(): int
    {
        return 1793836800;
    }

    public function update(Connection $connection): void
    {
        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `invoicexml_inbound_invoice` (
                `id`             BINARY(16)   NOT NULL,
                `invoice_number` VARCHAR(255) NOT NULL,
                `seller_vat_id`  VARCHAR(64)  NULL,
                `issue_date`     DATE         NULL,
                `grand_total`    DOUBLE       NULL,
                `file_name`      VARCHAR(255) NULL,
                `data`           JSON         NOT NULL,
                `exported`       TINYINT(1)   NOT NULL DEFAULT 0,
                `created_at`     DATETIME(3)  NOT NULL,
                `updated_at`     DATETIME(3)  NULL,
                PRIMARY KEY (`id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ');
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

// InboundService.php, continued: one record per valid invoice
    private function store(array $data, string $fileName, Context $context): void
    {
        $invoice = $data['invoice'];

        $this->inboundRepository->create([[
            'id'            => Uuid::randomHex(),
            'invoiceNumber' => $invoice['invoiceNumber'],
            'sellerVatId'   => $invoice['seller']['vatIdentifier'] ?? null,
            'issueDate'     => $invoice['issueDate'] ?? null,
            'grandTotal'    => $invoice['totals']['grandTotalAmount'] ?? null,
            'fileName'      => $fileName,
            'data'          => $data,
            'exported'      => false,
        ]], $context);
    }
}

The DAL adds created_at and updated_at on its own, so the table has both columns. The entity is available as invoicexml_inbound_invoice.repository in the container and under /api/invoicexml-inbound-invoice in the Admin API. An administration module that lists the records is a nice extra, not a requirement.


XRechnung for German public buyers

Invoices to German federal, state and municipal buyers must be XRechnung, not ZUGFeRD. XRechnung is pure XML with no PDF. Portals such as ZRE and OZG-RE validate it against the KoSIT rules. The same plugin creates it. You need a third endpoint and a few more fields:

// In OrderMapper::build, for public buyers. The Leitweg-ID is a custom field on the customer.
$leitwegId = $order->getOrderCustomer()->getCustomer()?->getCustomFields()['invoicexml_leitweg_id'] ?? null;

if ($leitwegId !== null) {
    $invoice['buyerReference'] = $leitwegId;
    $invoice['seller']['contact'] = [
        'name'  => $this->cfg('sellerContactName', $sc),
        'phone' => $this->cfg('sellerContactPhone', $sc),
        'email' => $this->cfg('sellerContactEmail', $sc),
    ];
    $invoice['seller']['electronicAddress'] = ['identifier' => $this->cfg('sellerVatId', $sc), 'schemeId' => '9930'];   // 9930 = German VAT id
    $invoice['buyer']['electronicAddress']  = ['identifier' => $customer->getVatIds()[0], 'schemeId' => '9930'];
}

// In EInvoiceService: pick the endpoint by the Leitweg-ID
$path = isset($invoice['buyerReference']) ? '/v1/create/xrechnung' : '/v1/create/zugferd';
$file = $this->api->postJson($path, ['invoice' => $invoice], $sc);

// XRechnung is XML, not a PDF. Save it as media next to the order and keep Shopware's PDF as the invoice document.
$mediaId = $this->mediaService->saveFile($file, 'xml', 'application/xml', "Rechnung-{$number}-xrechnung", $context, 'document', null, true);
$this->orderRepository->update([['id' => $order->getId(), 'customFields' => ['invoicexml_xrechnung_media_id' => $mediaId, 'invoicexml_done' => true]]], $context);

The Leitweg-ID is the routing id the public buyer gives you when you register as a supplier. Rule BR-DE-15 rejects invoices without it. Store it in the invoicexml_leitweg_id custom field on the customer and map it into buyerReference. The seller contact (name, phone, email) and the electronic addresses are required by the German rules. Scheme 9930 means a German VAT id. For Peppol delivery, use the Peppol participant id and its scheme instead.

The API returns the XML, validated against the KoSIT rules. Deliver it the way the buyer requires: upload to ZRE or OZG-RE, Peppol, or email where the buyer accepts it. /v1/render/xrechnung/to/pdf creates a readable PDF preview for your own records. XRechnung is also valid for B2B invoices, so the /email route works for it too. The XRechnung API toolkit covers the format in detail.


Flow Builder and Shopware versions

Shopware 6.5 and 6.6: a Flow Builder action. The scheduled task works everywhere. A flow action is more direct. Add it to a flow after "Generate document, Invoice" on the trigger you already use, such as "Order, Placed" or "Order transaction, Paid". The action calls the same processOrder method. A small administration override of sw-flow-sequence-action adds the label and the icon, so the action shows in the list.

<?php declare(strict_types=1);
// src/Flow/CreateEInvoiceAction.php (Shopware 6.5 and later), tagged flow.action with key action.invoicexml.create_einvoice
namespace InvoiceXmlEInvoice\Flow;

use InvoiceXmlEInvoice\Service\EInvoiceService;
use Shopware\Core\Content\Flow\Dispatching\Action\FlowAction;
use Shopware\Core\Content\Flow\Dispatching\StorableFlow;
use Shopware\Core\Framework\Event\OrderAware;

class CreateEInvoiceAction extends FlowAction
{
    public function __construct(private readonly EInvoiceService $service)
    {
    }

    public static function getName(): string
    {
        return 'action.invoicexml.create_einvoice';
    }

    public function requirements(): array
    {
        return [OrderAware::class];   // only on order triggers
    }

    public function handleFlow(StorableFlow $flow): void
    {
        if (!$flow->hasData(OrderAware::ORDER_ID)) {
            return;
        }

        // Same method as the scheduled task: load the order, find the invoice document, create and store
        $this->service->processOrder($flow->getData(OrderAware::ORDER_ID), $flow->getContext());
    }
}

Shopware 6.4: an event subscriber. Subscribe to state_enter.order_transaction.state.paid and call processOrder with the order id from the event. The invoice document must exist at that point, so generate it in the same flow or earlier. The JSON body and the endpoints are identical in every version.

Apps and Shopware Cloud. This guide builds a plugin, which needs a self-hosted shop. An app server does the same work through the Admin API: read the order and the document, post the JSON to the API, and upload the PDF with POST /api/_action/document/{documentId}/upload. The mapping in step 2 and the endpoints do not change.

Several sales channels. The plugin configuration can differ per sales channel. If two sales channels belong to two companies, set the seller data and the API key on each sales channel. The mapper reads the configuration with the sales channel id of the order, so every invoice carries the right seller.


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 plugin in this guide: configuration, mapper, create and store or email, check and store what comes in. No format logic in your plugin. No PDF library in your composer.json. Nothing to update 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