A clean, modern PHP SDK for the eCourier API, built with Saloon.
Send and receive electronic documents, such as invoices and credit notes, from your PHP application. The SDK wraps the full eCourier REST API v1 and gives you typed responses, automatic pagination, and clear exceptions for every error case.
composer require ecourier/ecourierRequirements: PHP 8.3+
Instantiate the connector with your API key. The key prefix determines the mode: pk_test_ for test, pk_live_ for production.
use Ecourier\EcourierConnector;
// Test mode
$ecourier = new EcourierConnector(apiKey: 'pk_test_your_key_here');
// Production
$ecourier = new EcourierConnector(apiKey: 'pk_live_your_key_here');All requests are authenticated automatically via Authorization: Bearer — you never touch headers yourself.
The SDK is organized into four resources, accessible as methods on the connector.
| Resource | Method | Covers |
|---|---|---|
| Companies | $ecourier->companies() |
List, create, update, delete, and inspect companies |
| Documents | $ecourier->documents() |
Send, receive, and inspect documents, such as invoices and credit notes |
| Participants | $ecourier->participants() |
List, create, update, delete, and inspect participants |
| Lookup | $ecourier->lookup() |
Look up network participants by channel, scheme, and ID |
use Ecourier\Enums\Channel;
$companies = $ecourier->companies()
->list(
channel: Channel::Peppol,
country: 'DK',
signed: false,
perPage: 50,
)
->collect();$company = $ecourier->companies()->find('comp_01abc');
echo $company->name; // Acme Danmark A/S
echo $company->companyNo; // 12345678
echo $company->mode; // Mode::Livefind() returns a typed CompanyData DTO. If you need the raw Response object instead, use get():
$response = $ecourier->companies()->get('comp_01abc');
$response->status(); // 200
$response->json(); // raw arrayuse Ecourier\Data\CompanyAuthorisationSignerData;
use Ecourier\Data\CreateCompanyData;
$company = $ecourier->companies()->create(new CreateCompanyData(
name: 'Acme Danmark A/S',
country: 'DK',
companyNo: '12345678',
signer: new CompanyAuthorisationSignerData(
firstName: 'Ada',
lastName: 'Lovelace',
title: 'CEO',
),
));$company = $ecourier->companies()->update(
company: '0101knwp96k3ggvkra831yrd74zh',
name: 'Acme Danmark A/S',
);$response = $ecourier->companies()->delete('0101knwp96k3ggvkra831yrd74zh');
$response->status(); // 204Documents are the core of eCourier — they represent invoices and credit notes moving through the network.
Build a typed InvoiceDocumentData payload and submit it to a specific channel. eCourier converts it to the correct XML schema automatically.
use Ecourier\Data\Invoice\InvoiceDocumentData;
use Ecourier\Data\Invoice\InvoiceLineData;
use Ecourier\Data\Invoice\InvoicePartyData;
use Ecourier\Data\Invoice\InvoiceTotalsData;
use Ecourier\Data\Invoice\ParticipantIdentifier;
use Ecourier\Enums\Channel;
use Ecourier\Enums\Currency;
use Ecourier\Enums\DocumentType;
use Ecourier\Enums\IdentifierScheme;
$invoice = new InvoiceDocumentData(
type: DocumentType::Invoice,
id: 'INV-2024-001',
issueDate: '2024-06-01',
currency: Currency::DKK,
supplier: new InvoicePartyData(
participant: new ParticipantIdentifier(IdentifierScheme::DK_CVR, '12345678'),
),
customer: new InvoicePartyData(
participant: new ParticipantIdentifier(IdentifierScheme::DK_CVR, '87654321'),
),
lines: [
new InvoiceLineData(id: '1'),
],
totals: new InvoiceTotalsData(
subtotalAmount: '1000.00',
taxAmount: '250.00',
totalAmount: '1250.00',
),
);
$document = $ecourier->documents()->sendJson(Channel::Peppol, $invoice);
echo $document->id; // 01kmkdaf55vrrecfy70180tpr6
echo $document->e2eMessageUuid; // ddc3b3ef-cbd4-4630-9d65-896b3e1abc61Note:
sendJson()returns the accepted document ID and network message UUID. Use webhooks or pollfind()to track delivery.
If you need full control over the XML schema, send the raw UBL document directly. All routing headers are required.
use Ecourier\Enums\Channel;
use Ecourier\Enums\IdentifierScheme;
$xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<!-- your UBL invoice XML here -->
</Invoice>
XML;
$document = $ecourier->documents()->sendXml(
xml: $xml,
channel: Channel::Peppol,
senderScheme: IdentifierScheme::DK_CVR,
senderId: '12345678',
recipientScheme: IdentifierScheme::GLN,
recipientId: '5790000123456',
);use Ecourier\Enums\Channel;
use Ecourier\Enums\Direction;
use Ecourier\Enums\DocumentStatus;
use Ecourier\Enums\DocumentType;
use Ecourier\Enums\Mode;
use Ecourier\Enums\SubmissionFormat;
$document = $ecourier->documents()->find('doc_01xyz');
$document->id; // '01kmkdaf55vrrecfy70180tpr6'
$document->status; // DocumentStatus::Delivered
$document->channel; // Channel::NemHandel
$document->mode; // Mode::Live
$document->direction; // Direction::Send
$document->type; // DocumentType::Invoice
$document->submissionFormat; // SubmissionFormat::JSON
$document->sender->scheme; // IdentifierScheme::DK_CVR
$document->recipient->identifier; // '5790000123456'
$document->company->name; // 'Acme Danmark A/S'list() returns a lazy paginator — see Pagination for the full API.
Get the raw XML of a delivered document:
$response = $ecourier->documents()->contentAsXml('doc_01xyz');
$xml = $response->body(); // raw XML stringGet an HTML or PDF rendering (experimental):
$html = $ecourier->documents()->renderAsHtml('doc_01xyz')->body();
$pdf = $ecourier->documents()->renderAsPdf('doc_01xyz')->body();
file_put_contents('invoice.pdf', $pdf);For received documents, mark them as delivered once your application has finished processing them:
$document = $ecourier->documents()->markDelivered('doc_01xyz');
echo $document->status; // DocumentStatus::DeliveredParticipants are how a company registers to send and receive documents on a channel (NemHandel or Peppol).
use Ecourier\Enums\Channel;
use Ecourier\Enums\IdentifierScheme;
$participants = $ecourier->participants()
->list(
companyId: '0101knwp96k3ggvkra831yrd74zh',
scheme: IdentifierScheme::GLN,
channel: Channel::NemHandel,
perPage: 50,
)
->collect();$participant = $ecourier->participants()->find('0101knwp96k3ggvkra831yrd77ghi');
echo $participant->company->name; // Acme Danmark A/S
echo $participant->fullIdentifier; // 0088:5790000435944
echo $participant->mode; // Mode::Livefind() returns a typed ParticipantData DTO. If you need the raw Response object instead, use get():
$response = $ecourier->participants()->get('0101knwp96k3ggvkra831yrd77ghi');
$response->status(); // 200
$response->json(); // raw arrayLeave identifier unset (or null) when the scheme is GLN — a GLN is assigned automatically.
use Ecourier\Data\CreateParticipantData;
use Ecourier\Enums\Channel;
use Ecourier\Enums\IdentifierScheme;
$participant = $ecourier->participants()->create(new CreateParticipantData(
companyId: '0101knwp96k3ggvkra831yrd74zh',
scheme: IdentifierScheme::DK_CVR,
channels: [Channel::NemHandel],
identifier: '12345678',
));Update replaces the full set of channels the participant is registered on — channels not listed are removed.
use Ecourier\Enums\Channel;
$participant = $ecourier->participants()->update(
participant: '0101knwp96k3ggvkra831yrd77ghi',
channels: [Channel::NemHandel, Channel::Peppol],
);$response = $ecourier->participants()->delete('0101knwp96k3ggvkra831yrd77ghi');
$response->status(); // 204Look up whether a company is reachable on a given channel.
use Ecourier\Enums\Channel;
use Ecourier\Enums\IdentifierScheme;
$participant = $ecourier->lookup()->findParticipant(
channel: Channel::Peppol,
scheme: IdentifierScheme::GLN,
participantId: '5790000123456',
);
echo $participant->entityName; // GLN Denmark
echo $participant->mode->value; // Live
echo $participant->orgNo; // 9999796418186eCourier sends webhooks for document lifecycle events. Parse the raw request body with WebhookEventFactory to get a typed event DTO — no need to know the event type up front.
use Ecourier\Data\Webhook\WebhookEventFactory;
$event = WebhookEventFactory::fromRequestBody($request->getContent());fromArray() is also available if you've already decoded the JSON body yourself.
Every event type maps to a subclass of the abstract WebhookEvent. Document events (Document.Send.Created, Document.Send.Delivered, Document.Send.Failed, Document.Receive.Created, Document.Receive.Ready, Document.Receive.Delivered) map to DocumentWebhook, which carries the same DocumentData DTO used by documents()->find():
use Ecourier\Data\Webhook\DocumentWebhook;
use Ecourier\Enums\WebhookEventType;
if ($event instanceof DocumentWebhook) {
echo $event->event; // WebhookEventType::DocumentSendDelivered
echo $event->eventId; // evt_01hxyz
echo $event->occurredAt->format(DATE_ATOM);
echo $event->document->id; // 01kmkdaf55vrrecfy70180tpr6
echo $event->document->status->value; // Delivered
}
if ($event->event === WebhookEventType::DocumentReceiveCreated) {
// a new inbound document has arrived
}An unknown event value throws InvalidArgumentException; malformed JSON throws JsonException.
list() returns a DocumentsPaginator — a lazy iterator built on Saloon's PagedPaginator. Pages are fetched from the API on demand, one at a time, as you consume items.
The simplest approach. Each page is fetched automatically when the previous one is exhausted:
foreach ($ecourier->documents()->list()->items() as $document) {
echo $document->id;
}If you're in a Laravel application, collect() wraps the paginator in a LazyCollection, giving you the full collection API without loading everything into memory:
$ecourier->documents()->list(perPage: 50)
->collect()
->each(function (DocumentData $document) {
// processed one at a time, page by page
});
// Chain collection methods — pages are fetched as items are consumed
$ecourier->documents()->list()
->collect()
->filter(fn ($doc) => $doc->company?->name === 'Acme Danmark A/S')
->each(fn ($doc) => ProcessDocument::dispatch($doc));Use setMaxPages(1) to stop after a single page. Exactly one HTTP request is made:
$documents = $ecourier->documents()
->list(perPage: 25)
->setMaxPages(1)
->collect()
->all();Combine setStartPage() and setMaxPages() to jump to any page:
$documents = $ecourier->documents()
->list(perPage: 25)
->setStartPage(3)
->setMaxPages(1)
->collect()
->all();All filters are applied at the API level — only matching documents are returned:
use Ecourier\Enums\DocumentStatus;
use Ecourier\Enums\Channel;
use Ecourier\Enums\Direction;
use Ecourier\Enums\Sort;
$ecourier->documents()
->list(
status: DocumentStatus::Delivered,
channel: Channel::Peppol,
companyId: '0101knwp96k3ggvkra831yrd74zh',
direction: Direction::Send,
sort: Sort::CreatedAtDesc,
perPage: 50,
)
->collect()
->each(fn ($doc) => ...);The SDK throws typed exceptions for every error case — no checking status codes manually.
| Exception | HTTP Status | When |
|---|---|---|
AuthenticationException |
401 | Invalid or missing API key |
NotFoundException |
404 | Resource does not exist |
ValidationException |
422 | Invalid request payload |
EcourierException |
5xx / other | Unexpected server errors |
All exceptions extend EcourierException, so you can catch broadly or narrowly:
use Ecourier\Exceptions\AuthenticationException;
use Ecourier\Exceptions\EcourierException;
use Ecourier\Exceptions\NotFoundException;
use Ecourier\Exceptions\ValidationException;
try {
$document = $ecourier->documents()->find('doc_missing');
} catch (NotFoundException $e) {
echo $e->getMessage();
} catch (ValidationException $e) {
foreach ($e->getErrors() as $field => $messages) {
echo "{$field}: " . implode(', ', $messages) . PHP_EOL;
}
} catch (AuthenticationException $e) {
// bad API key
} catch (EcourierException $e) {
$e->getResponse()->status();
}All typed fields use PHP backed enums, giving you IDE autocomplete and preventing invalid values at the type level.
| Enum | Values |
|---|---|
DocumentStatus |
Pending, Ready, Delivered, Failed |
DocumentType |
Invoice, CreditNote, ApplicationResponse, EndUserStatisticsReport, TransactionStatisticsReport, Other |
Direction |
Send, Receive |
Channel |
Peppol, NemHandel |
Mode |
Live, Test |
SubmissionFormat |
XML, GOBL, JSON |
Sort |
CreatedAt, CreatedAtDesc |
Currency |
EUR, DKK, USD, GBP, and 29 others (ISO 4217) |
IdentifierScheme |
DK_CVR, GLN, EU_VAT, and 80+ others |
TaxCategoryCode |
S, AA, Z, E, AE, K, G, O, L, M |
PaymentMeansCode |
CreditTransfer, DebitTransfer, PaymentToAccount |
AccountSchemeId |
IBAN, DK_BBAN |
Enums serialize to their wire value automatically when used in requests. When the API returns an unrecognised value for an optional enum field, the SDK maps it to null rather than throwing.
The SDK is built on Saloon, which ships with a first-class mock client. No HTTP requests are made in your tests.
use Ecourier\EcourierConnector;
use Ecourier\Enums\Direction;
use Ecourier\Enums\DocumentStatus;
use Ecourier\Requests\Documents\GetDocumentRequest;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
$mockClient = new MockClient([
GetDocumentRequest::class => MockResponse::make(
body: ['id' => 'doc_01xyz', 'status' => 'Delivered', 'direction' => 'Send'],
status: 200,
),
]);
$ecourier = new EcourierConnector(apiKey: 'pk_test_fake');
$ecourier->withMockClient($mockClient);
$document = $ecourier->documents()->find('doc_01xyz');
expect($document->status)->toBe(DocumentStatus::Delivered);
expect($document->direction)->toBe(Direction::Send);$mockClient->assertSent(GetDocumentRequest::class);
$mockClient->assertNotSent(SendDocumentAsJsonRequest::class);
$mockClient->assertSentCount(1);use Ecourier\Requests\Companies\GetCompanyRequest;
$mockClient = new MockClient([
GetCompanyRequest::class => MockResponse::make(
body: ['message' => 'Not found.'],
status: 404,
),
]);
$ecourier->withMockClient($mockClient);
// Throws NotFoundException
$ecourier->companies()->find('comp_missing');All resources return typed DTOs with readonly properties.
| Property | Type |
|---|---|
$id |
string |
$name |
string |
$country |
string |
$companyNo |
string |
| Property | Type |
|---|---|
$id |
string |
$name |
string |
$mode |
Mode |
$companyNo |
string |
$createdAt |
DateTimeImmutable |
$updatedAt |
DateTimeImmutable |
$parentId |
?string |
$children |
array |
$country |
string |
$authorisation |
?CompanyAuthorisationData |
$participants |
CompanyParticipantData[] |
| Property | Type | Required |
|---|---|---|
$name |
string |
Yes |
$country |
string |
Yes |
$companyNo |
string |
Yes |
$signer |
CompanyAuthorisationSignerData |
Yes |
$parentId |
?string |
No |
Shared by document endpoints (find, markDelivered, list items) and the document payload of DocumentWebhook — the same shape everywhere, so fields that only apply to one context are optional.
| Property | Type |
|---|---|
$id |
string |
$status |
DocumentStatus |
$channel |
Channel |
$mode |
?Mode |
$direction |
Direction |
$type |
DocumentType |
$submissionFormat |
?SubmissionFormat |
$sender |
?DocumentParticipantData |
$recipient |
?DocumentParticipantData |
$latestE2eMessageUuid |
?string |
$latestE2eTransmissionId |
?string |
$company |
?DocumentCompanyData |
$dashboardUrl |
?string |
$createdAt |
?DateTimeImmutable |
$transmittedAt |
?DateTimeImmutable |
$ubl |
?DocumentUblData |
| Property | Type |
|---|---|
$fullIdentifier |
string |
$scheme |
IdentifierScheme |
$schemeIcd |
string |
$identifier |
string |
| Property | Type |
|---|---|
$id |
?string |
$uuid |
?string |
$profileId |
?string |
$customizationId |
?string |
| Property | Type |
|---|---|
$id |
string |
$company |
ParticipantCompanyData |
$mode |
Mode |
$scheme |
IdentifierScheme |
$schemeIcd |
string |
$identifier |
string |
$fullIdentifier |
string |
$channels |
Channel[] |
$createdAt |
DateTimeImmutable |
$updatedAt |
DateTimeImmutable |
| Property | Type |
|---|---|
$id |
string |
$name |
string |
| Property | Type | Required |
|---|---|---|
$companyId |
string |
Yes |
$scheme |
IdentifierScheme |
Yes |
$channels |
Channel[] |
Yes |
$identifier |
?string |
No — leave null for GLN |
| Property | Type |
|---|---|
$channel |
Channel |
$mode |
Mode |
$entityName |
string |
$country |
?string |
$registrationDate |
string |
$orgNo |
string |
$registryUrl |
string |
| Property | Type |
|---|---|
$id |
string |
$e2eMessageUuid |
string |
Base fields shared by every webhook event DTO.
| Property | Type |
|---|---|
$eventId |
string |
$event |
WebhookEventType |
$occurredAt |
DateTimeImmutable |
$version |
int |
$teamId |
string |
$mode |
Mode |
$companyId |
string |
Extends WebhookEvent. Returned by WebhookEventFactory for all Document.* events.
| Property | Type |
|---|---|
$document |
DocumentData |
| Property | Type | Required |
|---|---|---|
$type |
DocumentType |
Yes |
$id |
string |
Yes |
$issueDate |
string |
Yes |
$currency |
Currency |
Yes |
$supplier |
InvoicePartyData |
Yes |
$customer |
InvoicePartyData |
Yes |
$lines |
InvoiceLineData[] |
Yes |
$totals |
InvoiceTotalsData |
Yes |
$uuid |
?string |
No |
$dueDate |
?string |
No |
$orderReference |
?string |
No |
$payment |
?InvoicePaymentData |
No |
| Property | Type | Required |
|---|---|---|
$id |
string |
Yes |
$name |
?string |
No |
$description |
?string |
No |
$quantity |
?string |
No |
$unitCode |
?string |
No |
$unitPrice |
?string |
No |
$lineTotal |
?string |
No |
$taxCategory |
?InvoiceTaxCategoryData |
No |
$itemId |
?string |
No |
$sellersItemId |
?string |
No |
$buyersItemId |
?string |
No |
Every resource method has a companion that returns the raw Saloon\Http\Response instead of a DTO. Use get() instead of find():
$response = $ecourier->companies()->get('comp_01abc');
$response->status(); // 200
$response->headers(); // response headers
$response->body(); // raw body string
$response->json(); // decoded arrayIf you need to bypass the resource layer entirely:
use Ecourier\Requests\Documents\GetDocumentRequest;
$response = $ecourier->send(new GetDocumentRequest('doc_01xyz'));composer testCode style:
vendor/bin/pint --test # check
vendor/bin/pint # fix- Bart Potmalnik — original author
- All Contributors
MIT — see LICENSE.