getpeppr / getpeppr-php
Send the UBL invoices you already generate in PHP over the Peppol network with getpeppr. PSR-18 client: UBL import, document status, platform customers (Legal Entities), webhook signature verification.
Requires
- php: ^8.2
- ext-hash: *
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9 || ^8.0
- josemmo/einvoicing: ^0.3.1
- num-num/ubl-invoice: ^2.4
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-31 22:28:09 UTC
README
Send the UBL invoices you already generate in PHP over the Peppol network.
You build the invoice with num-num/ubl-invoice, josemmo/einvoicing or any other tool. This package hands the XML to getpeppr, which validates it against the official Peppol rulebooks and delivers it to the recipient's access point. It does not generate, rewrite or repair your document.
- Send a UBL Invoice or CreditNote, for your own company or for one of your platform's customers.
- Read a document's status.
- Manage your platform's customers (Legal Entities).
- Verify webhook signatures without writing cryptography.
- Know what to do when a call fails: the exception class tells you whether resending can succeed.
It is a thin PSR-18 client: it uses the HTTP client your application already has (Guzzle, Symfony HttpClient, …), and it never retries on its own.
Install
composer require getpeppr/getpeppr-php
PHP 8.2 or later. If your project has no PSR-18 client yet, add one, for example composer require guzzlehttp/guzzle. It is found automatically.
Create an account and a sandbox key (sk_sandbox_…) at console.getpeppr.dev. Sandbox documents travel on the Peppol test network, to test participants.
Send an invoice made with num-num/ubl-invoice
use Getpeppr\Client; use NumNum\UBL; $invoice = (new UBL\Invoice()) ->setCustomizationId('urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0') ->setProfileId('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0') ->setId('INV-2026-001') // … supplier, customer, lines and totals, as you already do ; $xml = (new UBL\Generator())->invoice($invoice, 'EUR'); $getpeppr = new Client((string) getenv('GETPEPPR_API_KEY')); // One opaque id per logical send, created once and stored with the invoice // BEFORE the call, so that a retry of this send reuses it (see "Resending safely"). $operationId = bin2hex(random_bytes(16)); $sent = $getpeppr->importInvoice( $xml, 'INV-2026-001.xml', '9925:BE0314595348', // the recipient's Peppol id, scheme:identifier idempotencyKey: $operationId, ); echo $sent->providerDocumentId; // read its status with getInvoice()
The recipient id, the third argument, is the only thing that decides where the document goes. If it disagrees with the customer address written in the document, the send still goes through and $sent->warnings says so (recipient_endpoint_mismatch).
Complete, runnable scripts: examples/send-with-num-num.php and examples/send-with-josemmo.php.
Send an invoice made with josemmo/einvoicing
use Einvoicing\Invoice; use Einvoicing\Presets\Peppol; use Einvoicing\Writers\UblWriter; $invoice = new Invoice(Peppol::class); // … number, seller, buyer, lines, as you already do $xml = (new UblWriter())->export($invoice); $sent = $getpeppr->importInvoice($xml, 'F-2026-001.xml', '9925:BE0314595348', idempotencyKey: $operationId); // created once, stored with the invoice
Read the status
$document = $getpeppr->getInvoice($sent->providerDocumentId); echo $document->status; // submitted, delivered, accepted, rejected, failed, …
A successful send means getpeppr accepted the document for transmission, not that it was delivered. Read the status, or listen to the invoice.* webhooks.
Platforms: send for your customers
With a master key, create each customer once, then name it as the sender:
use Getpeppr\Sender; $customer = $getpeppr->createLegalEntity([ 'externalId' => 'customer_8412', // your own reference 'companyName' => 'Bright Health Ltd', 'country' => 'GB', 'address' => ['line1' => '10 King Street', 'city' => 'London', 'zip' => 'EC2V 8EA'], 'identifier' => ['scheme' => 'GB:VAT', 'value' => 'gb123456789'], ]); // $customer->created is false when that externalId already existed. $getpeppr->importInvoice($xml, 'INV-8412-001.xml', '9925:BE0314595348', sender: Sender::externalSubTenant('customer_8412'), // or Sender::legalEntity($customer->id) idempotencyKey: $operationId, // created once, stored with the invoice ); $page = $getpeppr->listLegalEntities(externalId: 'customer_8412'); $one = $getpeppr->getLegalEntity($customer->id);
The supplier written in the document must be that customer: getpeppr never rewrites your document, so a disagreement is refused. A standard key that names a sender is refused with 403. In the sandbox you can test without a real company: see the sandbox pilot guide.
When a call fails
Every exception extends Getpeppr\Exception\GetpepprException. Its class tells you what to do:
| Exception | What happened | What to do |
|---|---|---|
TransportException |
No response arrived (network, timeout), or the API host answered 429/5xx on its own. |
The document may have been sent. Resend later with the same idempotency key, never a new one. |
DocumentTooLargeException |
The API host refused a request over the size limit before getpeppr saw it. (When getpeppr refuses it, request.body_too_large, you get an InvalidRequestException, its parent class.) |
At most about 3 MB of document. Nothing was sent. |
DuplicateDocumentException |
The same invoice was already sent moments ago (409). |
Do not send it again. Read the status of $e->duplicateOf, the document that already left. |
IdempotencyKeyException |
The key was reused for another body, or was blank. | Fix the key: one key is one operation. |
InvalidRequestException |
The request or the document must be fixed, e.g. a Peppol rule is broken (invoices_import.validation_failed). |
Fix what the result code names: the document, or the request around it (for example sender sent with a standard key). Send a corrected invoice under a new idempotency key. |
ContactSupportException |
The account cannot do this right now (identity not verified, platform contract not active, master key needed, …). | The document is not at fault. Keep it, and resend it once the account state has changed. |
AuthenticationException |
The API key was refused. | Check the key. |
RetryableException |
A temporary refusal getpeppr announced (rate limit, validator unavailable, …). | Resend later with the same key, after $e->retryAfter seconds when set. |
UnsupportedOperationException |
The operation is not supported. | Do not resend. |
RequestNotSentException |
Your PSR-7/PSR-17 implementation could not build the request. | Nothing was sent. Fix the HTTP setup, then send again; the same idempotency key is fine. |
UnreadableResponseException |
The call succeeded but its response could not be read. | It may have taken effect: read the status, or resend with the same key, which never sends it twice (see Resending safely). |
GetpepprException itself |
A response this version cannot interpret. | Do not guess: keep the document and quote $e->requestId to support. |
The family comes from the Getpeppr-Remediation header the API sends, not from retryable alone: a rejected document and an account that may not send both say retryable: false, and they call for opposite actions. DocumentTooLargeException is also an InvalidRequestException.
Each exception also exposes status, resultCode (for example invoices_import.validation_failed), remediation, retryable, requestId, resultDocs (a link to the result catalogue), retryAfter, publicMessage (the catalogue's fixed sentence) and vercelId (when the host answered without getpeppr). Any of them may be null.
use Getpeppr\Exception; try { $sent = $getpeppr->importInvoice($xml, $filename, $recipient, idempotencyKey: $operationId); } catch (Exception\DuplicateDocumentException $e) { markAsSent($invoice, $e->duplicateOf); // it already left } catch (Exception\TransportException | Exception\RetryableException $e) { retryLater($invoice, $operationId, $e->retryAfter); // same key } catch (Exception\ContactSupportException $e) { holdUntilAccountReady($invoice, $e->resultCode); // keep the document } catch (Exception\InvalidRequestException $e) { markAsRejected($invoice, $e->resultCode, $e->resultDocs); // fix it, send it under a new key }
What an exception never contains: your API key, the document (raw or base64), the sender, the invoice number, the idempotency key or the response body. The message is built from the response headers only, the PSR-18 exception is not chained (it may carry your request), and the package's parameters are marked #[\SensitiveParameter] so they stay out of stack traces. The package writes no logs. The rules a rejected document broke are therefore not on the exception: open resultDocs, or check the document against the validation endpoint.
Resending safely
The package never retries by itself. When you retry, the idempotency key decides whether the API sees a retry or a new send.
- Use an opaque operation id, for example a UUID, created once per logical send and stored with the invoice before the call. Reuse it only to resend exactly the same request:
// $invoice is your own record. The id is created on the first attempt and reloaded on every retry. $invoice->getpepprOperationId ??= bin2hex(random_bytes(16)); $repository->save($invoice); $sent = $getpeppr->importInvoice($xml, $filename, $recipient, idempotencyKey: $invoice->getpepprOperationId);
- Do not derive the key from the invoice number. Once a request was sent with a key, that key belongs to it for good, across all your API keys and legal entities. On a platform, two of your customers may use the same invoice number, and the second legitimate send would be refused.
- Same key, same body: never a second document. You get
201for the same document, with the same$sent->providerDocumentId. While another attempt under that key is still running, you getRetryableException(result codeidempotency.concurrent_request) instead: retry later with the same key. Sent from another of your API keys within 15 minutes, the resend getsDuplicateDocumentException, naming the document. - After a
TransportException, the invoice may or may not have gone out. Resend the same body with the same key: you get the201of the document if it went out, never a second copy. Keep the body identical until you have an answer. - Same key, different body:
IdempotencyKeyException(idempotency.key_reused), and nothing is sent. A corrected invoice is a new send: give it a new operation id. Once getpeppr has tried to send a request, its key stays bound to it even if the network refuses the document. - Rarely, the network already holds a document under your key that getpeppr cannot attribute to your request. The key then stays blocked, and nothing is sent, until support reconciles it: if the same request keeps getting
RetryableException, quote$e->requestIdto support. - Like any request, a resend can also meet a refusal raised before these checks, for example
AuthenticationException,ContactSupportException(production eligibility checks), orRetryableExceptionfor a rate limit or for result codeidempotency.cache_unreadablewhen the key cannot be verified. Nothing is sent then: once the cause is cleared, resend with the same key.
What the API does on top, which you do not have to reimplement:
- The same invoice (same sender, recipient, number, invoice or credit note) sent again within 15 minutes, under another key or none, is refused with
DuplicateDocumentException. Nothing was sent. - After 15 minutes, it is accepted and
$sent->duplicateOfnames the earlier document: a second copy was accepted for transmission. Check it. - Two requests sent at the same instant under two different keys are not serialised: each can pass. Reuse one key for the retries of a send.
- The duplicate check looks back 30 days.
Webhooks
use Getpeppr\Webhook; use Getpeppr\Exception\WebhookVerificationException; $rawBody = file_get_contents('php://input'); // the raw body, never a re-encoded one $header = $_SERVER['HTTP_GETPEPPR_SIGNATURE'] ?? ''; try { $event = Webhook::verify($rawBody, $header, getenv('GETPEPPR_WEBHOOK_SECRET')); } catch (WebhookVerificationException $e) { http_response_code(400); // $e->reason says why exit; } // Deliveries are at least once: the same event can arrive again, with the same id. // Reserve the id and apply the effect in ONE transaction: if handling fails, the // reservation is rolled back and the redelivery is handled, not skipped. // (PostgreSQL shown; INSERT IGNORE on MySQL.) $pdo->beginTransaction(); try { $reserve = $pdo->prepare('INSERT INTO getpeppr_events (id) VALUES (?) ON CONFLICT (id) DO NOTHING'); $reserve->execute([$event->id]); if ($reserve->rowCount() === 1) { handle($event->type, $event->data); // invoice.sent, invoice.error, … } $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); http_response_code(500); // getpeppr delivers it again exit; } http_response_code(200);
If the effect cannot live in the same transaction (an email, a call to another service), delete the reservation when handling fails, so that the next delivery is not mistaken for a duplicate.
verify() checks the HMAC-SHA256 signature in constant time and refuses a timestamp more than 300 seconds away (toleranceSeconds: changes it). It proves the event comes from getpeppr and is recent; it cannot prove it is new, so a legitimate redelivery verifies too. Any answer other than 2xx, or no answer within 5 seconds, makes getpeppr deliver again after 1, 5 and 30 minutes. A runnable endpoint: examples/webhook-endpoint.php. Event types: getpeppr.dev/docs/webhooks.
Configuration
$getpeppr = new Client( apiKey: getenv('GETPEPPR_API_KEY'), // sk_sandbox_… or sk_live_…: the API decides the environment from the key baseUrl: 'https://api.getpeppr.dev/v1', // the default httpClient: $psr18Client, // optional; set your timeouts on it requestFactory: $psr17Factory, // optional streamFactory: $psr17Factory, // optional );
importInvoice() also takes skipValidation: true, which sends without the Peppol rulebook check for that call (x-skip-validation). Use it only for a document you validated yourself.
Every result (ImportResult, Invoice, LegalEntity, LegalEntityList) exposes requestId, resultCode and httpStatus, and toArray() returns the decoded response for fields this version does not map. Invalid arguments throw \InvalidArgumentException before anything is sent: a blank, . or .. id, an API key or idempotency key that cannot be an HTTP header value, or text that is not valid UTF-8 (convert Latin-1 with mb_convert_encoding() first). A response that succeeded but cannot be read throws UnreadableResponseException: the call may have taken effect, so check before repeating it.
Scope and limits
- This package sends UBL you already have. To send invoices described as JSON, use the API directly or the TypeScript SDK.
- Countries and document types depend on the getpeppr service, not on this package: see getpeppr.dev/countries.
- Version 0.x: the interface may still change between minor versions. PHP 8.2 is supported until its security support ends; the first release after 31 December 2026 requires PHP 8.3.
Links
- Documentation: getpeppr.dev/docs
- API reference: getpeppr.dev/reference
- Result codes: getpeppr.dev/docs/api-results
- Account and API keys: console.getpeppr.dev
- Security: SECURITY.md
License
MIT. See LICENSE.