novadigital / novapost-api-sdk
PHP SDK for Nova Post API
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.0
- psr/container: ^1.1.2 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^9.6
- squizlabs/php_codesniffer: ^3.13
- symfony/yaml: ^6.4
- vlucas/phpdotenv: ^5.6
Suggests
None
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+.
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.
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 (default: a file in the system temp directory, private to the API key and environment;->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 default JWT storage keeps its file (created when missing; a token that cannot be written is logged as a warning and fetched again next time)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.
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) and
attaches a JUnit report per version to the merge request. The unit jobs run in parallel; the
integration jobs take turns (resource_group: sandbox), because they all work on one sandbox account.
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-12 coding standard.