learning-machine / kapitalbank-checkout
Framework-agnostic PHP client for the Kapital Bank / Birbank Checkout API (OAuth2 token, payments, refunds, webhooks) with an optional Laravel service provider.
Package info
github.com/Learning-Machine-AZ/kapitalbank-checkout
pkg:composer/learning-machine/kapitalbank-checkout
Requires
- php: >=8.1
- ext-json: *
- ext-mbstring: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.5
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- phpunit/phpunit: ^10.5
Suggests
- guzzlehttp/guzzle: Used automatically as the PSR-18 client / PSR-17 factory when none is passed
- illuminate/http: Laravel: LaravelHttpClient sends through the Http facade (Http::fake() works)
- php-http/discovery: Finds any installed PSR-18 client / PSR-17 factory when Guzzle is absent
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-14 13:11:31 UTC
README
PHP client for the Kapital Bank / Birbank Checkout API: OAuth2 token, create payment, payment status, refunds, webhook verification. Framework-agnostic core (PSR-18 / PSR-17 / PSR-16) with an optional, auto-discovered Laravel integration.
PHP >= 8.1. MIT licensed.
Install
composer require learning-machine/kapitalbank-checkout
Until the package is listed on Packagist, add the GitHub repository first:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/Learning-Machine-AZ/kapitalbank-checkout.git" }
],
"require": {
"learning-machine/kapitalbank-checkout": "^0.2"
}
}
A PSR-18 client is needed: Guzzle (guzzlehttp/guzzle, already present in Laravel) is used
automatically; otherwise install php-http/discovery + any PSR-18 client, or pass one.
Usage (plain PHP)
use LearningMachine\KapitalBank\Checkout\Amount; use LearningMachine\KapitalBank\Checkout\{CheckoutClient, CheckoutResponse, RefundOptions}; use LearningMachine\KapitalBank\Checkout\Exception\{AuthenticationFailed, BankRejected, OutcomeUnknown}; $client = CheckoutClient::fromConfig([ 'base_url' => 'https://precheckout.kapitalbank.az/api', // or omit for production 'client_id' => getenv('KAPITALBANK_CLIENT_ID'), 'client_secret' => getenv('KAPITALBANK_CLIENT_SECRET'), 'merchant_id' => getenv('KAPITALBANK_MERCHANT_ID'), 'terminal_id' => getenv('KAPITALBANK_TERMINAL_ID'), // 'refund' => ['amount_format' => 'number', 'id_field' => 'id'], // defaults ], cache: $psr16Cache /* optional: token caching */); // 1. Create a payment and redirect the customer $payment = $client->createPayment( Amount::fromDecimal('12.50'), // or Amount::fromMinor(1250); currency defaults to AZN 'Order #123', 'https://shop.example/payment/return', $idempotencyKey, // optional; a UUID v4 is generated if null ); $payment->id; // bank payment uuid — store it $payment->idempotencyKey; // store it too (it is part of the redirect link) header('Location: ' . $payment->redirectUrl()); // confirmUrl?id=<id>&password=<key> // 2. Status $p = $client->getPayment($paymentId); $p->status; // Status::Pending | Succeeded | Canceled | Unknown (raw string in $p->rawStatus) $p->amount; // Amount|null $p->rrn; // string|null // 3. Refund (full or partial) try { $refund = $client->refund($paymentId, Amount::fromDecimal('2.50'), $refundRowUuid); $refund->status; $refund->rrn; $refund->id; $refund->requestBody; // what was sent } catch (BankRejected $e) { $e->error->httpStatus; $e->error->code; $e->error->message; $e->error->traceId; // quote traceId to the bank $e->isServerError(); // 5xx: outcome uncertain -> check before retrying } catch (OutcomeUnknown $e) { // timeout / connection drop / unreadable answer: the bank MAY have processed it. // Retry only with the SAME idempotency key, or reconcile via getPayment(). } catch (AuthenticationFailed $e) { // token request failed, nothing money-related was sent } // Preview the exact refund JSON (e.g. on an admin confirmation page) $client->refundBody($paymentId, Amount::fromMinor(250)); // ['id' => '…', 'amount' => 2.5] // …or without a client / credentials: RefundOptions::fromArray($refundConfig)->body($paymentId, Amount::fromMinor(250), $merchantId, $terminalId);
Keeping answers for audit: CheckoutResponse
Every result carries the answer it was read from: $payment->response, $refund->response,
$e->error->response (BankRejected) and $e->response on an OutcomeUnknown whose answer
arrived but could not be used (a 2xx body that is not a JSON object, a payment without an id;
null when there was no answer at all).
$r = $refund->response; $r->httpStatus; // 201 $r->status(); // "succeeded" (2xx only) $r->id(); $r->rrn(); $r->amountMinor(); $r->currency(); $r->errorCode(); // code | errorCode | error | cancellationReason $r->errorMessage(); // "Malformed JSON request — amount: must be a number [bank error id …]" (≤ 255 chars) $r->forStorage(); // pretty JSON: secret-looking keys redacted, card-like numbers masked, ≤ 8000 chars CheckoutResponse::encodeForStorage($refund->requestBody); // same treatment for anything you store CheckoutResponse::scrub($text); // masks 12+ digit runs, keeps UUIDs
Webhook (bank callback)
use LearningMachine\KapitalBank\Checkout\Webhook\WebhookParser; $parser = new WebhookParser(getenv('KAPITALBANK_WEBHOOK_SECRET')); $event = $parser->parse($rawBody, $request->headers->get('X-Signature')); // throws InvalidWebhook if ($event->isSucceeded()) { // credit order $event->id (idempotently!) } else { // canceled etc. — close the order; still answer HTTP 200 or the bank re-delivers }
Signature = base64(HMAC-SHA256(json_encode(payload, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE), secret))
over the inner payload value, sent in X-Signature. verify() only checks the signature
(exact, constant-time compare; the body must be a JSON object with a payload key);
parse() also checks the structure (id, type, status strings).
Laravel
The provider LearningMachine\KapitalBank\Checkout\Laravel\KapitalBankServiceProvider is
auto-discovered. Publish the config if you want to edit it:
php artisan vendor:publish --tag=kapitalbank-config
.env:
KAPITALBANK_BASE_URL=https://checkout.kapitalbank.az/api # preprod: https://precheckout.kapitalbank.az/api KAPITALBANK_CLIENT_ID= KAPITALBANK_CLIENT_SECRET= KAPITALBANK_MERCHANT_ID= KAPITALBANK_TERMINAL_ID= KAPITALBANK_WEBHOOK_SECRET= # KAPITALBANK_TOKEN_CACHE_STORE=redis # default store if empty, "false" disables # KAPITALBANK_REFUND_ID_FIELD=id # id | originalId | paymentId # KAPITALBANK_REFUND_AMOUNT_FORMAT=number # number | string | object | object_string # KAPITALBANK_REFUND_SEND_DESCRIPTION=false # KAPITALBANK_REFUND_SEND_POS_DETAIL=false # KAPITALBANK_REFUND_EXTRA_JSON= # JSON object merged last, e.g. {"currency":"AZN"}
Then inject CheckoutClient / WebhookParser anywhere (both are singletons).
Requests go through Laravel's HTTP client (Laravel\LaravelHttpClient, a PSR-18 adapter over
the Http facade), so Http::fake(), Http::preventStrayRequests() and Http::recorded()
work in your tests. Use it directly when you build the client yourself, e.g. with settings
from your own config keys:
use LearningMachine\KapitalBank\Checkout\Laravel\LaravelHttpClient; $client = CheckoutClient::fromConfig($settings, new LaravelHttpClient(timeout: 30, connectTimeout: 10)); // timeout / connectTimeout in seconds; null = Laravel's default, 0 = no limit
If you build the client from your own configuration, you can turn the auto-discovery off
("extra": {"laravel": {"dont-discover": ["learning-machine/kapitalbank-checkout"]}}).
Configuration keys (Config::fromArray)
| Key | Default | |
|---|---|---|
client_id, client_secret |
— | required |
merchant_id, terminal_id |
'' |
posDetail of payments (and of refunds with send_pos_detail) |
base_url / environment |
production | environment: preprod = Config::PREPROD_URL |
timeout, connect_timeout |
30 / 10 | used when the client creates Guzzle itself |
token_path, payments_path, refunds_path |
/oauth2/token, /v1/payments, /v1/refunds |
|
retry_on_unauthorized |
true |
a 401 drops the token and retries once; false reports it as BankRejected |
scope, payment_method_type |
email, BIRBANK |
|
refund |
see below | id_field, amount_format, send_description, send_pos_detail, extra_json / extra |
Invalid settings throw ConfigurationException before anything is sent; its $key names the
setting (client_id, base_url, refund.amount_format, refund.extra_json, …) so an
application can word the error in terms of its own environment variables.
API notes
| Call | Request |
|---|---|
| Token | POST {base}/oauth2/token, form scope=email&grant_type=client_credentials&client_id&client_secret |
| Create payment | POST {base}/v1/payments, Bearer, X-Idempotency-Key, body amount{value,currency}, capture:true, description, paymentMethodData{type:BIRBANK}, confirmation{type:REDIRECT,returnUrl}, posDetail{merchantId,terminalId} |
| Payment status | GET {base}/v1/payments/{id} |
| Refund | POST {base}/v1/refunds, Bearer, X-Idempotency-Key, body {"id": "<payment uuid>", "amount": 2.5} |
- Refund body: the default
{"id", "amount": <decimal number>}is the shape the live API accepts (the bank returns an RRN). The public reference documents only the refund object, not the request;{"originalId", "amount": {"value", "currency"}}is answered with 400 "Malformed JSON request". Other shapes stay configurable (RefundOptions/refundconfig) in case the bank changes it. - Base URLs: production
https://checkout.kapitalbank.az/api, preprodhttps://precheckout.kapitalbank.az/api(Config::PRODUCTION_URL/Config::PREPROD_URL, or'environment' => 'preprod'). - Statuses:
pending,succeeded,canceled(Statusenum; anything else isUnknownwith the raw string kept). Onlysucceededmeans money moved. - Error answers:
{"id","code","status","method","path","message","timestamp"}(+ optionalerrors[]) →BankRejected::$error(BankError).idthere is the bank's trace id. The exception message ends with the request line, e.g.[POST https://…/v1/payments](never tokens or bodies), so log lines show which call failed. - Amounts are integer minor units internally (
Amount), never floats; the JSON number is produced only when encoding the body (5,2.5). - The token is cached in memory per client and, if a PSR-16 cache is given, for
expires_in - 60seconds. - Keep production credentials away from dev machines: the package does not block the production host by itself; guard that in the application if needed.
Tests
composer install vendor/bin/phpunit
No network: all HTTP goes through a mock PSR-18 client or a faked Laravel HTTP factory. GitHub Actions runs the suite on PHP 8.1 – 8.4.
Changelog
0.2.0
CheckoutResponse: the answer behind every result (Payment::$response,Refund::$response,BankError::$response,OutcomeUnknown::$response) with HTTP status and storage-safe helpers (forStorage(),encodeForStorage(),scrub(),errorMessage(),errorCode()).Laravel\LaravelHttpClient: PSR-18 over Laravel'sHttpfacade (fakeable); the service provider uses it.Config:token_path/payments_path/refunds_pathfrom arrays,retry_on_unauthorized.ConfigurationException::$key;RefundOptions::body()builds a refund body without a client.BankRejectedand the token-rejection message name the request ([POST https://…]).WebhookParser::verify(): signature of anypayloadvalue, compared exactly (no trimming);parse()still requires the payload object.- Namespace
LearningMachine\KapitalBank\Checkout, packagelearning-machine/kapitalbank-checkout, MIT.
0.1.0
- First release: token, create payment, payment status, refunds, webhook parser, Laravel provider.
License
MIT, see LICENSE.