novadigital / novapost-api-sdk
PHP SDK for Nova Post API
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.0 || ^8.0
- psr/container: ^1.1.2 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^9.6
- psr/cache: ^1.0 || ^2.0 || ^3.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
- squizlabs/php_codesniffer: ^3.13
- symfony/yaml: ^6.4
- vlucas/phpdotenv: ^5.6
Suggests
- psr/cache: To keep the JWT in a PSR-6 cache pool through Psr6JwtTokenStorage instead of a file
- psr/simple-cache: To keep the JWT in the platform's cache through Psr16JwtTokenStorage instead of a file
Provides
None
Conflicts
None
Replaces
None
README
PHP SDK for the NovaPost API: shipments, tracking, divisions, pickups, registries, webhook subscriptions and fulfillment.
Every request and response is a typed DTO generated from the API's own OpenAPI document
(spec.yaml), so what the IDE autocompletes is what the API accepts.
Requires PHP 8.1+ and Guzzle 7 or 8.
Installation
composer require novadigital/novapost-api-sdk
Usage
Initializing the client
use NovaDigital\NovaPost\NovaPostApiFactory;
$novaPostApi = (new NovaPostApiFactory())('YOUR_API_KEY'); // production
$novaPostApi = (new NovaPostApiFactory())('YOUR_API_KEY', useSandbox: true);
API address
| Mode | Default address |
|---|---|
| production | https://api.novapost.com/v.1.0/ |
sandbox (useSandbox: true) | https://api-stage.novapost.pl/v.1.0/ |
The sandbox default is api-stage.novapost.pl, which Nova Post has deprecated in favour of
api-stage.novapost.com. It stays the default for one reason: .com currently answers API clients
with a Cloudflare "are you a robot" page (HTTP 403, header cf-mitigated: challenge) instead of the
API — on some days now and then, on others on every request. A program cannot pass that page, so the
SDK gets 403. The keys are the same for both addresses. The default moves to .com once Nova Post
lifts the check for API clients and the integration suite passes there.
Any other address is set with the baseUri container parameter; it replaces the default in either
mode. For example, api-stage.novapost.com:
use NovaDigital\NovaPost\DI\ContainerBuilder;
$novaPostApi = (new NovaPostApiFactory())(
apiKey: 'YOUR_API_KEY',
containerBuilder: (new ContainerBuilder())->setParameter('baseUri', 'https://api-stage.novapost.com/v.1.0/'),
useSandbox: true,
);
The two sandboxes do not share data: a shipment created on .pl is not visible on .com and the
other way round.
Calling the API
Each resource method takes a request DTO (body), a query DTO (query string) or path arguments, and
returns a response DTO. Optional fields default to null and are left out of the request.
use NovaDigital\NovaPost\Dto\Divisions\Enum\CountryCode;
use NovaDigital\NovaPost\Dto\Divisions\Enum\DivisionCategory;
use NovaDigital\NovaPost\Dto\Divisions\ListDivisionsQuery;
use NovaDigital\NovaPost\Dto\Shipments\CreateShipmentRequest;
use NovaDigital\NovaPost\Dto\Shipments\Enum\CargoCategory;
use NovaDigital\NovaPost\Dto\Shipments\Enum\PayerType;
use NovaDigital\NovaPost\Dto\Shipments\Request\Parcel;
use NovaDigital\NovaPost\Dto\Shipments\Request\Recipient;
use NovaDigital\NovaPost\Dto\Shipments\Request\Sender;
$divisions = $novaPostApi->divisions()->list(new ListDivisionsQuery(
countryCodes: [CountryCode::PL],
divisionCategories: [DivisionCategory::Postomat],
textSearch: 'Warszawa', // narrow the search: a whole country is too heavy a query
limit: 50,
));
foreach ($divisions->items ?? [] as $division) {
echo $division->id, ' ', $division->name, PHP_EOL;
}
$shipment = $novaPostApi->shipments()->create(new CreateShipmentRequest(
payerType: PayerType::Sender,
parcels: [new Parcel(CargoCategory::Parcel, 'Shoes', 120.0, 1, 10, 20, 30, 1500, insuranceCurrencyCode: 'PLN')],
sender: new Sender('+48500100100', 'Jan Kowalski', 'PL', divisionId: 948389),
recipient: new Recipient('+48500100200', 'Anna Nowak', 'PL', divisionId: 2176834),
));
echo $shipment->number;
A response DTO exposes the fields the spec documents and keeps the whole decoded body in ->raw,
so a field the API adds tomorrow is reachable today. A field of an unexpected shape becomes null
instead of breaking the call.
Binary endpoints (shipments()->print(), shipments()->downloadAttachment()) return a
BinaryResponse with the body as a stream:
use NovaDigital\NovaPost\Dto\Shipments\Enum\DeliveryType;
use NovaDigital\NovaPost\Dto\Shipments\Enum\PrintShipmentsType;
use NovaDigital\NovaPost\Dto\Shipments\Enum\PrintSizeType;
use NovaDigital\NovaPost\Dto\Shipments\PrintShipmentsQuery;
$labels = $novaPostApi->shipments()->print(new PrintShipmentsQuery(
numbers: [$shipment->number],
type: PrintShipmentsType::Marking,
printSizeType: PrintSizeType::Size100100,
deliveryType: DeliveryType::Shipment,
));
file_put_contents($labels->fileName() ?? 'labels.pdf', $labels->contents());
Errors
Every failure is an ApiException (or a subclass); the HTTP status is getStatusCode(), the API's
own error payload is errors(), the decoded body body(), and the body exactly as received
rawBody() (useful when a gateway answers with HTML):
| Exception | When |
|---|---|
ValidationException | 400, 422 — errors() holds the field messages |
AuthenticationException | 401 that survives a token refresh |
ForbiddenException | 403 — operation not allowed (extends the above) |
NotFoundException | 404 |
ConflictException | 409 |
RateLimitException | 429 — retryAfter() in seconds when sent |
ServerException | 5xx |
TransportException | no HTTP response (DNS, TLS, timeout); requestSent() says whether it went out |
MalformedResponseException | 2xx whose body is not JSON |
A TransportException carries the HTTP client's own exception as getPrevious() and answers
requestSent(): false when the connection was never made (DNS, TCP, TLS — the operation did not
happen, repeating it is safe), null when the request may have gone out and the answer was lost
(timeout, reset — repeating a create may duplicate it). A transport failure while getting a token is
an AuthenticationException whose getPrevious() is such a TransportException; the key travels
in the token request URL, so it is cut from the message, from the log entry and from the exception
chain. Anything that is not an API answer or a
transport failure — a TypeError, a dependency's own failure — is a defect and leaves with its own
class, never dressed up as an ApiException.
use NovaDigital\NovaPost\Exception\ApiException;
use NovaDigital\NovaPost\Exception\ValidationException;
try {
$novaPostApi->shipments()->create($request);
} catch (ValidationException $e) {
foreach ($e->errors() as $field => $message) {
// 'recipient.phone' => 'Invalid phone format'
}
} catch (ApiException $e) {
echo $e->getStatusCode(), ' ', $e->getMessage();
}
An expired JWT is refreshed and the call repeated once; a token issued seconds ago is not refreshed
again (a 401 on it means the key may not use that endpoint). The SDK does not retry anything else.
Redirects on reads (dictionary manifests on the CDN) are followed, also after a token refresh; the
token never leaves the API host. A redirect the SDK does not follow is an ApiException, not a success.
Errors are logged with the exception class and message only, never the request with its token.
Token storage
The API takes a JWT, not the key: the SDK exchanges the key for a token once
(GET clients/authorization) and sends the token with every call until its exp. Where that token
waits between PHP requests is the platform's decision, made through JwtTokenStorageInterface
(save, get, delete). The SDK ships three implementations:
| Storage | Where the token lives | Use it when |
|---|---|---|
Psr16JwtTokenStorage | the platform's cache, through any PSR-16 CacheInterface (Laravel, Symfony, Redis, APCu…), until the token's exp | recommended — the platform has a cache; needs psr/simple-cache |
Psr6JwtTokenStorage | the platform's cache, through any PSR-6 CacheItemPoolInterface, until the token's exp | the platform exposes a PSR-6 pool rather than PSR-16; needs psr/cache |
MemoryJwtTokenStorage | in memory, as long as the object lives | a worker, Octane/RoadRunner or a CLI run builds the client once; under PHP-FPM every request fetches its own token |
FileJwtTokenStorage | a file in the system temp directory, 0600, private to the key and environment | deprecated — historically the default, kept only so existing installations keep working |
use NovaDigital\NovaPost\DI\ContainerBuilder;
use NovaDigital\NovaPost\NovaPostApiFactory;
use NovaDigital\NovaPost\Storage\JwtTokenStorageInterface;
use NovaDigital\NovaPost\Storage\Psr16JwtTokenStorage;
$containerBuilder = (new ContainerBuilder())
->instance(JwtTokenStorageInterface::class, new Psr16JwtTokenStorage($cache, $apiKey, $useSandbox));
$novaPostApi = (new NovaPostApiFactory())($apiKey, $containerBuilder, $useSandbox);
Or let the container build it — apiKey and useSandbox are taken from the factory's arguments:
use Psr\SimpleCache\CacheInterface;
$containerBuilder = (new ContainerBuilder())
->instance(CacheInterface::class, $cache)
->bind(JwtTokenStorageInterface::class, Psr16JwtTokenStorage::class);
A cache that fails is logged as a warning and treated as empty; the call goes on with a freshly fetched token. A platform with its own cache API (Magento, WordPress transients) implements the three methods itself.
What to hand over on the common platforms:
// Laravel: the cache repository is a PSR-16 cache
new Psr16JwtTokenStorage(Cache::store(), $apiKey, $useSandbox);
// Symfony: any cache adapter is a PSR-6 pool, Psr16Cache wraps it as PSR-16
new Psr6JwtTokenStorage($cachePool, $apiKey, $useSandbox);
// A long-lived worker (Octane, RoadRunner, a queue consumer) that builds the client once
new MemoryJwtTokenStorage();
The default file, and why not to rely on it
Without a storage of your own the SDK writes the token to sys_get_temp_dir() (or the directory
parameter). That is a library writing to the host's disk, which a hardened host may forbid, so the
file storage decides once, when the client is built, whether it can work at all:
| Finding | Result |
|---|---|
the directory exists (or was created, 0700), is readable and writable | the token is cached in the file |
| the path exists but is not a directory | file cache disabled |
| the directory cannot be created | file cache disabled |
| the directory is not readable, or not writable | file cache disabled |
| a later write fails anyway (quota, policy, a directory that lies about its permissions) | file cache disabled from then on |
| the file is there but not ours or empty | the file is deleted, a new token is fetched |
«Disabled» never throws and never breaks an API call: one warning goes to the logger
(JWT token file cache is disabled: <reason>…), isAvailable() answers false, and the process
fetches a token once and keeps it in memory. The cost is one authorization request per PHP process.
To avoid it, hand the SDK your cache as shown above.
FileJwtTokenStorage is deprecated. It is the default historically, and stays so only for backward
compatibility: new integrations should not pick it, and a future major version will make a storage
of the platform's choice mandatory.
Generated code
src/Dto/** (except Cast.php) and src/Resources/* (except AbstractResource.php) are generated:
composer generate # regenerate from spec.yaml
composer check:generated # fail if src/ and spec.yaml disagree (runs in CI)
generator/operations.php names every operation of the spec; a new endpoint in spec.yaml fails
generation until it is named there, so the SDK never silently grows an unnamed method.
spec.yaml is Nova Post's developer spec as published (only indentation repairs where it does not
parse). Where the live API disagrees with it, the correction lives in generator/spec-overrides.php
with the reason and how it was confirmed: field names (divisionId), parameters the API accepts but
the spec omits (textSearch, insuranceCurrencyCode, hsCodeDeclaration), required fields, wrong
types, the tracking items[] wrapper, and the registry operations. Response fields the API returns but
the spec does not declare are recorded from live calls in generator/spec-additions/observed-fields.yaml.
To refresh the spec, download it over spec.yaml and run composer generate; an override that
upstream has fixed stops generation and asks to be removed.
Advanced usage: overriding services
The SDK wires itself through a PSR-11 container. Any service can be replaced on the
ContainerBuilder before the factory builds the client:
use NovaDigital\NovaPost\DI\ContainerBuilder;
use NovaDigital\NovaPost\NovaPostApiFactory;
use NovaDigital\NovaPost\Storage\JwtTokenStorageInterface;
use Psr\Log\LoggerInterface;
$containerBuilder = (new ContainerBuilder())
->bind(LoggerInterface::class, MyLogger::class)
->bind(JwtTokenStorageInterface::class, MyDbJwtTokenStorage::class);
$novaPostApi = (new NovaPostApiFactory())(apiKey: $apiKey, containerBuilder: $containerBuilder);
Overridable services:
Psr\Log\LoggerInterface— loggingPsr\Http\Client\ClientInterface— the HTTP client: any PSR-18 client, passed with->instance(); the SDK sends absolute URLs, so the client needs no base URL of its ownNovaDigital\NovaPost\Storage\JwtTokenStorageInterface— where the JWT is kept between requests (see «Token storage»; the default file in the system temp directory is deprecated,->setParameter('directory', '/path')moves it)NovaDigital\NovaPost\Http\ResponseValidatorInterface— status → exception mappingNovaDigital\NovaPost\Http\RetryHandlerInterface— what happens on an expired tokenNovaDigital\NovaPost\Http\RequestHeadersProviderInterface— platform-specific request headers
Parameters (->setParameter($name, $value)):
config— Guzzle options for the default client, e.g.['timeout' => 10, 'connect_timeout' => 5](the SDK sets no timeouts and retries nothing: that is the platform's call)directory— where the deprecated default file storage keeps its file (created when missing; a directory that cannot be used disables the file with a warning, see «Token storage»)baseUri— another API address than the default production/sandbox one, e.g. a gateway in front of Nova Post or the newer sandboxhttps://api-stage.novapost.com/v.1.0/(see «API address»)apiKey,useSandbox— set byNovaPostApiFactoryfrom its arguments
Custom request headers
An integration layer (a CMS or eCommerce module) can add headers to every request through
RequestHeadersProviderInterface. The SDK adds its own NovaPost-SDK-Vers header afterwards, so
SDK-owned headers win on conflict.
use NovaDigital\NovaPost\DI\ContainerBuilder;
use NovaDigital\NovaPost\Http\RequestHeadersProviderInterface;
use NovaDigital\NovaPost\NovaPostApiFactory;
final class MyPlatformRequestHeadersProvider implements RequestHeadersProviderInterface
{
public function getHeaders(): array
{
return [
'NovaPost-Ecomm-Platform' => 'MyPlatform',
'NovaPost-Ecomm-Platform-Version' => '1.0.0',
];
}
}
$containerBuilder = (new ContainerBuilder())
->bind(RequestHeadersProviderInterface::class, MyPlatformRequestHeadersProvider::class);
$novaPostApi = (new NovaPostApiFactory())(apiKey: 'YOUR_API_KEY', containerBuilder: $containerBuilder);
Logging
Pass a PSR-3 logger and every exchange with the API becomes one record in it:
$api = (new NovaPostApiFactory())(
apiKey: $apiKey,
containerBuilder: (new ContainerBuilder())->instance(LoggerInterface::class, $monolog),
);
// info HTTP request {"method":"POST","path":"/v.1.0/shipments","duration_ms":2534,"status":201}
// info Token fetched {"status":200,"duration_ms":1171}
// info Token refreshed, request retried
// warning HTTP request {"method":"DELETE","path":"/v.1.0/shipments/1","duration_ms":312,"status":404}
Below 400 the record is info, a client error is warning, a server error is error; a request that
got no answer is error with exception_class and error. The API key, the Authorization header and
the JWT never reach the log, in no field. Without a logger (NullLogger, the default) nothing is built.
The logger is wrapped on the way in: one that throws cannot change what a call returns or throws.
For development, the logging parameter adds headers and bodies to each record:
(new ContainerBuilder())
->instance(LoggerInterface::class, $monolog)
->setParameter('logging', [
'bodies' => true, // default false
'maskFields' => ['phone', 'email', 'name'], // default: phone, email, name, fullName, companyTin, secretToken
'bodyLimit' => 4000, // bytes kept per body
]);
Masked fields are matched by key at any depth of a JSON body, case-insensitively; jwt, token,
apiKey and authorization are masked always. Bodies on a production logger are not advised: a
shipment carries personal data beyond the default list.
Upgrading from 2.x
3.0 replaces array payloads with DTOs and is not backwards compatible:
shipments()->calculate($array)→shipments()->calculate(new CalculateShipmentRequest(...)); every method returns a DTO, raw data is in->raw.- Resource classes are plural (
Division→Divisions,Shipment→Shipments, …);divisions()->get()isdivisions()->list(),webhooks()issubscriptions(). shipments()->print()anddownloadAttachment()return aBinaryResponse, not a string.- 400 and 422 throw
ValidationExceptioninstead of returning the body; 403 is aForbiddenException. - Divisions are referred to by
divisionId(2.x payloads useddivisionID, which the API ignores). - Registry requests take shipment numbers; pickup shipments take numeric shipment ids.
- Query lists are sent as
name[]=a&name[]=b; booleans astrue/false. - PHP 8.1 is the minimum; Guzzle 7 and 8 both work.
Examples
examples/ has a runnable script per area (divisions, customs, exchange rates, cost calculation,
shipments, registry, subscriptions). Copy .env.sample to .env, put a sandbox key in API_KEY,
and run e.g. php examples/shipments-create.php; every example removes what it creates.
Tests
composer test # unit suite: no network, no key
composer test:integration # real calls to the sandbox API
composer test:all # both
The integration suite builds the client with useSandbox: true, never production, calls the SDK's
default sandbox — the deprecated .pl, see «API address»; it belongs on .com once Cloudflare lets
API clients through (NOVAPOST_SANDBOX_BASE_URL changes it) — and reads the key
from NOVAPOST_SANDBOX_API_KEY (.env locally, see .env.sample; a masked variable in CI). Without
the key every integration test is skipped locally and the CI job fails, so a pipeline cannot be green
without a single live call. Every operation the SDK exposes is called by an integration test — a unit
test (IntegrationCoverageTest) reads generator/operations.php and fails when one is not — and the
failures a platform has to handle (a rejected key, 404, 422) and the token being fetched once and reused
are covered as well. What the sandbox cannot do with our key is skipped with the API's own answer as the
reason, so the gap is visible in the report rather than green: a light return (needs a delivered
shipment), downloading an attachment (the API does not list uploads). Everything a test creates on the
sandbox account it deletes again, and an object it cannot delete fails the test — except in the
warehouse (fulfillment), where the API never deletes goods and only cancels plans and orders, so those
tests leave SDK-TEST-… goods and cancelled documents behind and place orders for goods without stock,
which the API opens as Incomplete (13), the one status that lets an order be edited and cancelled; the five CI containers share the account, so every check is by the id of
what the test created, never by a global count. The registry tests work on one kept registry
(NOVAPOST_TEST_REGISTRY_ID), because the API never deletes a registry; for the same reason creating a
registry is not checked in a normal run — its test is skipped unless NOVAPOST_TEST_REGISTRY_CREATE=1,
and each such run leaves a registry behind. The sender and recipient are
SENDER_* / RECIPIENT_* from .env, defaults as in .env.sample.
CI runs both suites on PHP 8.1, 8.2, 8.3, 8.4 and 8.5 (the versions the Magento module declares),
plus the next PHP while it is a release candidate (allowed to fail), and attaches a JUnit report per
run to the merge request. Every unit job runs twice, with the lock file (Guzzle 8) and with the newest
Guzzle 7 (composer update "guzzlehttp/guzzle:^7.0" -W in the job), because Magento 2.4 and OMS still
resolve Guzzle 7. The unit jobs run in parallel; the integration jobs take turns (resource_group:
sandbox), because they all work on one sandbox account, so Guzzle 7 goes live on the oldest and the
newest stable PHP only.
A PHP deprecation fails the suite (convertDeprecationsToExceptions), so a version that only warns
today does not pass unnoticed. Two things a run leaves on the sandbox account by the API's design:
the kept registry, and one cancelled pickup per run (a pickup that was Created cannot be deleted).
PSR standards
PSR-4 autoloading, PSR-11 container (psr/container 1.1.2+ and 2.x), PSR-3 logging, PSR-7/PSR-17
messages and factories, PSR-18 HTTP client, PSR-16 cache for the token (optional), PSR-12 coding standard.