peppol-sh / sdk
Official PHP SDK for the peppol.sh Peppol API: Peppol e-invoicing (UBL, EN 16931) for PHP and Laravel.
Requires
- php: ^7.4 || ^8.0
- ext-curl: *
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- orchestra/testbench: ^6.47 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^1.12 || ^2.0
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.5 || ^12.0 || ^13.0
Suggests
- laravel/framework: The SDK has a Laravel integration (Laravel 8 to 13): the Peppol facade, the peppol.webhook route middleware, and Peppol::fake().
Provides
None
Conflicts
- illuminate/support: <8.0
Replaces
None
README
Send e-invoices over the Peppol network with one API call, from plain PHP or from Laravel. A PHP array in, an e-invoice out, delivered to the recipient's access point.
The client is a thin wrapper over the peppol.sh /v1
API. It runs on PHP 7.4 to 8.5, needs only ext-curl and ext-json, and has
a first-party Laravel integration in the same package. Arrays in,
arrays out: every key is the snake_case name the JSON API uses, so the API
reference is also the SDK reference.
Install
composer require peppol-sh/sdk
Quickstart
Sign up at peppol.sh — or with POST /v1/signup — and you
get a sandbox key (ps_test_…) straight away. Point the client at
https://sandbox.peppol.sh, create a company to send from, and send an invoice.
<?php require __DIR__ . '/vendor/autoload.php'; use PeppolSh\Client; $peppol = new Client(getenv('PEPPOL_API_KEY'), [ // ps_test_… 'base_url' => Client::SANDBOX_BASE_URL, // omit for production ]); $company = $peppol->companies()->create([ 'name' => 'Acme BV', 'country' => 'BE', 'company_registration_id' => '0123456749', 'peppol_id' => '0208:0123456749', // 0208 = Belgian enterprise number ]); $invoice = $peppol->documents()->send([ 'company_id' => $company['id'], 'type' => 'invoice', 'number' => 'INV-2026-001', 'issue_date' => '2026-03-01', 'due_date' => '2026-03-31', 'currency' => 'EUR', 'from' => [ 'name' => 'Acme BV', 'tax_id' => 'BE0123456749', 'peppol_id' => '0208:0123456749', 'address' => [ 'street' => 'Keizerslaan 1', 'city' => 'Brussels', 'postal_code' => '1000', 'country' => 'BE', ], ], 'to' => [ 'name' => 'Globex NV', 'tax_id' => 'BE0987654394', 'peppol_id' => '0208:0987654394', ], 'lines' => [ [ 'description' => 'API integration services', 'quantity' => 1, 'unit' => 'C62', // UN/CEFACT unit code — C62 is "piece" 'unit_price' => 500.0, 'tax_rate' => 21.0, ], ], 'payment_means' => ['method' => 'bank_transfer', 'iban' => 'BE68539007547034'], 'idempotency_key' => 'acme-INV-2026-001', ]); echo $invoice['id'], ' ', $invoice['status'], ' ', $invoice['url'], PHP_EOL; // doc_a1b2c3d4 queued /v1/documents/doc_a1b2c3d4
Sending is asynchronous. Read the document back for its current status, or subscribe to webhooks instead of polling.
$current = $peppol->documents()->get($invoice['id'], [ 'company_id' => $company['id'], ]); echo $current['status']; // queued | sending | delivered | failed $timeline = $peppol->documents()->history($invoice['id']); $ubl = $peppol->documents()->ubl($invoice['id']); // Send-ready UBL XML, a string
Laravel
The package includes a Laravel integration for Laravel 8 to 13. Laravel finds
the service provider and the Peppol facade by package auto-discovery: there
is nothing to register.
Add the key to .env. A sandbox key needs the sandbox base URL.
PEPPOL_API_KEY=ps_test_... PEPPOL_BASE_URL=https://sandbox.peppol.sh
| Variable | Default | What it does |
|---|---|---|
PEPPOL_API_KEY |
(required) | Bearer key. |
PEPPOL_BASE_URL |
https://api.peppol.sh |
Set https://sandbox.peppol.sh for a ps_test_ key. |
PEPPOL_TIMEOUT |
30 |
Time limit for each attempt, in seconds. |
PEPPOL_MAX_RETRIES |
2 |
Extra attempts after a failure that can be retried. |
PEPPOL_WEBHOOK_SECRET |
(none) | Signing secret (whsec_…) for the peppol.webhook middleware. |
PEPPOL_WEBHOOK_TOLERANCE |
300 |
Permitted clock difference for a webhook signature, in seconds. 0 disables the time check. |
To change the config file, publish it to config/peppol.php:
php artisan vendor:publish --tag=peppol-config
Use the facade, or inject PeppolSh\Client. The container holds one client
(a singleton), so the two give you the same instance.
use PeppolSh\Client; use PeppolSh\Laravel\Facades\Peppol; // Facade $invoice = Peppol::documents()->send([ 'company_id' => 'com_abc123', 'type' => 'invoice', 'number' => 'INV-2026-001', 'issue_date' => '2026-03-01', 'currency' => 'EUR', 'from' => ['name' => 'Acme BV', 'tax_id' => 'BE0123456749'], 'to' => ['name' => 'Globex NV', 'tax_id' => 'BE0987654394', 'peppol_id' => '0208:0987654394'], 'lines' => [ ['description' => 'Support', 'quantity' => 2, 'unit' => 'C62', 'unit_price' => 75, 'tax_rate' => 21], ], 'idempotency_key' => 'acme-INV-2026-001', ]); // Dependency injection, for example in a queued job public function handle(Client $peppol): void { $peppol->documents()->get($this->documentId, ['company_id' => $this->companyId]); }
Webhook middleware
The peppol.webhook route middleware
(PeppolSh\Laravel\Http\Middleware\VerifyWebhookSignature) verifies the raw
request body against the X-Peppol-Signature-V2 header with
PEPPOL_WEBHOOK_SECRET. A request with a missing or bad signature, or a
timestamp out of tolerance, gets a 400 response and does not get to your
controller. If the secret is not configured, the middleware throws a
PeppolException (HTTP 500), because that is a server configuration error.
// routes/api.php use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; Route::post('/webhooks/peppol', function (Request $request) { if ($request->input('type') === 'document.delivered') { // $request->input('data.document_id'), $request->input('data.status') } return response()->noContent(); })->middleware('peppol.webhook');
Put the route in routes/api.php. If you put it in routes/web.php, exclude
it from CSRF verification.
Tests in Laravel
Peppol::fake() replaces the client in the container with one that sends
nothing, and returns the FakeHttpClient to assert against. The fake client
needs no API key, never sends the configured one (each recorded request has
Bearer ps_test_fake), and does not retry. Call it at the start of the test: an
object that got the real client injected before the call keeps the real one.
use PeppolSh\Laravel\Facades\Peppol; use PeppolSh\Testing\FakeHttpClient; public function test_it_sends_the_invoice(): void { $fake = Peppol::fake([ FakeHttpClient::json(['id' => 'doc_abc123', 'status' => 'queued', 'url' => '/v1/documents/doc_abc123'], 202), ]); $this->post('/invoices/42/send')->assertOk(); $fake->assertSent('POST', '/v1/documents'); }
Laravel Boost
The package ships AI guidelines for Laravel Boost, so a coding agent in your Laravel project gets the correct method names and payload keys.
Authentication and environments
Every request sends Authorization: Bearer <api key>. Keys are long-lived and
carry their environment in the prefix.
| Key prefix | Base URL | Delivery |
|---|---|---|
ps_test_ |
https://sandbox.peppol.sh |
Sandbox — delivered by email, never touches the real Peppol network |
ps_live_ |
https://api.peppol.sh (default) |
Production — delivered over Peppol |
The client does not select the environment from the key. base_url defaults
to https://api.peppol.sh, so a sandbox key needs the sandbox URL set
explicitly. A trailing slash is tolerated.
$peppol = new \PeppolSh\Client(getenv('PEPPOL_API_KEY'), [ 'base_url' => getenv('PEPPOL_BASE_URL') ?: \PeppolSh\Client::DEFAULT_BASE_URL, 'timeout' => 15.0, 'max_retries' => 3, ]); print_r($peppol->health()); // ['status' => 'ok', 'version' => '2.1.0', 'environment' => 'production', 'checks' => ['db' => 'ok']]
Client options
The API key is the first constructor argument. The constructor throws
PeppolSh\Exception\PeppolException if the key is empty, if an option has an
invalid value, or if an option name is unknown.
| Option | Default | What it does |
|---|---|---|
base_url |
https://api.peppol.sh |
Sandbox, production, or a local API. |
timeout |
30.0 |
Time limit for each attempt, in seconds. On expiry the call throws TimeoutException. |
max_retries |
2 |
Extra attempts after a retryable failure. |
http_client |
CurlHttpClient |
Bring your own transport: an object that implements PeppolSh\HttpClient\HttpClientInterface. For a proxy or a CA bundle, pass new CurlHttpClient([CURLOPT_PROXY => '...']). |
sleep |
usleep |
A callable(float $seconds): void that replaces the backoff wait. Useful in tests. |
The defaults are constants on the client: Client::DEFAULT_BASE_URL,
Client::PRODUCTION_BASE_URL, Client::SANDBOX_BASE_URL,
Client::DEFAULT_TIMEOUT, and Client::DEFAULT_MAX_RETRIES. The package
version the client reports in its x-peppol-sdk request header is
PeppolSh\Version::VERSION.
The configuration cannot change after construction and the client keeps no static state, so one instance is safe to share for the life of a process.
Error handling
Every non-2xx response becomes a typed exception carrying the API's canonical
envelope — { error: { type, code, message, param?, details? } }. Branch on
getErrorCode(), never on the message.
use PeppolSh\Exception\ApiException; use PeppolSh\Exception\ConnectionException; use PeppolSh\Exception\RateLimitException; use PeppolSh\Exception\TimeoutException; use PeppolSh\Exception\ValidationException; try { $peppol->documents()->get('doc_a1b2c3d4', ['company_id' => 'com_abc123']); } catch (ValidationException $e) { error_log(sprintf('%s on %s: %s', $e->getErrorCode(), $e->getParam(), $e->getMessage())); } catch (RateLimitException $e) { error_log(sprintf('Rate limited, retry in %ss', $e->getRetryAfter() ?? 1)); } catch (ApiException $e) { error_log(sprintf('HTTP %d %s (req %s)', $e->getStatusCode(), $e->getErrorCode(), $e->getRequestId())); } catch (TimeoutException $e) { error_log(sprintf('Timed out after %ss', $e->getTimeout())); } catch (ConnectionException $e) { error_log('Could not reach the API: ' . $e->getMessage()); }
All classes are in the PeppolSh\Exception namespace.
| Class | Thrown when |
|---|---|
ValidationException |
HTTP 400 or 422 — the body or query failed validation. Read getParam() for the field. |
AuthenticationException |
HTTP 401 — the key is missing, malformed, or unknown. |
PermissionException |
HTTP 403 — authenticated, but not allowed to do this. |
NotFoundException |
HTTP 404 — no such resource, or not visible to this key. |
ConflictException |
HTTP 409 — the request conflicts with current state. |
RateLimitException |
HTTP 429 — too many requests. getRetryAfter() gives the Retry-After seconds when the API sent them. |
ServerException |
HTTP 5xx — a valid request the API failed to process. |
ApiException |
Any other non-2xx status, for example 402 when the workspace is out of credits. Base class of all of the above. |
TimeoutException |
No answer within timeout. getTimeout() gives the limit in seconds. |
ConnectionException |
The request never arrived: DNS, TLS, or socket failure. |
SignatureVerificationException |
Webhook::verify() found a malformed header, no matching signature, or a timestamp out of tolerance. |
PeppolException |
Base class of everything the SDK throws, including client-side misconfiguration such as an empty API key. |
ApiException and its subclasses expose getStatusCode(), getErrorType(),
getErrorCode(), getParam(), getDetails(), getRawBody(), getHeaders(),
and getRequestId() (from the x-request-id response header — quote it in
support requests). The built-in getCode() is the HTTP status, because PHP
exception codes are integers.
Pagination
List endpoints answer with ['data' => [...], 'has_more' => bool, 'next_cursor' => string|null]. PeppolSh\Pagination::iterate() walks every
page for you and yields the items one at a time. It fetches a page only when
the loop gets to it.
use PeppolSh\Pagination; $documents = Pagination::iterate([$peppol->documents(), 'list'], [ 'company_id' => 'com_abc123', 'status' => 'delivered', 'limit' => 50, ]); foreach ($documents as $doc) { echo $doc['id'], ' ', $doc['status'], ' ', $doc['total'], PHP_EOL; } // A list method that takes an id first: $deliveries = Pagination::iterate(function (array $params) use ($peppol) { return $peppol->webhooks()->listDeliveries('wh_abc123', $params); });
To page by hand, pass next_cursor back as cursor while has_more is true.
documents()->list, events()->list, webhooks()->listDeliveries, and both
listAuditEvents methods page this way. companies()->list,
webhooks()->list, and workspaces()->list return the full set in a data
envelope.
Retries and timeouts
Each attempt gets its own timeout budget; on expiry the call throws
TimeoutException and is not retried. What is retried, up to max_retries
extra attempts:
| Failure | Retried |
|---|---|
| HTTP 429 | Yes, for every method — the request was rejected before it was processed |
| HTTP 5xx | GET only |
Network failure (ConnectionException) |
GET only |
| HTTP 4xx other than 429 | Never |
| Timeout | Never |
Backoff is exponential with jitter, starting at 250 ms and capped at 2 s. A
Retry-After header in seconds wins over the computed delay (still capped at
2 s); the HTTP-date form is ignored. Set 'max_retries' => 0 to handle
failures yourself.
Send is idempotent
The SDK does not make an idempotency key for you. Put idempotency_key in the
documents()->send body and a repeated call is safe. The API answers 202
when it queues a new send and 200 when it replays an existing one; both
carry the same shape (id, status, url), so you always get the id of
the record that exists. A retried send therefore never produces a duplicate
invoice on the network.
API surface
Nine resources hang off the client, one per /v1 area. Each method takes
arrays and returns the decoded JSON response as an array, unless the table
says otherwise.
$peppol->documents()
| Method | Endpoint | Purpose |
|---|---|---|
send(array $params) |
POST /v1/documents |
Create and send one document |
sendBatch(array $params) |
POST /v1/documents/batch |
Send up to 100 documents for one company; results map 1:1 to the input |
list(array $params) |
GET /v1/documents |
One cursor page of a company's documents |
get(string $id, array $params) |
GET /v1/documents/{id} |
One document, scoped to its company (company_id is required) |
history(string $id) |
GET /v1/documents/{id}/history |
Delivery timeline: created, validated, queued, sending, delivered, failed |
ubl(string $id) |
GET /v1/documents/{id}/ubl |
The stored Send-ready UBL XML, as a string |
listAttachments(string $id) |
GET /v1/documents/{id}/attachments |
Attachment metadata |
getAttachment(string $id, string $attachmentId) |
GET /v1/documents/{id}/attachments/{att_id} |
One attachment's raw bytes, as a binary string |
$peppol->companies()
| Method | Endpoint | Purpose |
|---|---|---|
create(array $params) |
POST /v1/companies |
Create a company in the caller's workspace |
list() |
GET /v1/companies |
Every company in the workspace, newest first |
get(string $id) |
GET /v1/companies/{id} |
Full details for one company |
update(string $id, array $params) |
PATCH /v1/companies/{id} |
Partial update; owners and admins only |
$peppol->webhooks()
| Method | Endpoint | Purpose |
|---|---|---|
list() |
GET /v1/webhooks |
Every webhook in the workspace |
create(array $params) |
POST /v1/webhooks |
Register an endpoint; returns the signing secret once |
get(string $id) |
GET /v1/webhooks/{id} |
One webhook, without the secret |
delete(string $id) |
DELETE /v1/webhooks/{id} |
Retire a webhook |
listDeliveries(string $id, array $params = []) |
GET /v1/webhooks/{id}/deliveries |
One page of the delivery log |
test(string $id) |
POST /v1/webhooks/{id}/test |
Dispatch a synthetic webhook.test event |
rotateSecret(string $id) |
POST /v1/webhooks/{id}/rotate-secret |
Mint a new secret, with a 24-hour overlap |
$peppol->workspaces()
| Method | Endpoint | Purpose |
|---|---|---|
create(array $params) |
POST /v1/workspaces |
Create a workspace owned by the caller |
list() |
GET /v1/workspaces |
Every workspace the account belongs to |
get(string $id) |
GET /v1/workspaces/{id} |
One workspace, with the caller's role |
update(string $id, array $params) |
PATCH /v1/workspaces/{id} |
Rename or reconfigure; owners and admins only |
delete(string $id) |
DELETE /v1/workspaces/{id} |
Delete a workspace; owners only |
listMembers(string $id) |
GET /v1/workspaces/{id}/members |
Every member and their role |
inviteMember(string $id, array $params) |
POST /v1/workspaces/{id}/members |
Add a member; owners and admins only |
changeMemberRole(string $id, string $accountId, array $params) |
PATCH /v1/workspaces/{id}/members/{accountId} |
Change a role; owners only |
removeMember(string $id, string $accountId) |
DELETE /v1/workspaces/{id}/members/{accountId} |
Remove a member and revoke their keys for this workspace |
transferOwnership(string $id, array $params) |
POST /v1/workspaces/{id}/transfer-ownership |
Promote another account to owner; the caller becomes admin |
listAuditEvents(string $id, array $params = []) |
GET /v1/workspaces/{id}/audit |
Audit events for this workspace |
$peppol->account(), $peppol->kyc(), $peppol->events(),
$peppol->lookup(), $peppol->validate(), and the client itself
| Method | Endpoint | Purpose |
|---|---|---|
account()->get() |
GET /v1/account |
Profile, key prefixes, usage totals |
account()->createKey(array $params = []) |
POST /v1/account/keys |
Mint an extra key; the full key is returned once |
account()->revokeKey(string $prefix) |
DELETE /v1/account/keys/{prefix} |
Revoke a key permanently |
account()->listAuditEvents(array $params = []) |
GET /v1/account/audit |
Audit events across every workspace joined |
account()->getUsage(array $params = []) |
GET /v1/account/usage |
Daily documents and API calls |
kyc()->get() |
GET /v1/kyc |
Status, attestation, documents, and what is still missing |
kyc()->uploadDocument(array $params) |
POST /v1/kyc/documents |
Upload one document as base64 (10 MB decoded max) |
kyc()->submit(array $params) |
POST /v1/kyc/submit |
Submit the workspace for review |
events()->list(array $params = []) |
GET /v1/events |
The workspace event feed behind webhook deliveries, newest first |
lookup()->participant(string $peppolId, array $params = []) |
GET /v1/lookup/{peppol_id} |
Resolve a participant through its SMP: document types and AS4 endpoints |
lookup()->dns(string $peppolId, array $params = []) |
GET /v1/lookup/{peppol_id}/dns |
The NAPTR/SMP DNS layer only |
validate()->document(array $params) |
POST /v1/validate |
Check a payload without creating or sending anything |
health() |
GET /v1/health |
Liveness probe with per-dependency checks |
signup(array $params) |
POST /v1/signup |
Create an account and get a sandbox key. Unauthenticated: no Authorization header is sent |
The client also exposes request() and requestRaw(), the same transport
the resource methods use, so an endpoint the SDK does not wrap yet is still
one call away with the auth, retry, timeout, and error handling described
above:
$result = $peppol->request('GET', '/v1/documents', ['company_id' => 'com_abc123', 'limit' => 10]);
Types and static analysis
The SDK supports PHP 7.4, so it has no typed value objects: requests and
responses are plain arrays. The types are in the PHPDoc instead. The array
shapes are generated from the API's OpenAPI spec into
PeppolSh\Generated\Types, and regenerated whenever the spec changes. There
is one alias for each schema of the spec (Company, Workspace, …), and each
operation has <Operation>Params (JSON body), <Operation>Query (query
parameters), and <Operation>Response where they apply — for example
SendDocumentParams and SendDocumentResponse. The resource methods use
these aliases in their @param and @return tags.
PhpStorm reads these shapes for key completion, and PHPStan checks your arrays against them. Because the types are PHPDoc and not syntax, they work the same on every supported PHP version. To use an alias in your own code, import it in the docblock of your class:
/** * @phpstan-import-type SendDocumentParams from \PeppolSh\Generated\Types */ final class InvoiceMapper { /** * @return SendDocumentParams */ public function toPeppol(Invoice $invoice): array { // ... } }
The API can add fields at any time: a shape lists the known keys only.
Two quirks of generation: response keys are mostly optional, because the spec
marks few of them required — check them or use ??. Request fields with a
schema default (type, currency, unit, payment_means.method) are filled
in by the API when you leave them out.
Webhooks
Register an endpoint and the API posts document events to it — no polling.
$hook = $peppol->webhooks()->create([ 'url' => 'https://example.com/hooks/peppol', 'events' => ['document.delivered', 'document.failed', 'credits.low'], ]); echo $hook['secret']; // whsec_… — shown once, store it now
Each delivery carries X-Peppol-Signature-V2 (an HMAC-SHA256 signature over
the timestamp and the raw body), X-Peppol-Timestamp, X-Peppol-Event, and
X-Peppol-Delivery-Id. PeppolSh\Webhook::verify() checks the signature in
constant time, rejects a timestamp more than $tolerance seconds (default
300) from the local clock, and returns the decoded event.
use PeppolSh\Exception\SignatureVerificationException; use PeppolSh\Webhook; $rawBody = file_get_contents('php://input'); try { $event = Webhook::verify( $rawBody, $_SERVER['HTTP_X_PEPPOL_SIGNATURE_V2'] ?? '', getenv('PEPPOL_WEBHOOK_SECRET') ); } catch (SignatureVerificationException $e) { http_response_code(400); exit; } if ($event['type'] === 'document.delivered') { // $event['data']['document_id'], $event['data']['status'] } http_response_code(204);
Always pass the body bytes as received — re-encoding a decoded array breaks
the HMAC — and deduplicate on X-Peppol-Delivery-Id, which is stable across
retries. During a secret rotation the header carries one signature for each
active secret, and one match is sufficient. Webhook::verifySignature() does
the check without decoding the body. The header names are constants on the
class (Webhook::SIGNATURE_HEADER, Webhook::DELIVERY_ID_HEADER, …).
In Laravel, use the peppol.webhook middleware instead.
The rotation overlap and the retry schedule are documented at
https://peppol.sh/docs. webhooks()->test($id) sends a synthetic event to
check your receiver; webhooks()->listDeliveries($id) shows every attempt.
Testing your integration
PeppolSh\Testing\FakeHttpClient is a transport that sends nothing. It
replies from a queue and records every request, so your tests can run without
the network.
use PeppolSh\Client; use PeppolSh\Testing\FakeHttpClient; $http = new FakeHttpClient([ FakeHttpClient::json(['id' => 'doc_abc123', 'status' => 'queued', 'url' => '/v1/documents/doc_abc123'], 202), FakeHttpClient::error(404, 'document_not_found', 'Document not found', 'not_found'), ]); $peppol = new Client('ps_test_x', ['http_client' => $http]); $peppol->documents()->send(['company_id' => 'com_abc123', 'number' => 'INV-2026-001']); $http->assertSent('POST', '/v1/documents'); $http->assertRequestCount(1); $sent = $http->getLastRequest()->getJson(); // the request body, decoded
A queue entry is an HttpResponse, a Throwable (it is thrown, for example a
ConnectionException), or a callable that receives the RecordedRequest and
returns an HttpResponse. Entries are used in order; a request with an empty
queue throws a LogicException. Add entries later with push().
| Assertion or accessor | What it does |
|---|---|
assertSent(string $method, string $path) |
Fails when no request with this method and path was sent. |
assertRequestCount(int $expected) |
Fails when the number of requests is different. |
assertNothingSent() |
Fails when a request was sent. |
getRequests(), getLastRequest() |
The recorded requests: getMethod(), getPath(), getQuery(), getHeader(), getJson(). |
The assertions throw a PHPUnit failure when PHPUnit is loaded, and a
RuntimeException in other test runners. To test a webhook receiver, sign a
fixture body with Webhook::signatureHeader($rawBody, $secret). In Laravel,
use Peppol::fake().
Requirements
PHP 7.4 or later (tested on 7.4 to 8.5) with ext-curl and ext-json. Zero
other runtime dependencies. The Laravel integration works with Laravel 8 to
13 and loads only when Laravel is installed. To use a different HTTP stack,
or to go through a proxy, pass your own transport with the http_client
option.
Documentation
- Guides and API reference — https://peppol.sh/docs
- OpenAPI spec — https://api.peppol.sh/v1/openapi.json
- Changelog — CHANGELOG.md
Contributing
This repository is a read-only mirror. The SDK is developed in a private monorepo next to the API and its OpenAPI contract, so an API change and its client update land in one commit and are tested together. Pull requests are still welcome here: maintainers apply accepted changes upstream, and they flow back with the next mirror push — your change ships, but not under the commit hash you pushed, and mirror pushes rewrite history, so do not build long-lived branches here. Issues and feature requests belong on this repository.
License
MIT © e-invoice bv