Search by

novapay-ua / novapay

novapay-ua

NovaPay Internet Acquiring and Checkout API client for PHP

v3.0.1 2026-08-04 12:47 UTC

This package is auto-updated.

Last update: 2026-09-04 13:00:55 UTC


README

CI packagist license

PHP client for the NovaPay external API β€” Internet Acquiring and Checkout. Requests are signed for you, postbacks are verified for you, every payload is documented.

πŸ“– ДокумСнтація ΡƒΠΊΡ€Π°Ρ—Π½ΡΡŒΠΊΠΎΡŽ

Requirements

  • PHP 8.1+ with ext-curl, ext-json, ext-openssl
  • No runtime dependencies

Install

composer require novapay-ua/novapay

Quickstart

use NovaPay\Environment;
use NovaPay\NovaPayClient;

// getenv() returns string|false. Under `strict_types` an unset variable would be a TypeError
// rather than a readable error, so check it here.
$privateKeyPem = getenv('MERCHANT_PRIVATE_KEY_PEM');
if (false === $privateKeyPem) {
    throw new RuntimeException('MERCHANT_PRIVATE_KEY_PEM is not set');
}

$client = new NovaPayClient(
    privateKeyPem: $privateKeyPem,
    merchantId: '<your-merchant-id>',
    novapayPublicKeyPem: ((string) getenv('NOVAPAY_PUBLIC_KEY_PEM')) ?: null,
    environment: Environment::Production,
);

$session = $client->acquiring->createSession([
    'client_phone' => '+380501112233',
    'callback_url' => 'https://your.api/novapay/postback',
]);

$payment = $client->acquiring->addPayment([
    'session_id' => $session->id,
    'amount' => 100.5,
]);

echo 'Redirect the customer to: '.$payment->url;

createSession returns an object with id β€” that id is the session id you pass everywhere else.

Pass 'use_hold' => true to addPayment to authorize now and capture later with completeHold.

Checkout

Same client, same host, different paths. Checkout also collects delivery details.

$session = $client->checkout->createSession([
    'callback_url' => 'https://your.api/novapay/checkout-postback',
    'client_phone' => '+380501112233',
]);

$payment = $client->checkout->addPayment([
    'session_id' => $session->id,
    'amount' => 250,
]);

Responses

Every response is a read-only object you can read as a property or as an array key, dump as JSON, or convert back to a plain array. A field NovaPay adds tomorrow stays readable instead of breaking your integration:

$status = $client->acquiring->getStatus([...]);

$status->status;                          // 'paid'
$status['status'];                        // the same
$status->operations[0]->transaction_id;   // nested objects are wrapped too
$status->toArray();                       // plain nested array, for logging or storage
json_encode($status);                     // back to JSON

Reads never fail: an unknown field is null, exactly like a field NovaPay sent as null. isset() follows PHP semantics and is false for both, so it cannot tell "absent" from "null" β€” if you need that distinction, go through toArray() and array_key_exists().

Postbacks

NovaPay signs postbacks with its own RSA public key (not your merchant key) and sends the signature in the x-sign-v2 header β€” a different header from the x-sign on outgoing requests.

The signature covers the raw request body. Verify before parsing. json_encode($_POST) or a re-encoded array will not match and verification will fail.

use NovaPay\Constants;
use NovaPay\Exception\SignatureVerificationException;

$rawBody = file_get_contents('php://input');
$xSign = $_SERVER['HTTP_X_SIGN_V2'] ?? '';

try {
    $postback = $client->parsePostback($rawBody, $xSign);
} catch (SignatureVerificationException $e) {
    http_response_code(401);
    exit('invalid postback signature');
}
// Catch SignatureVerificationException and nothing wider: an InvalidArgumentException here means
// a missing key or a non-JSON body β€” a broken deployment. Let it 500, a 401 buries it under
// a loop of NovaPay retries.

error_log($postback->id.' β†’ '.$postback->status);
http_response_code(200);

parsePostback verifies the signature and then decodes β€” in that order. If you only need the boolean, verifyPostback($rawBody, $xSign) does the check alone; both throw if you did not pass novapayPublicKeyPem to the constructor. To verify without a client:

use NovaPay\Signature;

$ok = Signature::verify($rawBody, $xSign, getenv('NOVAPAY_PUBLIC_KEY_PEM'));

The header name is exported as Constants::WEBHOOK_HEADER_X_SIGN.

The payload shape depends on the merchant's postback version β€” a per-merchant NovaPay setting (postback_version). v1 (the default) sends one POST per payment with external_id, amount and products on the top level; v2 sends one POST per session with payments grouped under payments. Which one you receive must match what is configured for your merchant at NovaPay β€” ask their support if you are not sure. Both shapes are documented on NovaPay\Postback; a checkout postback is an acquiring one plus the delivery* fields.

Handle postbacks idempotently

x-sign-v2 signs the body and nothing else β€” no nonce, no timestamp. A valid postback therefore stays valid forever, and anyone who captured one can replay it. Verification will pass, correctly, on every replay. Deduplication is your job, not the SDK's:

$postback = $client->parsePostback($rawBody, $xSign);

// Look up what you already know about this session and only move forward through the lifecycle.
$known = $orders->findBySessionId($postback->id);
if (null === $known || !$known->statusIsNewerThan($postback->status)) {
    http_response_code(200);   // a replay is not an error β€” a non-2xx just makes NovaPay retry
    exit;
}

$orders->apply($postback);     // side effects (ship the goods, send the email) live behind this
http_response_code(200);

Three rules that follow from it:

  • Key on id (the session id) plus status. The same pair arriving twice is a replay.
  • Never walk the lifecycle backwards. paid β†’ holded means a stale or replayed delivery.
  • Put side effects behind the dedup check, not in front of it. Shipping twice is the failure mode.

example/public/index.php implements exactly this, in ~10 lines.

API

Every method takes one array and returns a response object.

Method Path Returns
createSession($params) POST /v1/session Session
addPayment($params) POST /v1/payment Payment
getStatus($params) POST /v1/get-status SessionStatus
completeHold($params) POST /v1/complete-hold void
voidSession($params) POST /v1/void void
expireSession($params) POST /v1/expire void

$client->checkout exposes the same six methods. createSession and addPayment use /v1/checkout/*, the rest share the acquiring endpoints.

Two checkout differences worth knowing:

  • checkout->addPayment returns CheckoutPayment β€” url and session_id, no id. The transaction id only appears later in getStatus under operations[0]->transaction_id.
  • A checkout session starts in status precreated, an acquiring one in created.

completeHold, voidSession and expireSession return no data β€” the response body is a literal null. They succeeded if they didn't throw. getStatus is the only way to observe what they did.

Session lifecycle

createSession ──▢ created / precreated
                       β”‚
              customer pays
                       β”œβ”€β”€ use_hold: true ──▢ holded ──completeHold──▢ paid
                       └── use_hold: false ─────────────────────────▢ paid
                                                                       β”‚
                                                                  voidSession
                                                                       β–Ό
                                                                     voided

expireSession moves an unpaid session to expired. voidSession works on a direct charge and on a captured hold alike.

SessionStatus

$s = $client->acquiring->getStatus(['session_id' => $sessionId]);

$s->status;              // 'created', 'precreated', 'holded', 'paid', 'voided', 'expired', … β€” full list on NovaPay\SessionStatus
$s->transaction_status;  // 'APPROVED' | 'REFUNDED' | null
$s->amount;              // '1.00' β€” decimal string, not a number
$s->pan;                 // '424242xxxxxx4242' β€” masked, null before payment
$s->paytype;             // 'card'; empty string (not null) before payment
$s->card_type;           // 'VISA'
$s->approval_code;       // '1785182032.057'
$s->operations;          // [{ transaction_id, external_id, amount, refunded_amount, status }]

Three things that bite:

  • Amounts are decimal strings ('1.00'), not numbers. Don't compare them with === against a number.
  • refunded_amount stays null even after a successful void. Detect refunds via status === 'voided' or transaction_status === 'REFUNDED'.
  • rrn stayed null even on a paid session, and processing_result is null before payment but '' after. Neither is a reliable success signal β€” use status.

Request fields are documented as array shapes on every method, so PHPStan and your IDE check them for you.

Configuration

new NovaPayClient(
    privateKeyPem: $pem,          // required β€” merchant RSA private key, signs outgoing requests
    merchantId: '1',              // required β€” sent as merchant_id in every request body
    novapayPublicKeyPem: $pem,    // NovaPay RSA public key, verifies incoming postbacks
    environment: Environment::Test, // Test (default) | Production
    acquiringBaseUrl: null,       // override the resolved host (staging, mocks)
    checkoutBaseUrl: null,        // override the resolved host (staging, mocks)
    httpClient: null,             // custom transport β€” proxies, instrumentation, tests
    timeout: 30.0,                // per-request timeout, seconds
);

Acquiring and Checkout share one host per environment:

environment Constant Host
Environment::Test (default) Constants::TEST_BASE_URL https://api-qecom.novapay.ua
Environment::Production Constants::PRODUCTION_BASE_URL https://api-ecom.novapay.ua

Environment::Test->baseUrl() resolves the host outside a client.

Note on keys in env vars: PEM keys are multi-line. In a .env file either quote the whole value and keep real newlines, or store it base64-encoded and decode at startup β€” a PEM with literal \n will not parse.

Custom transport

httpClient takes any NovaPay\HttpClient\ClientInterface β€” one method. Wrap Guzzle, add retries you control, or record calls in tests:

use NovaPay\HttpClient\ClientInterface;

final class LoggingClient implements ClientInterface
{
    public function __construct(private ClientInterface $inner, private \Psr\Log\LoggerInterface $log) {}

    public function post(string $url, array $headers, string $body, float $timeout): array
    {
        $this->log->info('NovaPay '.$url);

        return $this->inner->post($url, $headers, $body, $timeout);
    }
}

Errors

Every exception implements NovaPay\Exception\ExceptionInterface.

Exception When
ProcessingException 4xx with type: processing β€” the request was valid but rejected
ValidationException 4xx with type: validation β€” the body failed schema validation
ApiException any other non-2xx (5xx, gateway errors, non-JSON bodies)
ApiConnectionException no HTTP response at all β€” DNS, TLS, connection reset, timeout
SignatureVerificationException a postback did not match its x-sign-v2
InvalidArgumentException bad input on your side: unreadable key, missing option
use NovaPay\Exception\ApiException;
use NovaPay\Exception\ProcessingException;
use NovaPay\Exception\ValidationException;

try {
    $client->acquiring->voidSession([...]);
} catch (ProcessingException $e) {
    $e->getErrorCode();      // 'SessionAlreadyRefundedError' | 'SessionNotFoundError' | 'NotFoundError' | …
    $e->getErrorMessage();   // 'session already refunded'
} catch (ValidationException $e) {
    $e->getErrors();         // [['path' => 'client_phone', 'code' => 'invalid_type', 'message' => '…']]
} catch (ApiException $e) {
    $e->getHttpStatus();     // 502
    $e->getHttpBody();       // raw response text
    $e->getJsonBody();       // decoded body, or null when it wasn't JSON
}

Branch on getErrorCode(), never on the message. Every 4xx body carries a uuid β€” $e->getUuid(), quote it in support tickets.

There are no automatic retries. addPayment is not idempotent β€” a blind retry can charge the customer twice. On a timeout or 5xx, call getStatus to find out what actually happened before retrying.

Example app

A runnable Slim 4 storefront β€” hold and direct-charge buttons, success/fail pages, signed postbacks, and a purchases list wired to completeHold / voidSession:

cd example && composer install
cp .env.example .env   # PUBLIC_URL + your QE key pair
ngrok http 3000
set -a && . ./.env && set +a
php -S 127.0.0.1:3000 -t public public/index.php

See example/README.md.

Development

composer install
composer ci        # everything below, in order
composer test      # phpunit
composer analyse   # phpstan level 10
composer lint      # php-cs-fixer (PER-CS), dry run
composer fix       # php-cs-fixer, apply

No PHP locally? Everything runs in Docker:

docker run --rm -v "$PWD":/app -w /app php:8.1-cli php vendor/bin/phpunit

Issues and pull requests: github.com/NovaPay/novapay-php.

API reference

Full field lists, status values and postback schemas: NovaPay API Reference.

License

MIT