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 618

Dependents: 1

Suggesters: 0

Stars: 0

3.2.2 2026-10-27 21:23 UTC

This package is not auto-updated.

Last update: 2026-10-27 21:26:44 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+.

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.

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 (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 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 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 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.

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.