novapay-ua / novapay
NovaPay Internet Acquiring and Checkout API client for PHP
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- ext-openssl: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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) plusstatus. The same pair arriving twice is a replay. - Never walk the lifecycle backwards.
paidβholdedmeans 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->addPaymentreturnsCheckoutPaymentβurlandsession_id, noid. The transaction id only appears later ingetStatusunderoperations[0]->transaction_id.- A checkout session starts in status
precreated, an acquiring one increated.
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_amountstaysnulleven after a successful void. Detect refunds viastatus === 'voided'ortransaction_status === 'REFUNDED'.rrnstayednulleven on a paid session, andprocessing_resultisnullbefore payment but''after. Neither is a reliable success signal β usestatus.
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.