Search by

novadigital / novapost-api-sdk

nova-poshta-global

PHP SDK for Nova Post API

Package info

gitlab.com/novadigitalhub/novapost-api-sdk

Issues

pkg:composer/novadigital/novapost-api-sdk

Statistics

Installs: 3 884

Dependents: 1

Suggesters: 0

Stars: 0

3.3.0 2026-11-10 00:51 UTC

This package is not auto-updated.

Last update: 2026-11-09 23:35:23 UTC


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

ModeDefault address
productionhttps://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):

ExceptionWhen
ValidationException400, 422 — errors() holds the field messages
AuthenticationException401 that survives a token refresh
ForbiddenException403 — operation not allowed (extends the above)
NotFoundException404
ConflictException409
RateLimitException429 — retryAfter() in seconds when sent
ServerException5xx
TransportExceptionno HTTP response (DNS, TLS, timeout); requestSent() says whether it went out
MalformedResponseException2xx 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:

StorageWhere the token livesUse it when
Psr16JwtTokenStoragethe platform's cache, through any PSR-16 CacheInterface (Laravel, Symfony, Redis, APCu…), until the token's exprecommended — the platform has a cache; needs psr/simple-cache
Psr6JwtTokenStoragethe platform's cache, through any PSR-6 CacheItemPoolInterface, until the token's expthe platform exposes a PSR-6 pool rather than PSR-16; needs psr/cache
MemoryJwtTokenStoragein memory, as long as the object livesa worker, Octane/RoadRunner or a CLI run builds the client once; under PHP-FPM every request fetches its own token
FileJwtTokenStoragea file in the system temp directory, 0600, private to the key and environmentdeprecated — 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:

FindingResult
the directory exists (or was created, 0700), is readable and writablethe token is cached in the file
the path exists but is not a directoryfile cache disabled
the directory cannot be createdfile cache disabled
the directory is not readable, or not writablefile 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 emptythe 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 — logging
  • Psr\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 own
  • NovaDigital\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 mapping
  • NovaDigital\NovaPost\Http\RetryHandlerInterface — what happens on an expired token
  • NovaDigital\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 sandbox https://api-stage.novapost.com/v.1.0/ (see «API address»)
  • apiKey, useSandbox — set by NovaPostApiFactory from 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() is divisions()->list(), webhooks() is subscriptions().
  • shipments()->print() and downloadAttachment() return a BinaryResponse, not a string.
  • 400 and 422 throw ValidationException instead of returning the body; 403 is a ForbiddenException.
  • Divisions are referred to by divisionId (2.x payloads used divisionID, 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 as true/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.