Search by

webatvantage / bpost-api-library

tom.sixJente

Modern PHP 8.5 client for the full bpost API: Shipping Manager orders and labels, parcel announcement and tracking, and the Geolocator.

Package info

github.com/webatvantage/bpost-api-library

pkg:composer/webatvantage/bpost-api-library

Statistics

Installs: 285

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0-beta.3 2026-10-08 09:18 UTC

This package is auto-updated.

Last update: 2026-10-08 09:18:15 UTC


README

About

bpost API library is a PHP client for the bpost APIs: the Shipping Manager, the Geolocator, parcel announcement and tracking.

Built against the bpack integration manual v3.3.35 and bpost's own SHM API v5 example set. Upgrading from 1.x? See MIGRATION.md.

Requirements

PHP 8.5 or newer.

Installation

composer require webatvantage/bpost-api-library

Usage

Every bpost service is a separate API with its own host and credentials. Construct the one you need, or the central client if you use more than one.

use Webatvantage\Bpost\Api\BpostApiClient;
use Webatvantage\Bpost\Api\BpostApiConfig;
use Webatvantage\Bpost\Api\Shm\ShmApiConfig;
use Webatvantage\Bpost\Api\Geo\GeoApiConfig;
use Webatvantage\Bpost\Api\Parcel\ParcelApiConfig;

$bpost = new BpostApiClient(new BpostApiConfig(
    shm: new ShmApiConfig(accountId: '123456', passphrase: 'MyGreatApiPassword'),
    geo: new GeoApiConfig(partner: '123456', apiKey: 'xxxxxxxx'),
    parcel: new ParcelApiConfig(accountId: '123456', password: '...'),
));

$bpost->shm()->orders()->create($order);
$bpost->geo()->servicePoints()->nearest(zone: '1000')->get();

Reaching a service you did not configure throws MissingConfigurationException rather than failing later at the HTTP layer.

Shipping Manager

Building an order

use Webatvantage\Bpost\Api\Enums\Language;
use Webatvantage\Bpost\Api\Shm\DataObjects\{Order, Box, Sender, Receiver, Address};
use Webatvantage\Bpost\Api\Shm\DataObjects\Box\AtHome;
use Webatvantage\Bpost\Api\Shm\DataObjects\Options\{Insured, Messaging};
use Webatvantage\Bpost\Api\Shm\Enums\{InsuranceAmount, Product};

$sender = new Sender()
    ->name('Business Solutions Team')
    ->company('bpost - bpack')
    ->address(
        new Address()
            ->streetName('Muntcentrum')->number(1)
            ->postalCode(1000)->locality('Brussel')->countryCode('BE'),
    )
    ->emailAddress('esolutions@bpost.be')
    ->phoneNumber('0032499123456');

$receiver = new Receiver()
    ->name('Alma van Appel')
    ->address(
        new Address()
            ->streetName('Rue du Grand Duc')->number(13)
            ->postalCode(1040)->locality('Etterbeek')->countryCode('BE'),
    )
    ->emailAddress('alma@example.com');

$order = new Order('ref_0123456789')
    ->costCenter('Webshop')
    ->addLine('Article description', 1)
    ->addBox(
        new Box()
            ->sender($sender)
            ->deliverTo(
                new AtHome(Product::Bpack24hPro)
                    ->weight(2000)
                    ->receiver($receiver)
                    ->withOption(Messaging::infoNextDay(Language::EN)->email('alma@example.com'))
                    ->withOption(Insured::additional(InsuranceAmount::UpTo2500)),
            )
            ->remark('Handle with care'),
    );

Other delivery methods take the place of AtHome: AtBpost for a pick-up point, At247 for a parcel locker, International for an address abroad and AtIntlPugo for a pick-up point abroad.

Lengths the manual documents are checked when you set them, so an over-long name throws InvalidLengthException rather than coming back as a schema violation from bpost. An email address is also checked for shape and throws InvalidPatternException, since bpost accepts a malformed one and then silently never sends the message. Reading is not held to either: an order bpost already holds comes back as it is, however far outside the documented limits it falls.

Orders

use Webatvantage\Bpost\Api\Shm\Enums\BoxStatus;

$shm = $bpost->shm();

$shm->orders()->create($order);
$order = $shm->orders()->get('ref_0123456789');
$shm->orders()->updateStatus('ref_0123456789', BoxStatus::Open);

foreach ($order->boxes as $box) {
    $box->status;                 // BoxStatus
    $box->barcode;
    $box->deliveryBox->product;   // Product
}

Labels

use Webatvantage\Bpost\Api\Shm\Enums\{LabelFormat, LabelOutput};

$labels = $shm->labels()->forOrder('ref_0123456789', LabelFormat::A6, LabelOutput::Pdf)->get();

foreach ($labels as $label) {
    file_put_contents($label->barcode() . '.pdf', $label->contents());
}

// One box again, by barcode
$labels = $shm->labels()->forBox($barcode)->get();

// Several orders at once, with return labels
$labels = $shm->labels()->inBulk(['ref_1', 'ref_2'], withReturnLabels: true)->get();

// ZPL, which bpost only produces in A6
$labels = $shm->labels()->forOrder('ref_1', LabelFormat::A6, LabelOutput::Zpl)->get();

Product configuration

What this account may actually sell. Worth reading before building an order, since bpost refuses a product the account is not configured for.

$configuration = $shm->productConfiguration()->get();

$configuration->offers(Product::Bpack24hPro);   // bool

Parcel: announcement and tracking

The route for anyone printing their own labels. Announce the parcel before it reaches bpost, then follow it afterwards.

This service has its own Sender, Receiver and Address — they are not the Shipping Manager's and cannot be swapped for them.

use Webatvantage\Bpost\Api\Parcel\DataObjects\{Announcement, Sender, Receiver, Address, ContactDetail};
use Webatvantage\Bpost\Api\Parcel\DataObjects\Options\Flags\Signature;

$parcel = $bpost->parcel();

$sender = new Sender()
    ->name('bpost - bpack')
    ->address(
        new Address()
            ->streetName('Muntcentrum')->houseNumber(1)
            ->postalCode(1000)->city('Brussel')->countryCode('BE'),
    )
    ->contactDetail(new ContactDetail()->emailAddress('esolutions@bpost.be'));

$receiver = new Receiver()
    ->name('Alma van Appel')
    ->address(
        new Address()
            ->streetName('Rue du Grand Duc')->houseNumber(13)
            ->postalCode(1040)->city('Etterbeek')->countryCode('BE'),
    )
    ->contactDetail(new ContactDetail()->emailAddress('alma@example.com'));

$feedback = $parcel->announcements()->create(
    new Announcement('323212345689100101119030', $sender, $receiver, weightInGrams: 250)
        ->customerReference('order-123')
        ->costCenter('Webshop')
        ->withOption(new Signature()),
);

if ($feedback->hasErrors()) {
    // A 201 does not mean bpost accepted it cleanly
    $feedback->errors;
}
$tracking = $parcel->tracking()->get('323212345659900040669030');

$tracking->isDelivered();
$tracking->latestState()?->stateDescription;   // "DistributedNormally - regular"
$tracking->trackingUrl();                      // the page to show a customer
$tracking->pickupPoint?->name;                 // where it is waiting, if it is

It spells the address fields its own way too — houseNumber, boxNumber and city, where the Shipping Manager says number, box and locality — and puts the email and phone in a ContactDetail rather than on the party itself.

Geolocator (pick-up points, parcel points and parcel lockers)

use Webatvantage\Bpost\Api\Geo\GeoApiClient;
use Webatvantage\Bpost\Api\Geo\GeoApiConfig;
use Webatvantage\Bpost\Api\Geo\Enums\PointType;

$geo = new GeoApiClient(new GeoApiConfig(
    partner: '999999',      // your bpost account id, activated for the Geolocator
    apiKey: 'xxxxxxxx',     // the x-api-key bpost issues per account, request it from esolutions@bpost.be
    appId: 'A001',          // optional
));

Which host, and whether you need a key

The key goes with the host. pudo.bpost.cloud, the default since 2.0 and the one bpost documents, rejects a request without x-api-key. The older pudo.bpost.be still answers and ignores the header entirely, so an integration pointed at it needs no key and can leave it out:

$geo = new GeoApiClient(new GeoApiConfig(
    partner: '999999',
    baseUri: 'https://pudo.bpost.be',
));

Left out, the header is not sent at all rather than sent empty. On the default host that means a missing key comes back as a rejected request rather than a missing argument, so it is worth checking you passed one.

Nearest points

use Webatvantage\Bpost\Api\Enums\Language;
use Webatvantage\Bpost\Api\Enums\Weekday;

$points = $geo->servicePoints()
    ->nearest(zone: '1000', street: 'Grand Place', number: '3')
    ->types(PointType::PostOffice, PointType::PostPoint)
    ->language(Language::FR)
    ->withDetails()   // ask for opening hours
    ->limit(5)
    ->get();

foreach ($points as $point) {
    $point->name;
    $point->distance;               // metres
    $point->openingHours->for(Weekday::Monday)?->amOpen;
}

The Geolocator only offers NL and FR; Language carries EN and DE for the Shipping Manager's messaging, and passing either here throws InvalidValueException rather than being ignored on bpost's side. Language::forGeolocator() returns the set, so a language choice in your own code can be built from it rather than hardcoded:

foreach (Language::forGeolocator() as $language) {
    $language->value;   // 'NL', 'FR'
}

One point's details

$point = $geo->servicePoints()->details('220000', PointType::PostOffice)->get();

Every point in a country

$points = $geo->servicePoints()
    ->all()
    ->country('BE')
    ->type(PointType::ParcelLocker)
    ->get();

The URL of bpost's own details page

$url = $geo->servicePoints()->pageUrl('220000', PointType::PostOffice);

Logging

Pass a PSR-3 logger and every call this client makes is written to it, request and response alike.

// The config from Usage above, and any PSR-3 logger.
$bpost = new BpostApiClient($config, logger: $logger);

Records carry the level of what bpost answered, so your own logger's threshold decides how much of the detail survives.

record level
the request, and the transfer statistics debug
a 2xx response info
a 3xx response notice
a 4xx response — a refused order, a bad barcode error
a 5xx response — bpost is having trouble critical

A logger set to warning therefore keeps what bpost refused — a 4xx, a 5xx, and a call that never reached a status at all — and drops the rest.

Marking the calls worth a record

Severity is one axis and it is the same for every call. Noise is the other, and it is per call: withLogging() and withoutLogging() mark the calls you want at four levels — the whole client, one service, one resource, or one call. The narrowest setting wins.

// Everything, including services you have not reached yet.
$bpost->withLogging();

// One service.
$bpost->shm()->withLogging();

// One resource. It is built fresh each time, so this reaches nothing else.
$bpost->shm()->orders()->withLogging()->get('order-123');

// One call, on a request you were narrowing anyway.
$bpost->geo()->servicePoints()->nearest(zone: '1000')->withLogging()->get();

The mark travels with the request as the BpostApiConfig::LOGGING_OPTION_NAME Guzzle option, and nothing here acts on it: a handler is where a record is made, so a handler is where you decide whether to make one. Read it off $options in one of your own and keep the calls that asked for a record. The response is there too, so keeping every refusal whatever the call asked for is the same handler with one more condition:

use GuzzleHttp\TransferStats;
use GuzzleLogMiddleware\Handler\HandlerInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use Webatvantage\Bpost\Api\BpostApiConfig;

readonly class LoggableHandler implements HandlerInterface
{
    public function __construct(private HandlerInterface $handler) {}

    public function log(
        LoggerInterface $logger,
        RequestInterface $request,
        ?ResponseInterface $response = null,
        ?Throwable $exception = null,
        ?TransferStats $stats = null,
        array $options = [],
    ): void {
        $shouldBeLogged = $options[BpostApiConfig::LOGGING_OPTION_NAME] ?? false;

        if ($shouldBeLogged === true)
        {
            $this->handler->log($logger, $request, $response, $exception, $stats, $options);
        }
    }
}

Your own handler

A handler is also what a record is made of — its levels, its truncation, one line instead of an array. Pass yours on the config and it reaches every service:

use GuzzleLogMiddleware\Handler\StringHandler;
use GuzzleLogMiddleware\Handler\LogLevelStrategy\FixedStrategy;

$bpost = new BpostApiClient(
    new BpostApiConfig(
        shm: new ShmApiConfig(accountId: '123456', passphrase: '...'),
        logHandler: new StringHandler(new FixedStrategy('info')),
    ),
    logger: $logger,
);

Pass it to a service client directly as logHandler: if you construct one yourself. Either way the middleware is pushed onto a copy of the handler stack, so the client options you share between services collect one copy rather than one per service — which is what pushing your own LogMiddleware onto a Guzzle handler stack would cost you.

Debugging

When bpost refuses a document, the body is usually the only thing that says why. withDebug() hands you the PSR-7 request and response themselves, at the same four levels:

$debug = function (RequestInterface $request, ResponseInterface $response) {
    echo (string) $request->getBody(), (string) $response->getBody();
};

$bpost->withDebug($debug);                                  // every service
$bpost->shm()->withDebug($debug);                           // one service
$bpost->shm()->orders()->withDebug($debug)->get('order-1'); // one resource
$bpost->geo()->servicePoints()->all()->withDebug($debug)->get();

Pass null to clear it. The callback runs whether or not the response was an error, and before the exception is raised, so it sees the body of a refused request too.

Both ladders are interfaces — Contracts\Loggable and Contracts\Debuggable — so every level spells them the same way.

Contributing

You can read the CONTRIBUTING.md file