webatvantage / bpost-api-library
Modern PHP 8.5 client for the full bpost API: Shipping Manager orders and labels, parcel announcement and tracking, and the Geolocator.
Requires
- php: ^8.5
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
- composer/ca-bundle: ^1.5
- guzzlehttp/guzzle: ^7.8
- psr/log: ^2.0 || ^3.0
- webatvantage/guzzle-log-middleware: ^2.3.1
Requires (Dev)
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0.4
- phpstan/phpstan-phpunit: ^2.0.13
- phpunit/phpunit: ^13.3
- webatvantage/php-cs-fixer-config: ^1.4.0
Suggests
- ext-curl: Lets Guzzle use the cURL handler, which is faster than the stream wrapper.
Provides
None
Conflicts
None
Replaces
None
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