mosesadewale / kora-php
Framework-agnostic PHP SDK for the Kora payment API (formerly KoraPay).
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.8
- psr/log: ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Framework-agnostic PHP SDK for the Kora payment API.
Requirements
- PHP 8.2+
ext-openssl,ext-json
Installation
composer require mosesadewale/kora-php
Quick start
Get your API keys from the Kora dashboard. Use sk_test_ keys for sandbox and sk_live_ keys for production.
use Kora\Sdk\Factory; $kora = Factory::make( secretKey: getenv('KORA_SECRET_KEY') ?: '', encryptionKey: getenv('KORA_ENCRYPTION_KEY') ?: '', // required for card payments );
The SDK infers the environment from the secret key prefix: sk_test_ maps to sandbox and sk_live_ maps to live.
Webhook verification uses your Kora API secret key.
Configuration
use Kora\Sdk\Enums\Environment; use Kora\Sdk\Factory; $kora = Factory::make( secretKey: 'sk_live_...', encryptionKey: '...', // 32-byte key, required for card payments timeout: 30.0, // seconds, default 30 connectTimeout: 10.0, // seconds, default 10 retryAttempts: 3, // safe-read retries, default 3 );
If you want to be explicit, you can still pass environment: Environment::Live or Environment::Sandbox. A mismatch with the key prefix throws InvalidArgumentException at construction time.
Alternatively, build from a config object:
use Kora\Sdk\Factory; use Kora\Sdk\Support\KoraConfig; $config = new KoraConfig(secretKey: 'sk_live_...', retryAttempts: 5); $kora = Factory::fromConfig($config, logger: $psrLogger);
Resources
Charges
Amounts are validated as decimal strings with no more than two fractional digits. The SDK converts them to JSON numbers for Kora's documented Number fields; for example, 50000.50 may be serialized as 50000.5, because JSON does not preserve trailing zeroes. No floating-point arithmetic is used for validation or response mapping.
// Hosted Checkout Redirect $charge = $kora->charges()->checkout([ 'reference' => 'ref_' . uniqid(), 'amount' => '5000.00', 'currency' => 'NGN', 'customer' => ['email' => 'user@example.com', 'name' => 'Ada Okonkwo'], 'redirect_url' => 'https://yourapp.com/callback', ]); echo $charge->checkoutUrl; echo $charge->reference; echo $charge->status; // Verify a charge $charge = $kora->charges()->verify('ref_001');
Card charges
Card encryption requires
encryptionKeyto be exactly 32 bytes. ALogicExceptionis thrown if it is missing or empty.
Direct card payments require PCI DSS certification and Kora account enablement. Pass the complete payment object with card at the top level. The SDK encrypts the complete object transparently using Kora's AES-256-GCM format before sending it as charge_data.
$charge = $kora->charges()->card([ 'reference' => 'ref_' . uniqid(), 'amount' => 5000, 'currency' => 'NGN', 'customer' => ['email' => 'user@example.com'], 'card' => [ 'name' => 'Test Card', 'number' => '5399831111111111', 'cvv' => '100', 'expiry_month' => '10', 'expiry_year' => '31', 'pin' => '1234', ], ]); if ($charge->status === 'processing' && $charge->authModel === 'PIN') { $charge = $kora->charges()->authorize($charge->transactionReference, ['pin' => '1234']); } // For OTP, submit the collected OTP. // `$customerOtp` is collected from the customer or your payment provider. if ($charge->status === 'processing' && $charge->authModel === 'OTP') { $charge = $kora->charges()->authorize($charge->transactionReference, ['otp' => $customerOtp]); } // AVS address data belongs inside authorization.avs, not inside card. if ($charge->status === 'processing' && $charge->authModel === 'AVS') { $charge = $kora->charges()->authorize($charge->transactionReference, [ 'avs' => [ 'state' => 'Lagos', 'city' => 'Lekki', 'country' => 'Nigeria', 'address' => 'Osapa, Lekki', 'zip_code' => '101010', ], ]); } // CARD_ENROLL requires the phone tied to the cardholder's bank account. // Inspect the response afterward; Kora may request OTP as the next step. // `$customerPhone` should be collected from the customer. if ($charge->status === 'processing' && $charge->authModel === 'CARD_ENROLL') { $charge = $kora->charges()->authorize($charge->transactionReference, ['phone' => $customerPhone]); } if ($charge->status === 'processing' && $charge->authModel === 'OTP') { $charge = $kora->charges()->authorize($charge->transactionReference, ['otp' => $customerOtp]); } // For 3DS, redirect the customer to Kora's authorization URL. Do not call // charges()->authorize() for this model; verify the payment after redirect. if ($charge->status === 'processing' && $charge->authModel === '3DS' && $charge->redirectUrl !== null) { header('Location: ' . $charge->redirectUrl); exit; }
Mobile Money
$charge = $kora->mobileMoney()->charge([ 'reference' => 'ref_' . uniqid(), 'amount' => 5000, 'currency' => 'KES', 'customer' => ['email' => 'user@example.com'], 'mobile_money' => ['number' => '254712345678'], ]); // If Kora returns auth_model = OTP: $charge = $kora->mobileMoney()->authorize([ 'reference' => $charge->transactionReference, 'token' => '123456', ]);
Payouts
$payout = $kora->payouts()->disburse([ 'reference' => 'payout_' . uniqid(), 'destination' => [ 'type' => 'bank_account', 'amount' => 10000, 'currency' => 'NGN', 'narration' => 'Freelancer payment', 'bank_account' => ['bank' => '058', 'account' => '0123456789'], 'customer' => ['name' => 'Ada Okonkwo', 'email' => 'user@example.com'], ], ]); echo $payout->reference; echo $payout->status;
Remittance payouts
For remittance merchants, use the explicit remittance() method instead of the ordinary payout operation. Sandbox verification confirmed that this operation also uses the merchant API prefix:
$payout = $kora->payouts()->remittance($payload);
Bulk Payouts
$bulk = $kora->bulkPayouts()->disburse([ 'batch_reference' => 'bulk_' . uniqid(), 'currency' => 'NGN', 'payouts' => [ [ 'reference' => 'item_1', 'amount' => 5000, 'type' => 'bank_account', 'narration' => 'Freelancer payment', 'bank_account' => ['bank_code' => '058', 'account_number' => '0123456789'], 'customer' => ['name' => 'Ada Okonkwo', 'email' => 'one@example.com'], ], [ 'reference' => 'item_2', 'amount' => 3000, 'type' => 'bank_account', 'narration' => 'Freelancer payment', 'bank_account' => ['bank_code' => '033', 'account_number' => '9876543210'], 'customer' => ['name' => 'Bola Ahmed', 'email' => 'two@example.com'], ], ], ]); echo $bulk->reference; echo $bulk->totalAmount; // Retrieve individual payout items $items = $kora->bulkPayouts()->items($bulk->reference);
Balances
$balances = $kora->balances()->list(); foreach ($balances as $currency => $balance) { // The array key is the currency returned by Kora. echo $currency . ': ' . $balance['available_balance']; }
Conversions
// Fetch exchange rates $rates = $kora->conversions()->rates([ 'from_currency' => 'USD', 'to_currency' => 'NGN', 'amount' => 10, 'reference' => 'rate_' . uniqid(), ]); // Initiate a conversion $conversion = $kora->conversions()->initiate([ 'rate_reference' => $rates['reference'], 'from_currency' => 'USD', 'to_currency' => 'NGN', 'amount' => 10, 'customer_name' => 'Ada Okonkwo', 'customer_email' => 'user@example.com', ]); echo $conversion->sourceAmount; echo $conversion->destinationAmount; echo $conversion->sourceCurrency; echo $conversion->destinationCurrency;
Refunds
$refund = $kora->refunds()->initiate([ 'reference' => 'refund_' . uniqid(), 'payment_reference' => 'ref_001', 'amount' => 5000, ]); echo $refund->reference; echo $refund->status; // List refunds $refunds = $kora->refunds()->list();
Pool Accounts
$account = $kora->poolAccounts()->create([ 'customer_name' => 'Ada Okonkwo', 'customer_email' => 'user@example.com', 'currency' => 'KES', 'account_type' => 'bank_account', ]); echo $account->reference; echo $account->currency;
Chargebacks
// List chargebacks $chargebacks = $kora->chargebacks()->list(); // Accept a chargeback $result = $kora->chargebacks()->accept('chb_ref_001'); // Decline a chargeback with Kora's uploaded evidence reference $result = $kora->chargebacks()->decline('chb_ref_001', [ 'reason' => 'Item was delivered on 2026-04-10', 'evidence' => 'KPY-FILE-123', ]); // Or partially decline it: $result = $kora->chargebacks()->partial('chb_ref_001', [ 'reason' => 'Only part of the claim is valid', 'evidence' => 'KPY-FILE-123', 'approved_amount' => 500.50, ]);
Webhooks
$raw = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_KORAPAY_SIGNATURE'] ?? ''; if (!$kora->webhooks()->verify($raw, $signature)) { http_response_code(401); exit; } $event = $kora->webhooks()->parse($raw); match (\Kora\Sdk\Enums\WebhookEventType::tryFrom($event->type)) { \Kora\Sdk\Enums\WebhookEventType::ChargeSuccess => fulfillOrder($event->data['reference']), \Kora\Sdk\Enums\WebhookEventType::PayoutSuccess => markPaid($event->data['reference']), \Kora\Sdk\Enums\WebhookEventType::RefundSuccess => processRefund($event->data['reference']), default => null, }; http_response_code(200);
Supported event types:
| Constant | Kora event |
|---|---|
WebhookEventType::ChargeSuccess |
charge.success |
WebhookEventType::ChargeFailed |
charge.failed |
WebhookEventType::PayoutSuccess |
transfer.success |
WebhookEventType::PayoutFailed |
transfer.failed |
WebhookEventType::RefundSuccess |
refund.success |
WebhookEventType::RefundFailed |
refund.failed |
WebhookEventType::ChargebackCreated |
chargeback.created |
WebhookEventType::ChargebackWon |
chargeback.won |
WebhookEventType::ChargebackLost |
chargeback.lost |
Unknown future event types return null from tryFrom() and fall through to default.
Error handling
use Kora\Sdk\Exceptions\ApiException; use Kora\Sdk\Exceptions\AuthenticationException; use Kora\Sdk\Exceptions\DuplicateReferenceException; use Kora\Sdk\Exceptions\InsufficientFundsException; use Kora\Sdk\Exceptions\KoraException; use Kora\Sdk\Exceptions\NetworkException; use Kora\Sdk\Exceptions\ValidationException; try { $kora->payouts()->disburse($payload); } catch (DuplicateReferenceException $e) { // reference already used — check existing payout status } catch (InsufficientFundsException $e) { // wallet balance too low } catch (ValidationException $e) { // Kora may return field-level errors in either errors or data. logger()->warning('Kora validation', $e->errors()); } catch (AuthenticationException $e) { // invalid or revoked secret key } catch (ApiException $e) { // 5xx from Kora — $e->context() returns the raw response body logger()->error('Kora server error', ['context' => $e->context()]); } catch (NetworkException $e) { // connectivity, timeout, or non-JSON response } catch (KoraException $e) { // catch-all for any other SDK exception }
| Exception | HTTP status | Notes |
|---|---|---|
AuthenticationException |
401 | Invalid or revoked key |
ValidationException |
400 | errors() returns field-level detail |
InsufficientFundsException |
400 | Wallet balance too low |
DuplicateReferenceException |
409 | |
ApiException |
other HTTP errors | context() includes status, code, data, and raw response body |
NetworkException |
— | Connectivity, timeout, or non-JSON response |
WebhookException |
— | Invalid payload or signature; thrown by verifyThenParse() |
Testing
Keep the SDK behind an application-owned payment interface and mock that narrow
interface in domain tests. For SDK integration tests, inject a recording
HttpClientInterface with Factory::withClient() so no network or real
credentials are involved. Resource classes are final and are not mock seams.
$http = new RecordingHttpClient(['data' => [ 'reference' => 'ref_001', 'checkout_url' => 'https://checkout.korapay.com/ref_001', ]]); $kora = Factory::withClient($http, new KoraConfig('sk_test_example')); $charge = $kora->charges()->checkout($payload);
Laravel
See mosesadewale/kora-laravel for the Laravel service provider, facade, and optional webhook receiver. The Laravel package verifies and dispatches a generic webhook event; your app owns event-specific jobs and listeners.
License
MIT