Skip to content

PHP

Send the UBL invoices you already generate in PHP over the Peppol network, with a thin Composer package.

You build the invoice with num-num/ubl-invoice, josemmo/einvoicing or any other tool. The getpeppr/getpeppr-php package hands the XML to getpeppr, which validates it against the official Peppol rulebooks and delivers it to the recipient's access point. It sends a UBL Invoice or CreditNote for your own company or for one of your platform's customers, reads a document's status, manages your platform's customers (Legal Entities) and verifies webhook signatures.

It does not generate, rewrite or repair your document. To send an invoice described as JSON, use the TypeScript SDK or the REST API instead. The package is a PSR-18 client: it uses the HTTP client your application already has (Guzzle, Symfony HttpClient…), and it never retries on its own.

Install

Send UBL from PHP with one Composer package, getpeppr/getpeppr-php:

Terminal
composer require getpeppr/getpeppr-php

PHP 8.2 or later. If your project has no PSR-18 client yet, add one; it is found automatically.

Terminal
composer require guzzlehttp/guzzle

Create an account and a sandbox key (sk_sandbox_…) in the getpeppr console. Sandbox documents travel on the Peppol test network, to test participants.

Send an invoice made with num-num/ubl-invoice

send.php
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.
$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).

Send an invoice made with josemmo/einvoicing

send.php
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

Complete, runnable scripts for both libraries live in the package's examples folder.

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 webhooks. The statuses are described in Document Status.

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 Your First Sandbox Test.

When a call fails

Every exception a call raises extends Getpeppr\Exception\GetpepprException, and its class tells you what to do. An argument the package refuses before sending (an API key or idempotency key that cannot be a header value, a blank id, an invalid base URL, text that is not valid UTF-8, a negative webhook tolerance) raises PHP's own \InvalidArgumentException instead, and nothing is sent.

ExceptionWhat happenedWhat to do
TransportExceptionNo response arrived (network, timeout), or the API host answered 429 or 5xx on its own.The document may have been sent. Resend the same request later with the same idempotency key, never a new one.
DocumentTooLargeExceptionThe API host refused a request over the size limit before getpeppr saw it.At most about 3 MB of document. Nothing was sent.
DuplicateDocumentExceptionThe same invoice was already sent moments ago (409).Do not send it again. Read the status of $e->duplicateOf, the document that already left.
IdempotencyKeyExceptionThe key was reused for another body, or was blank.Fix the key: one key is one operation.
InvalidRequestExceptionThe request or the document must be fixed, for example a broken Peppol rule.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.
ContactSupportExceptionThe 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.
AuthenticationExceptionThe API key was refused.Check the key.
RetryableExceptionA temporary refusal getpeppr announced: rate limit, validator unavailable…Resend later with the same key, after $e->retryAfter seconds when set.
UnsupportedOperationExceptionThe operation is not supported.Do not resend.
RequestNotSentExceptionYour PSR-7 or PSR-17 implementation could not build the request.Nothing was sent. Fix the HTTP setup, then send again; the same idempotency key is fine.
UnreadableResponseExceptionThe call succeeded but its response could not be read.It may have taken effect. Read the status, or resend with the same key, which cannot send it twice.
GetpepprExceptionA 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. Each exception also exposes status, resultCode, remediation, retryable, requestId, resultDocs (a link to the result catalogue), retryAfter, publicMessage and vercelId. 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
}

An exception never contains your API key, the document, the sender, the invoice number, the idempotency key or the response body, and 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 201 for the same document, with the same $sent->providerDocumentId. While another attempt under that key is still running, you get RetryableException (result code idempotency.concurrent_request) instead: retry later with the same key. Sent from another of your API keys within 15 minutes, the resend gets DuplicateDocumentException, 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 the 201 of the document if it went out, never a second copy. Keep the body identical until you have an answer.
  • Same key, different body: IdempotencyKeyException, 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->requestId to support.
  • Like any request, a resend can also meet a refusal raised before these checks, for example AuthenticationException, ContactSupportException (production eligibility checks), or RetryableException for a rate limit or for result code idempotency.cache_unreadable when the key cannot be verified. Nothing is sent then: once the cause is cleared, resend with the same key.
  • The same invoice sent again within 15 minutes, under another key or none, is refused with DuplicateDocumentException. After 15 minutes it is accepted and $sent->duplicateOf names the earlier document: check it.
  • Two requests sent at the same instant under two different keys are not serialised: each can pass. The duplicate check looks back 30 days.

Webhooks

webhook.php
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 (PostgreSQL shown).
$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);

Webhook::verify() checks the HMAC-SHA256 signature in constant time and refuses a timestamp more than 300 seconds away. 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. If the effect cannot live in the same transaction (an email, a call to another service), delete the reservation when handling fails. Event types: 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. Use it only for a document you validated yourself. Every result exposes requestId, resultCode and httpStatus, and toArray() returns the decoded response.

Scope and limits

  • The package sends UBL you already have. It does not generate UBL and is not a port of the TypeScript SDK.
  • Countries and document types depend on the getpeppr service, not on the package: see 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.
  • Source, issues and changelog: GitHub. Releases: Packagist. MIT licence.

Next steps

Create a free sandbox key and send your first UBL to the Peppol test receiver 9925:BE0314595348.