ventnet / xinvoice-client
PHP client for the XInvoice API (api.xinvoice.net): build, generate, validate and retrieve XRechnung (E-Rechnung) and ZUGFeRD e-invoices.
Requires
- php: >=8.1
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.59
- guzzlehttp/guzzle: ^7.8
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client implementation used for sending requests.
- nyholm/psr7: A lightweight PSR-7 / PSR-17 implementation.
README
Deutsch | English
Komfortabler PHP-Client für die XInvoice API (www.xinvoice.net).
Erzeuge, validiere und verwalte XRechnung (UBL und CII) sowie ZUGFeRD / Factur-X
E-Rechnungen direkt aus deiner eigenen Software heraus.
Die Zielgruppe sind Entwickler von Rechnungssoftware, die E-Rechnungen nach EN 16931 erstellen müssen, ohne XML-, Schematron- und PDF/A-Details selbst implementieren zu wollen.
- Fluent Builder zum schrittweisen Aufbau des Payloads (
addInvoiceItem(),addAllowance(),addPrecedingInvoiceReference()) - Eingabe wahlweise als Builder, Array oder JSON
- Framework-unabhängig über PSR-18 / PSR-17 (läuft in Laravel, Symfony, Plain-PHP)
- Typisierte Antwortobjekte, Enums und eine vollständige Exception-Hierarchie
- Polling-Helfer für den asynchronen Standard-Flow
Vollständige API-Referenz: https://xinvoice-doc.vent.net/
Installation
composer require ventnet/xinvoice-client
Zusätzlich wird eine PSR-18-HTTP-Client- und eine PSR-17-Factory-Implementierung benötigt. Wer noch keine im Projekt hat, installiert z. B.:
composer require guzzlehttp/guzzle nyholm/psr7
Der Client findet installierte Implementierungen automatisch (via
php-http/discovery). Alternativ lassen sie sich explizit injizieren.
Voraussetzungen
- PHP 8.1 oder neuer
- Ein API-Key im Format
xr_<keyId>.<secret>(Registrierung unter www.xinvoice.net) - Ein Konto mit aktivem Billing-Status für produktive Nutzung
Schnellstart
use VentNet\XInvoice\XInvoiceClient; use VentNet\XInvoice\Builder\InvoiceBuilder; use VentNet\XInvoice\Builder\PartyBuilder; use VentNet\XInvoice\Builder\LineBuilder; use VentNet\XInvoice\Enum\DocumentFormat; $client = XInvoiceClient::create('xr_your-key-id.your-secret'); $invoice = (new InvoiceBuilder()) ->documentFormat(DocumentFormat::XRECHNUNG_UBL) ->invoiceNumber('RE-2026-001') ->issueDate('2026-04-07') ->dueDate('2026-04-21') ->buyerReference('04011000-12345-03') ->currency('EUR') ->seller(fn (PartyBuilder $s) => $s ->name('Muster GmbH') ->vatId('DE123456789') ->address('Musterstr. 1', '12345', 'Berlin', 'DE')) ->buyer(fn (PartyBuilder $b) => $b ->name('Kunde GmbH') ->address('Hauptstr. 5', '54321', 'Hamburg', 'DE')) ->addInvoiceItem(fn (LineBuilder $l) => $l ->name('Webentwicklung') ->quantity(10)->unit('HUR')->price(100)->taxRate(19)); $result = $client->generate($invoice, sync: true); if ($result->isGenerated()) { echo $result->xml; }
Den Payload aufbauen
Es gibt drei austauschbare und kombinierbare Wege.
1. Schrittweise mit dem Builder
$invoice = (new InvoiceBuilder()) ->invoiceNumber('RE-2026-001') ->currency('EUR') ->addInvoiceItem(fn (LineBuilder $l) => $l->name('Position 1')->quantity(2)->unit('C62')->price(50)->taxRate(19)) ->addInvoiceItem(['name' => 'Position 2', 'quantity' => 1, 'unit_code' => 'C62', 'price' => 10, 'tax_rate' => 19]);
2. Aus einem Array
$invoice = InvoiceBuilder::fromArray([ 'invoice_number' => 'RE-2026-001', 'currency' => 'EUR', 'seller' => ['name' => 'Muster GmbH', 'vat_id' => 'DE123456789', /* ... */], 'buyer' => ['name' => 'Kunde GmbH', /* ... */], 'items' => [['name' => 'Beratung', 'quantity' => 1, 'unit_code' => 'HUR', 'price' => 100, 'tax_rate' => 19]], ]); // und danach weiter ergänzen: $invoice->addInvoiceItem(['name' => 'Zusatz', 'quantity' => 1, 'unit_code' => 'C62', 'price' => 5, 'tax_rate' => 19]);
3. Aus JSON
$invoice = InvoiceBuilder::fromJson($jsonString);
Party-Objekte eigenständig erstellen
PartyBuilder lässt sich auch unabhängig aufbauen und erst danach zuweisen:
$seller = new PartyBuilder(); $seller->name('Muster GmbH'); $seller->vatId('DE123456789'); $seller->address('Musterstr. 1', '12345', 'Berlin', 'DE'); $seller->email('seller@example.com'); $invoice->seller($seller);
Arrays, Closures und vorab erstellte Builder sind bei seller(), buyer() und
addInvoiceItem() jederzeit mischbar.
Logo und PDF-Briefkopf (nur ZUGFeRD)
Für das ZUGFeRD-PDF lässt sich entweder ein Logo oder ein leeres Briefkopf-PDF (Hintergrund) hinterlegen. Beides schließt sich gegenseitig aus: Ein Briefkopf enthält üblicherweise bereits das Logo, daher hat der Briefkopf Vorrang und ein zusätzlich gesetztes Logo wird ignoriert. Die erzeugte Rechnung wird auf den Briefkopf gesetzt (nur auf der ersten Seite):
// Variante A: eigener Briefkopf aus Logo + Daten $invoice->logoUrl('https://example.com/logo.png'); // Variante B: fertiges Briefkopf-PDF (Logo wird dann ignoriert) $invoice // Briefkopf entweder als URL ... ->letterheadUrl('https://example.com/briefkopf.pdf') // ... oder als Datei / Base64 (schließt letterheadUrl gegenseitig aus): ->letterheadPdfFromFile('/pfad/zu/briefkopf.pdf') ->letterheadPdfBase64($base64Pdf) // optionale Inhalts-Ränder in mm, damit der Inhalt den Briefkopf nicht überdeckt: ->letterheadMargins(top: 45, right: 20, bottom: 30, left: 20);
Da der Briefkopf üblicherweise bereits die Angaben des Rechnungserstellers enthält,
werden die Seller-Daten im PDF bei verwendetem Briefkopf standardmäßig unterdrückt.
Mit printSellerAddress() werden sie trotzdem ausgegeben. Das XML bleibt davon
unberührt. Das Briefkopf-PDF sollte PDF/A-tauglich sein (eingebettete Schriften,
keine Transparenz, nicht verschlüsselt).
$invoice->printSellerAddress(); // Seller-Block trotz Briefkopf im PDF ausgeben
Rabatte und Anzahlungen
Dokumentrabatte (EN16931 BG-20) und bereits gezahlte Beträge (BT-113) lassen sich typisiert setzen. Jeder Rabatt braucht den Steuersatz, auf den er wirkt:
use VentNet\XInvoice\Builder\AllowanceBuilder; $invoice ->addAllowance(fn (AllowanceBuilder $a) => $a ->reason('Nachlass') ->amount(100) ->taxRate(19) ->baseAmount(1000) ->percentage(10)) ->prepaidAmount(500);
Reverse Charge
Für eine vollständig dem Reverse-Charge-Verfahren unterliegende Rechnung wird die
Umsatzsteuerkategorie AE zusammen mit dem maschinenlesbaren Befreiungsgrund
übergeben. Alle Positionen und Dokumentrabatte müssen dabei einen Steuersatz von
0 tragen. Außerdem benötigen Verkäufer und Käufer eine USt-IdNr.
use VentNet\XInvoice\Enum\VatCategoryCode; $invoice ->taxCategory(VatCategoryCode::REVERSE_CHARGE) ->taxExemptionReason('Steuerschuldnerschaft des Leistungsempfängers') ->taxExemptionReasonCode('VATEX-EU-AE');
Die API setzt damit AE auf den Positionen (BT-151), Dokumentrabatten (BT-95)
und in der Umsatzsteueraufschlüsselung (BT-118). Ein Rechnungshinweis für die
menschenlesbare Ausgabe muss unabhängig davon mit invoiceNote() gesetzt werden.
Rechnungstypen und Vorgängerrechnungen
Für Gutschriften und Rechnungskorrekturen stehen typisierte UNTDID-1001-Codes zur Verfügung. Referenzen auf Vorgängerrechnungen (EN16931 BG-3) können als Builder, Array oder Closure ergänzt werden:
use VentNet\XInvoice\Builder\PrecedingInvoiceReferenceBuilder; use VentNet\XInvoice\Enum\InvoiceTypeCode; $invoice ->invoiceTypeCode(InvoiceTypeCode::CORRECTED_INVOICE) ->addPrecedingInvoiceReference( fn (PrecedingInvoiceReferenceBuilder $reference) => $reference ->invoiceNumber('RE-2026-001') ->issueDate('2026-04-07'), ) ->precedingInvoiceReference('RE-2025-100');
invoiceTypeCode() akzeptiert weiterhin auch Strings und null. Bei Array- und
JSON-Payloads lautet das Feld exakt preceding_invoice_references:
'preceding_invoice_references' => [ ['invoice_number' => 'RE-2026-001', 'issue_date' => '2026-04-07'], ['invoice_number' => 'RE-2025-100'], ],
Rechnungen erzeugen
// Asynchron (API-Standard): liefert sofort eine "queued"-Antwort mit Poll-URL $result = $client->generate($invoice); $result->isQueued(); // true $result->invoiceId; // zum späteren Abrufen / Pollen // Synchron erzwingen (Header "Prefer: respond-sync") $result = $client->generate($invoice, sync: true);
Eine synchrone Anfrage mit blockierenden Validierungsfehlern wirft keine
Exception, sondern liefert ein GenerateResult mit isFailed() === true:
$result = $client->generate($invoice, sync: true); if ($result->isFailed()) { foreach ($result->validation->errors() as $error) { echo "[{$error->code}] {$error->message}\n"; } }
Asynchron erzeugen und automatisch pollen
use VentNet\XInvoice\PollOptions; $resource = $client->generateAndWait($invoice, new PollOptions(maxAttempts: 30, intervalSeconds: 2)); if ($resource->isGenerated()) { $pdf = $client->downloadPdf($resource->invoiceId); // nur bei ZUGFeRD }
Validieren (ohne Speichern)
$client->validate($invoice); // JSON-Payload $client->validateXml($xmlString); // rohes XML $client->validatePdf($pdfBinary); // rohes ZUGFeRD-PDF $client->validatePdfBase64($base64String); // ZUGFeRD-PDF als Base64 $response = $client->validate($invoice); $response->isValid(); $response->validation()->errors();
Rechnungen abrufen und auflisten
use VentNet\XInvoice\Query\ListInvoicesQuery; use VentNet\XInvoice\Enum\InvoiceStatus; $resource = $client->getInvoice($invoiceId); $resource = $client->getInvoice($invoiceId, includePdf: true); $list = $client->listInvoices( ListInvoicesQuery::create() ->status(InvoiceStatus::GENERATED) ->createdFrom('2026-04-01') ->perPage(50) ); foreach ($list as $summary) { echo $summary->invoiceNumber . ' – ' . $summary->status->value . PHP_EOL; } $list->meta->total; $list->meta->hasMorePages(); // PDF-Bytes herunterladen file_put_contents('invoice.pdf', $client->downloadPdf($invoiceId));
Systemendpunkte
$client->ping(); // bool – öffentliche Liveness-Prüfung $client->readiness(); // ReadinessResult – Validierungs-Runtime bereit?
Fehlerbehandlung
Alle Fehler implementieren VentNet\XInvoice\Exception\XInvoiceException, sodass
sie sich gemeinsam fangen lassen.
| HTTP | Exception |
|---|---|
| 401 | AuthenticationException |
| 402 | PaymentRequiredException |
| 404 | NotFoundException |
| 422 | RequestValidationException (mit getErrors()) |
| 429 | RateLimitException (mit getRetryAfter()) |
| 5xx | ServerException |
| Netzwerk | TransportException |
use VentNet\XInvoice\Exception\RequestValidationException; use VentNet\XInvoice\Exception\RateLimitException; use VentNet\XInvoice\Exception\XInvoiceException; try { $client->generate($invoice); } catch (RequestValidationException $e) { foreach ($e->getErrors() as $field => $messages) { // Feldfehler verarbeiten } } catch (RateLimitException $e) { sleep($e->getRetryAfter() ?? 5); } catch (XInvoiceException $e) { // alle übrigen Client-Fehler }
Konfiguration
use VentNet\XInvoice\ClientConfig; use VentNet\XInvoice\Enum\DocumentFormat; $config = new ClientConfig( baseUrl: 'https://api.xinvoice.net/v1', // z. B. für lokale Tests überschreibbar userAgent: 'meine-rechnungssoftware/2.0', defaultDocumentFormat: DocumentFormat::ZUGFERD, ); $client = XInvoiceClient::create('xr_keyId.secret', $config);
Eigene PSR-18/PSR-17-Implementierungen injizieren:
$client = new XInvoiceClient( apiKey: 'xr_keyId.secret', config: new ClientConfig(), httpClient: $myPsr18Client, requestFactory: $myPsr17Factory, streamFactory: $myPsr17Factory, );
Beispiele
Lauffähige Skripte liegen im Ordner examples/.
Entwicklung
composer install composer test # PHPUnit composer stan # PHPStan (level max) composer cs-fix # PHP-CS-Fixer
Lizenz
MIT – siehe LICENSE.