Skip to content

Latest commit

 

History

33 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

eCourier PHP SDK

A clean, modern PHP SDK for the eCourier API, built with Saloon.

Tests PHP License

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.


Installation

composer require ecourier/ecourier

Requirements: PHP 8.3+


Getting Started

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.


Resources

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

Companies

List companies

use Ecourier\Enums\Channel;

$companies = $ecourier->companies()
    ->list(
        channel: Channel::Peppol,
        country: 'DK',
        signed: false,
        perPage: 50,
    )
    ->collect();

Get a company

$company = $ecourier->companies()->find('comp_01abc');

echo $company->name;      // Acme Danmark A/S
echo $company->companyNo; // 12345678
echo $company->mode;      // Mode::Live

find() 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 array

Create a company

use 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',
    ),
));

Update a company

$company = $ecourier->companies()->update(
    company: '0101knwp96k3ggvkra831yrd74zh',
    name: 'Acme Danmark A/S',
);

Delete a company

$response = $ecourier->companies()->delete('0101knwp96k3ggvkra831yrd74zh');

$response->status(); // 204

Documents

Documents are the core of eCourier — they represent invoices and credit notes moving through the network.

Send a document as JSON

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-896b3e1abc61

Note: sendJson() returns the accepted document ID and network message UUID. Use webhooks or poll find() to track delivery.

Send a document as raw XML

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',
);

Get a single document

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 documents

list() returns a lazy paginator — see Pagination for the full API.

Retrieve document content

Get the raw XML of a delivered document:

$response = $ecourier->documents()->contentAsXml('doc_01xyz');

$xml = $response->body(); // raw XML string

Render a document

Get 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);

Mark a document as delivered

For received documents, mark them as delivered once your application has finished processing them:

$document = $ecourier->documents()->markDelivered('doc_01xyz');

echo $document->status; // DocumentStatus::Delivered

Participants

Participants are how a company registers to send and receive documents on a channel (NemHandel or Peppol).

List participants

use Ecourier\Enums\Channel;
use Ecourier\Enums\IdentifierScheme;

$participants = $ecourier->participants()
    ->list(
        companyId: '0101knwp96k3ggvkra831yrd74zh',
        scheme: IdentifierScheme::GLN,
        channel: Channel::NemHandel,
        perPage: 50,
    )
    ->collect();

Get a participant

$participant = $ecourier->participants()->find('0101knwp96k3ggvkra831yrd77ghi');

echo $participant->company->name;   // Acme Danmark A/S
echo $participant->fullIdentifier;  // 0088:5790000435944
echo $participant->mode;            // Mode::Live

find() 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 array

Create a participant

Leave 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 a participant

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],
);

Delete a participant

$response = $ecourier->participants()->delete('0101knwp96k3ggvkra831yrd77ghi');

$response->status(); // 204

Lookup

Look 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;      // 9999796418186

Webhooks

eCourier 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.


Pagination

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.

Iterating all pages

The simplest approach. Each page is fetched automatically when the previous one is exhausted:

foreach ($ecourier->documents()->list()->items() as $document) {
    echo $document->id;
}

LazyCollection

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));

First page only

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();

A specific page

Combine setStartPage() and setMaxPages() to jump to any page:

$documents = $ecourier->documents()
    ->list(perPage: 25)
    ->setStartPage(3)
    ->setMaxPages(1)
    ->collect()
    ->all();

Filtering

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) => ...);

Exception Handling

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();
}

Enums

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.


Testing & Mocking

The SDK is built on Saloon, which ships with a first-class mock client. No HTTP requests are made in your tests.

Mocking a response

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);

Asserting requests were sent

$mockClient->assertSent(GetDocumentRequest::class);
$mockClient->assertNotSent(SendDocumentAsJsonRequest::class);
$mockClient->assertSentCount(1);

Mocking exceptions

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');

Data Objects

All resources return typed DTOs with readonly properties.

CompanyListItemData

Property Type
$id string
$name string
$country string
$companyNo string

CompanyData

Property Type
$id string
$name string
$mode Mode
$companyNo string
$createdAt DateTimeImmutable
$updatedAt DateTimeImmutable
$parentId ?string
$children array
$country string
$authorisation ?CompanyAuthorisationData
$participants CompanyParticipantData[]

CreateCompanyData (request payload)

Property Type Required
$name string Yes
$country string Yes
$companyNo string Yes
$signer CompanyAuthorisationSignerData Yes
$parentId ?string No

DocumentData

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

DocumentParticipantData

Property Type
$fullIdentifier string
$scheme IdentifierScheme
$schemeIcd string
$identifier string

DocumentUblData

Property Type
$id ?string
$uuid ?string
$profileId ?string
$customizationId ?string

ParticipantData

Property Type
$id string
$company ParticipantCompanyData
$mode Mode
$scheme IdentifierScheme
$schemeIcd string
$identifier string
$fullIdentifier string
$channels Channel[]
$createdAt DateTimeImmutable
$updatedAt DateTimeImmutable

ParticipantCompanyData

Property Type
$id string
$name string

CreateParticipantData (request payload)

Property Type Required
$companyId string Yes
$scheme IdentifierScheme Yes
$channels Channel[] Yes
$identifier ?string No — leave null for GLN

ParticipantLookupData

Property Type
$channel Channel
$mode Mode
$entityName string
$country ?string
$registrationDate string
$orgNo string
$registryUrl string

SendDocumentData

Property Type
$id string
$e2eMessageUuid string

WebhookEvent (abstract)

Base fields shared by every webhook event DTO.

Property Type
$eventId string
$event WebhookEventType
$occurredAt DateTimeImmutable
$version int
$teamId string
$mode Mode
$companyId string

DocumentWebhook

Extends WebhookEvent. Returned by WebhookEventFactory for all Document.* events.

Property Type
$document DocumentData

InvoiceDocumentData (request payload)

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

InvoiceLineData (request payload)

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

Advanced Usage

Accessing the raw Saloon response

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 array

Sending requests directly

If you need to bypass the resource layer entirely:

use Ecourier\Requests\Documents\GetDocumentRequest;

$response = $ecourier->send(new GetDocumentRequest('doc_01xyz'));

Running Tests

composer test

Code style:

vendor/bin/pint --test  # check
vendor/bin/pint         # fix

Credits

License

MIT — see LICENSE.

About

PHP SDK for the eCourier API

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages