Search by

mosesadewale / kora-php

mosesadewale

Framework-agnostic PHP SDK for the Kora payment API (formerly KoraPay).

Package info

github.com/mosesadewale/kora-php

pkg:composer/mosesadewale/kora-php

Statistics

Installs: 30

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v3.0.0 2026-09-07 16:14 UTC

This package is auto-updated.

Last update: 2026-09-07 23:48:00 UTC


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 encryptionKey to be exactly 32 bytes. A LogicException is 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