thekiharani / laravel-payments
Laravel SDK for payment integrations: M-PESA Daraja, SasaPay, KCB Buni, and Paystack.
Requires
- php: ^8.2
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.29
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
README
Laravel package for payment providers:
- M-PESA Daraja
- SasaPay v1 merchant APIs
- SasaPay Wallet as a Service (WAAS) v2 APIs
- KCB Buni APIs
- Paystack APIs
The package is a Laravel-native HTTP SDK. It registers container bindings, publishes config, obtains and caches OAuth tokens where providers require them, sends authenticated requests, supports retries and hooks, verifies SasaPay callbacks, KCB Buni IPNs, and Paystack webhooks, and throws typed exceptions for HTTP and network failures.
It does not persist transactions, define your application callback controllers, reconcile settlements, or transform provider callback payloads. Your application owns those concerns.
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
Installation
composer require thekiharani/laravel-payments
The service provider is auto-discovered. Publish the config:
php artisan vendor:publish --tag="payments-config"
Bindings
The package registers:
NoriaLabs\Payments\PaymentsManagerNoriaLabs\Payments\MpesaClientNoriaLabs\Payments\SasaPayClientNoriaLabs\Payments\SasaPayCallbackVerifierNoriaLabs\Payments\KcbBuniClientNoriaLabs\Payments\KcbBuniIpnVerifierNoriaLabs\Payments\PaystackClientNoriaLabs\Payments\PaystackWebhookVerifier
It also registers the facade alias:
Payments
And these route middleware aliases:
kcb-buni.ipn→VerifyKcbBuniIpnsasapay.callback→VerifySasaPayCallbackpaystack.webhook→VerifyPaystackWebhook
Config
Published config file: config/payments.php
Top-level sections:
httpmpesasasapaykcb_bunipaystack
Shared HTTP Config
| Key | Description |
|---|---|
timeout_seconds |
Default request timeout. |
default_headers |
Headers applied to every provider request. |
user_agent |
Optional User-Agent fallback applied when default_headers does not already include one. |
cache_store |
Optional Laravel cache store for provider OAuth tokens. Use true or default for the default store. Leave unset to use only per-client in-memory token caching. |
cache_ttl_seconds |
Optional OAuth-token cache TTL override. When omitted, token expires_in is used. |
retry.max_attempts |
Total attempts including the first request. |
retry.retry_methods |
Methods eligible for retry, for example POST. Empty means all methods. |
retry.retry_on_statuses |
HTTP statuses eligible for retry. |
retry.retry_on_network_error |
Whether connection failures/timeouts are retried. |
retry.base_delay_seconds |
Initial retry delay. |
retry.max_delay_seconds |
Maximum retry delay. |
retry.backoff_multiplier |
Retry delay multiplier. |
retry.jitter_seconds |
Maximum random jitter added to computed backoff delays. |
retry.respect_retry_after |
Whether retryable HTTP responses should honor a Retry-After header before using configured backoff. |
M-PESA Config
| Key | Description |
|---|---|
environment |
sandbox or production. Any other value requires an explicit base_url, otherwise the client throws ConfigurationException. |
base_url |
Optional full base URL override. |
throw_on_business_error |
Throw BusinessException when Daraja answers HTTP 200 with an errorCode or a non-zero ResponseCode/ResultCode. Defaults to false. See Business-Level Failures. |
consumer_key |
Daraja consumer key. |
consumer_secret |
Daraja consumer secret. |
token_cache_skew_seconds |
Refresh token before expiry by this many seconds. |
b2c_version |
Default B2C payment API version. Defaults to v1; set MPESA_B2C_VERSION=v3 only when your Daraja app is enabled for the v3 B2C path. |
amount_normalization |
M-PESA amount handling. Defaults to string; set to none to preserve raw numeric Amount/amount values. |
cache_store |
Optional M-PESA-specific token cache store override. |
cache_ttl_seconds |
Optional M-PESA-specific token cache TTL override. |
endpoints |
Optional endpoint-path overrides keyed by MpesaClient::ENDPOINTS. Useful when Safaricom enables tenant-specific or newer product paths. |
SasaPay Config
| Key | Description |
|---|---|
environment |
sandbox or production. Any other value requires an explicit base_url. |
base_url |
SasaPay v1 base URL. Defaults to https://sandbox.sasapay.app/api/v1 in sandbox and https://api.sasapay.app/api/v1 in production. |
waas_base_url |
SasaPay WAAS v2 base URL. Defaults to https://sandbox.sasapay.app/api/v2/waas in sandbox and https://api.sasapay.app/api/v2/waas in production. |
token_url |
Optional full SasaPay OAuth URL. Defaults to /oauth/v1/generate on the v1 base URL host. |
waas_token_url |
Optional full WAAS OAuth URL. Defaults to token_url, then /oauth/v1/generate on the WAAS base URL host. |
throw_on_business_error |
Throw BusinessException when SasaPay answers HTTP 200 with "status": false. Defaults to false. See Business-Level Failures. |
client_id |
SasaPay v1 client ID. Also used for WAAS unless WAAS-specific credentials are configured. |
client_secret |
SasaPay v1 client secret. Also used for WAAS unless WAAS-specific credentials are configured. |
waas_client_id |
Optional WAAS-specific client ID. |
waas_client_secret |
Optional WAAS-specific client secret. |
token_cache_skew_seconds |
v1 token cache skew. |
waas_token_cache_skew_seconds |
WAAS token cache skew. |
cache_store |
Optional SasaPay-specific token cache store override. |
cache_ttl_seconds |
Optional SasaPay-specific token cache TTL override. |
amount_normalization |
SasaPay amount handling. Defaults to string; set to none to preserve raw numeric Amount/amount values. |
payment_defaults |
Optional v1 defaults for MerchantCode, Currency, and CallBackURL. Defaults are added only when the payload omits the key. |
waas_payment_defaults |
Optional WAAS defaults for merchantCode, currencyCode, and callbackUrl. Defaults are added only when the payload omits the key. |
endpoints |
Optional SasaPay v1 endpoint-path overrides keyed by SasaPayClient::ENDPOINTS. |
waas_endpoints |
Optional SasaPay WAAS endpoint-path overrides keyed by SasaPayClient::WAAS_ENDPOINTS. |
callback_security.secret_key |
HMAC secret for inbound callbacks. Defaults to the SasaPay client ID, as documented by SasaPay. |
callback_security.trusted_ips |
SasaPay callback source IP allowlist. Defaults to the documented SasaPay list. Override in published config or with comma-separated SASAPAY_CALLBACK_TRUSTED_IPS. |
callback_security.enforce_ip_whitelist |
Reject callbacks from non-allowlisted IPs when using verifyRequest() or the middleware. Defaults to false; enable it after Laravel trusted proxy handling is configured for your deployment. |
callback_security.verify_signature |
Verify callback HMAC signatures when using verifyRequest() or the middleware. Defaults to true; set SASAPAY_CALLBACK_VERIFY_SIGNATURE=false only if you intentionally rely on a different callback-authentication control. |
SasaPay documents OAuth client-credentials authentication at
/oauth/v1/generate. The client derives that endpoint from the configured API
host. Override token_url / waas_token_url if SasaPay issues your application
a different authentication host.
KCB Buni Config
| Key | Description |
|---|---|
environment |
uat (default) or production. Anything else requires an explicit base_url. |
base_url |
Optional full base URL override. |
validate_payloads |
Validate outbound payloads against the constraints published in Buni's OpenAPI documents before sending. Defaults to true. See KCB Buni Payload Validation. |
throw_on_business_error |
Throw BusinessException when Buni answers HTTP 200 with a non-zero status. Defaults to false. See Business-Level Failures. |
token_url |
Optional full OAuth token URL override. |
token_path |
Token path used with base_url when token_url is unset. Defaults to /token. |
consumer_key |
Buni application consumer key. |
consumer_secret |
Buni application consumer secret. |
api_key |
Optional WSO2 apikey header value when your subscribed API requires it. The verified M-PESA Express Postman collection used bearer auth without an apikey header. |
token_cache_skew_seconds |
Refresh token before expiry by this many seconds. |
amount_normalization |
KCB Buni M-PESA Express amount handling. Defaults to string; set to none to preserve raw numeric amount values. |
cache_store |
Optional KCB Buni-specific token cache store override. |
cache_ttl_seconds |
Optional KCB Buni-specific token cache TTL override. |
endpoints |
Optional endpoint-path overrides keyed by KcbBuniClient::ENDPOINTS. |
mpesa_express.route_code |
Required routeCode header for mpesaStkPush() unless passed per call. Buni's M-PESA Express docs show 207 for M-PESA. |
mpesa_express.operation |
operation header for mpesaStkPush(). Defaults to the documented STKPush. |
ipn_security.public_key |
KCB public key used to verify inbound IPN Signature headers with SHA256withRSA. |
ipn_security.trusted_ips |
Optional KCB Buni IPN source IP allowlist. The verified public docs specify signature verification but do not publish a fixed IP list. |
ipn_security.enforce_ip_whitelist |
Reject IPNs from non-allowlisted IPs when using verifyRequest() or the middleware. Defaults to false. |
ipn_security.verify_signature |
Verify the IPN Signature header over the raw request body. Defaults to true. |
KCB Buni token acquisition is POST /token with HTTP Basic client credentials and grant_type=client_credentials as form data. This was verified against the UAT token endpoint: GET returns HTTP 405, while POST reaches OAuth client validation. Buni also accepts grant_type as a query parameter; the form-encoded body used here works on both hosts.
KCB Buni hosts
| Environment | Host | How it was established |
|---|---|---|
uat |
https://uat.buni.kcbgroup.com |
Published on the Buni DevPortal. |
production |
https://api.buni.kcbgroup.com |
Not published by KCB. Determined by probing the live gateway: /token returns the same OAuth client-validation response as UAT, and /mm/api/request/1.0.0/stkpush returns the same WSO2 gateway response. |
Because the production host is inferred rather than documented, confirm it with
KCB before moving real money, or pin it yourself with base_url. Setting
KCB_BUNI_ENVIRONMENT=production is a deliberate opt-in; every other environment
name still fails fast with a ConfigurationException.
Paystack Config
| Key | Description |
|---|---|
base_url |
Paystack API base URL. Defaults to https://api.paystack.co. Paystack uses your API key to determine test vs live mode. |
secret_key |
Paystack secret key used as the bearer token. |
public_key |
Optional Paystack public key. Used by requeryCapitecPayCharge(), which Paystack authorises with the public key rather than the secret key. |
throw_on_business_error |
Throw BusinessException when Paystack answers HTTP 200 with "status": false. Defaults to false. See Business-Level Failures. |
endpoints |
Optional endpoint-path overrides keyed by PaystackClient::ENDPOINTS. |
webhook_security.secret_key |
HMAC secret for inbound webhooks. Defaults to PAYSTACK_SECRET_KEY. |
webhook_security.trusted_ips |
Paystack webhook source IP allowlist. Defaults to the documented Paystack list. Override in published config or with comma-separated PAYSTACK_WEBHOOK_TRUSTED_IPS. |
webhook_security.enforce_ip_whitelist |
Reject webhooks from non-allowlisted IPs when using verifyRequest() or the middleware. Defaults to false; enable it after Laravel trusted proxy handling is configured for your deployment. |
webhook_security.verify_signature |
Verify x-paystack-signature HMAC signatures when using verifyRequest() or the middleware. Defaults to true. |
Usage
M-PESA
use NoriaLabs\Payments\MpesaClient; $mpesa = app(MpesaClient::class); $timestamp = MpesaClient::buildTimestamp(); $response = $mpesa->stkPush([ 'BusinessShortCode' => '174379', 'Password' => MpesaClient::buildStkPassword('174379', config('services.mpesa.passkey'), $timestamp), 'Timestamp' => $timestamp, 'TransactionType' => 'CustomerPayBillOnline', 'Amount' => 1, 'PartyA' => '254700000000', 'PartyB' => '174379', 'PhoneNumber' => '254700000000', 'CallBackURL' => 'https://example.com/mpesa/callback', 'AccountReference' => 'INV-001', 'TransactionDesc' => 'Payment', ]);
Kenyan mobile numbers supplied to M-PESA payment requests are normalized automatically. Daraja
stkPush(), SasaPay requestPayment() / waasRequestPayment(), and KCB Buni mpesaStkPush()
accept local 07XXXXXXXX and 01XXXXXXXX numbers, unprefixed numbers, and formatted +254
numbers. They are sent to the provider as 2547XXXXXXXX or 2541XXXXXXXX.
SasaPay v1 C2B
use NoriaLabs\Payments\SasaPayClient; $sasapay = app(SasaPayClient::class); $response = $sasapay->requestPayment([ 'MerchantCode' => '600980', 'NetworkCode' => '63902', 'Currency' => 'KES', 'Amount' => '1.00', 'PhoneNumber' => '254700000080', 'AccountReference' => '12345678', 'TransactionDesc' => 'Request Payment', 'CallBackURL' => 'https://example.com/sasapay/callback', ]);
SasaPay WAAS Request Payment
use NoriaLabs\Payments\SasaPayClient; $sasapay = app(SasaPayClient::class); $response = $sasapay->waasRequestPayment([ 'merchantReference' => 'TOPUP-001', 'merchantCode' => '600980', 'networkCode' => '63902', 'mobileNumber' => '254700000080', 'receiverAccountNumber' => '600980-1', 'amount' => '50', 'transactionFee' => '0', 'currencyCode' => 'KES', 'transactionDesc' => 'Wallet topup', 'callbackUrl' => 'https://example.com/sasapay/waas/callback', ]);
KCB Buni M-PESA Express
use NoriaLabs\Payments\KcbBuniClient; $buni = app(KcbBuniClient::class); $response = $buni->mpesaStkPush([ 'phoneNumber' => '254722000000', 'amount' => '10', 'invoiceNumber' => '1234567-INV001', 'sharedShortCode' => true, 'orgShortCode' => '', 'orgPassKey' => '', 'callbackUrl' => 'https://example.com/kcb-buni/stk-callback', 'transactionDescription' => 'school fees', ], messageId: '232323_KCBOrg_8875661561', routeCode: '207');
The payload is validated against the constraints in Buni's own M-PESA Express
schema before it is sent — transactionDescription is capped at 13 characters,
phoneNumber must be 2547XXXXXXXX, messageId at 32, and so on. See
KCB Buni Payload Validation.
callbackUrlis not the IPN endpoint. The route you pass here receives Safaricom's Daraja-shaped STK result (Body.stkCallback) relayed by KCB. It is unsigned — it carries noSignatureheader — so do not put theVerifyKcbBuniIpnmiddleware on it. Instant Payment Notifications are a separate, signed API on separate routes; see KCB Buni IPN Security.
KCB Buni Funds Transfer
use NoriaLabs\Payments\KcbBuniClient; $buni = app(KcbBuniClient::class); $response = $buni->transferFunds([ 'companyCode' => 'KE0010001', 'transactionType' => 'IF', 'debitAccountNumber' => '37890012', 'creditAccountNumber' => '909099090', 'debitAmount' => 10, 'paymentDetails' => 'fee payment', 'transactionReference' => 'MHSGS7883', 'currency' => 'KES', 'beneficiaryDetails' => 'JOHN DOE', ]);
Paystack Initialize Transaction
use NoriaLabs\Payments\PaystackClient; $paystack = app(PaystackClient::class); $response = $paystack->initializeTransaction([ 'email' => 'customer@example.com', 'amount' => 10000, 'currency' => 'NGN', 'reference' => 'INV-001', 'callback_url' => 'https://example.com/paystack/callback', ]);
Paystack Webhook Security
Paystack documents two webhook-origin controls:
- verify the
x-paystack-signatureheader with HMAC-SHA512 over the raw request body - verify the request source IP against the Paystack allowlist
Use the middleware on your webhook route:
use NoriaLabs\Payments\Http\Middleware\VerifyPaystackWebhook; Route::post('/paystack/webhook', PaystackWebhookController::class) ->middleware(VerifyPaystackWebhook::class);
Or verify manually:
use Illuminate\Http\Request; use NoriaLabs\Payments\PaystackWebhookVerifier; public function __invoke(Request $request, PaystackWebhookVerifier $verifier) { if (! $verifier->verifyRequest($request, enforceIpWhitelist: true, verifySignature: true)) { abort(403); } // Process the already-authenticated webhook payload. }
SasaPay Callback Security
SasaPay documents two callback/IPN controls:
- verify the request source IP against the SasaPay allowlist
- verify the callback signature with HMAC-SHA512
The signed message format is:
sasapay_transaction_code-merchant_code-account_number-payment_reference-amount
The HMAC secret is the Merchant API Client ID unless you override payments.sasapay.callback_security.secret_key. Signature verification and IP allowlisting are independent controls:
SASAPAY_CALLBACK_VERIFY_SIGNATURE=true|falseSASAPAY_CALLBACK_ENFORCE_IP_WHITELIST=true|falseSASAPAY_CALLBACK_TRUSTED_IPS=203.0.113.10,198.51.100.25
Use the middleware on your callback route:
use NoriaLabs\Payments\Http\Middleware\VerifySasaPayCallback; Route::post('/sasapay/callback', SasaPayCallbackController::class) ->middleware(VerifySasaPayCallback::class);
Or verify manually:
use Illuminate\Http\Request; use NoriaLabs\Payments\SasaPayCallbackVerifier; public function __invoke(Request $request, SasaPayCallbackVerifier $verifier) { if (! $verifier->verifyRequest($request, enforceIpWhitelist: true, verifySignature: true)) { abort(403); } // Process the already-authenticated callback payload. }
The verifier accepts documented SasaPay callback field names only; it does not infer case variants or provider-specific names that are not in SasaPay's published callback examples. The documented signature field is sasapay_signature; if your application receives the signature through another transport, pass it explicitly to verify($payload, signature: $value).
Canonical callback fields include documented aliases across C2B, IPN, checkout/card, B2C, B2B, remittance, utilities, WAAS, and bulk status payloads:
| Canonical field | Documented aliases |
|---|---|
sasapay_transaction_code |
sasapay_transaction_code, TransactionCode, TransID, SasaPayTransactionCode |
sasapay_transaction_id |
SasaPayTransactionID |
third_party_transaction_id |
ThirdPartyTransID, ThirdPartyTransactionCode, third_party_transaction_code |
merchant_code |
merchant_code, merchantCode, MerchantCode, BusinessShortCode |
account_number |
account_number, accountNumber, AccountNumber, CustomerMobile, MSISDN, RecipientAccountNumber, BeneficiaryAccountNumber, SenderAccountNumber, ContactNumber, DestinationAccountNumber |
checkout_request_id |
CheckoutRequestID, CheckoutRequestId, checkoutRequestId |
payment_reference |
payment_reference, BillRefNumber, InvoiceNumber, MerchantReference, merchantReference, MerchantTransactionReference, TransactionReference, transactionReference, PaymentRequestID, MerchantRequestID, bulk_payment_reference |
amount |
amount, TransactionAmount, TransAmount, AmountPaid, PaidAmount, Amount, RequestedAmount |
third_party_transaction_id and sasapay_transaction_id are intentionally not treated as SasaPay transaction-code aliases. Amount formatting is part of the signature input, so keep the exact provider value, for example 1500.00.
KCB Buni IPN Security
Buni's InstantPaymentNotification API (context /ipn, version 1.0.0) defines
three inbound routes that KCB calls on your host. They do not share one
contract:
| Buni route | Signature header |
Body | Response you must return |
|---|---|---|---|
/till-notification |
required | nested header + requestPayload.additionalData.notificationData |
header + responsePayload.transactionInfo |
/account-notification |
required | flat transaction fields | transactionID, statusCode, statusMessage |
/validation |
not sent | requestId, customerReference, organizationReference |
the above plus optional CustomerName, billAmount, currency, billType, creditAccountIdentifier |
The Signature header is a base64 SHA256withRSA signature of the raw request
body, signed by KCB and verified with the KCB public key.
Because /validation carries no signature, the middleware takes per-route
options. Applying the default middleware there would reject every validation
request with HTTP 403:
use NoriaLabs\Payments\Http\Middleware\VerifyKcbBuniIpn; // Signed notification routes — default configuration. Route::post('/kcb-buni/ipn/till', TillNotificationController::class) ->middleware(VerifyKcbBuniIpn::class); Route::post('/kcb-buni/ipn/account', AccountNotificationController::class) ->middleware(VerifyKcbBuniIpn::class); // Unsigned validation route — opt out of signature verification. Route::post('/kcb-buni/ipn/validation', ValidationController::class) ->middleware('kcb-buni.ipn:no-signature');
The package registers the aliases kcb-buni.ipn, sasapay.callback and
paystack.webhook. All three accept the same options, and several can be
combined:
| Option | Effect |
|---|---|
signature |
Force signature verification on for this route. |
no-signature |
Skip signature verification for this route. |
ip |
Enforce the configured source-IP allowlist for this route. |
no-ip |
Skip the source-IP allowlist for this route. |
Route::post('/kcb-buni/ipn/validation', ValidationController::class) ->middleware('kcb-buni.ipn:no-signature,ip');
An unrecognised option throws ConfigurationException rather than being silently
ignored.
Or verify manually:
use Illuminate\Http\Request; use NoriaLabs\Payments\KcbBuniIpnVerifier; public function __invoke(Request $request, KcbBuniIpnVerifier $verifier) { if (! $verifier->verifyRequest($request, enforceIpWhitelist: true, verifySignature: true)) { abort(403); } // Process the already-authenticated IPN payload. }
Reading IPN payloads and building acknowledgements
NoriaLabs\Payments\Support\KcbBuniIpn models the three contracts so you do not
have to hand-assemble the nested till envelope or remember which acknowledgement
shape each route expects.
use NoriaLabs\Payments\Support\KcbBuniIpn; public function __invoke(Request $request) { $payload = $request->all(); return match (KcbBuniIpn::type($payload)) { // Nested envelope: transaction fields live under // requestPayload.additionalData.notificationData KcbBuniIpn::TYPE_TILL => response()->json( KcbBuniIpn::tillAcknowledgement( $payload, $this->recordTill(KcbBuniIpn::tillNotificationData($payload)), ) ), // Flat envelope KcbBuniIpn::TYPE_ACCOUNT => response()->json( KcbBuniIpn::accountAcknowledgement($this->recordAccount($payload)) ), KcbBuniIpn::TYPE_VALIDATION => response()->json( KcbBuniIpn::validationResponse('LOCAL-1', [ 'CustomerName' => 'JOHN DOE', 'billAmount' => '1500.00', 'currency' => 'KES', ]) ), default => response()->json( KcbBuniIpn::rejection($payload, '1', 'Unrecognised notification'), 400 ), }; }
KcbBuniIpn::rejection() mirrors whichever contract the inbound payload belongs
to, so a rejection is shaped correctly without a second branch.
Manager Usage
Use the manager when you want custom runtime clients instead of the default container bindings:
use NoriaLabs\Payments\PaymentsManager; $manager = app(PaymentsManager::class); $sasapay = $manager->sasapay([ 'environment' => 'production', 'base_url' => 'https://your-confirmed-production-host/api/v1', 'waas_base_url' => 'https://your-confirmed-production-host/api/v2/waas', 'default_headers' => [ 'X-App-Name' => 'billing', ], ]); $paystack = $manager->paystack([ 'secret_key' => config('services.paystack.secret_key'), ]); $buni = $manager->kcbBuni([ 'base_url' => 'https://your-confirmed-buni-production-host', 'consumer_key' => config('services.kcb_buni.consumer_key'), 'consumer_secret' => config('services.kcb_buni.consumer_secret'), ]);
KCB Buni Coverage
The KCB Buni client keeps Buni field names exactly as documented. It does not translate callbackUrl, transactionReference, or nested request payloads. For mpesaStkPush(), it normalizes Kenyan phoneNumber values to 2547XXXXXXXX or 2541XXXXXXXX and string-casts amount by default, matching the Buni M-PESA Express schema. Set amount_normalization to none when you need to preserve raw JSON number types. Note that debitAmount on transferFunds() is never stringified — Buni's Funds Transfer schema types it as a JSON number.
KCB Buni Payload Validation
mpesaStkPush() and transferFunds() validate their payloads against the
constraints published in Buni's own OpenAPI documents before the request leaves
your app, so an over-long or malformed field fails locally with a precise message
instead of a generic gateway rejection. The rule sets are public constants —
KcbBuniClient::MPESA_STK_PUSH_RULES, ::MPESA_STK_PUSH_HEADER_RULES and
::FUNDS_TRANSFER_RULES.
use NoriaLabs\Payments\Exceptions\ValidationException; try { $buni->mpesaStkPush($payload, messageId: $id, routeCode: '207'); } catch (ValidationException $e) { $e->getMessage(); // "KCB Buni M-PESA Express payload is invalid: [transactionDescription] must not exceed 13 characters, got 21." $e->errors; // ['[transactionDescription] must not exceed 13 characters, got 21.'] }
Required fields are checked for presence rather than non-emptiness where Buni's
schema allows a blank value — orgShortCode and orgPassKey must be present but
may be '' when sharedShortCode is true.
Turn it off globally with payments.kcb_buni.validate_payloads = false, or per
call when you need to send something the published schema does not describe:
$buni->transferFunds($payload, ['validate' => false]); $buni->transferFunds($payload, new RequestOptions(validate: false));
Validation never applies to authorizedPost() / authorizedGet().
KCB Buni Endpoint Provenance
Not every endpoint below comes from the same source, which matters when you subscribe an application on the Buni DevPortal:
- In the DevPortal API catalog (
MpesaExpressAPIService,FundsTransferAPIService,VENDINGGATEWAYAPIS,KCBKEeTIMSKraServices,KCBBIIpsP2PTransferStatusInquiry,InstantPaymentNotification) — subscribable, with published OpenAPI documents. - Live on the gateway but absent from the catalog:
queryCoreTransactionStatus()andqueryTransactionDetails(). Both resolve on the UAT gateway with the methods this package uses (POSTandGETrespectively, each returning HTTP 401 without credentials), but they are not listed as subscribable API products. A standard Buni application subscription may not grant your token access to them — ask KCB to enable them. - Not deployed on UAT:
p2pTransferStatusInquiry()is in the catalog but its UAT gateway route returns 404. Exercise it against the environment KCB enables for your subscription.
The DevPortal publishes UAT endpoint URLs only; see KCB Buni hosts for how the production host was established.
KCB Buni Auth and IPN
| API | Behavior |
|---|---|
KcbBuniClient::getAccessToken() |
Returns a Buni OAuth token from POST /token or a custom token-provider value. |
KcbBuniIpnVerifier::verify() |
Validates raw body/signature/IP checks according to configured or per-call toggles. |
KcbBuniIpnVerifier::verifyRequest() |
Extracts the raw body, Signature header, and IP from a Laravel request. |
KcbBuniIpnVerifier::isTrustedIp() |
Checks the configured KCB Buni IPN IP allowlist. |
KcbBuniIpnVerifier::verifiesSignature() |
Shows whether signature verification is enabled by default. |
VerifyKcbBuniIpn middleware |
Rejects invalid Laravel IPN requests with HTTP 403. Accepts per-route signature / no-signature / ip / no-ip options. |
KcbBuniIpn::type() |
Identifies which of the three inbound IPN contracts a payload belongs to. |
KcbBuniIpn::tillNotificationData() |
Reads requestPayload.additionalData.notificationData from a till notification. |
KcbBuniIpn::tillAcknowledgement() |
Builds the /till-notification acknowledgement, echoing the inbound messageID. |
KcbBuniIpn::accountAcknowledgement() |
Builds the /account-notification acknowledgement. |
KcbBuniIpn::validationResponse() |
Builds the /validation response, including the optional bill fields. |
KcbBuniIpn::rejection() |
Builds a non-zero-status response shaped to match the inbound contract. |
KCB Buni Outbound APIs
| Method | Endpoint | Source |
|---|---|---|
mpesaStkPush($payload, $messageId) |
POST /mm/api/request/1.0.0/stkpush |
DevPortal catalog |
transferFunds() |
POST /fundstransfer/1.0.0/api/v1/transfer |
DevPortal catalog |
vendingValidateRequest() |
POST /kcb/vendingGateway/v1/1.0.0/api/validate-request |
DevPortal catalog |
vendingVendorConfirmation() |
POST /kcb/vendingGateway/v1/1.0.0/api/vendor-confirmation |
DevPortal catalog |
vendingTransactionStatus() |
POST /kcb/vendingGateway/v1/1.0.0/api/query/transaction-status |
DevPortal catalog |
etimsRequest($path, $payload, $method) |
/kcb/ke/kra/etims/1.0.0/{path} |
DevPortal catalog (wildcard resource) |
p2pTransferStatusInquiry($payload, $path) |
POST /kcb/bi/ips/p2p/transfer/status/inquiry/1.0.0/{path} |
DevPortal catalog (not deployed on UAT) |
queryCoreTransactionStatus() |
POST /v1/core/t24/querytransaction/1.0.0/api/transactioninfo |
Live gateway, not in catalog |
queryTransactionDetails($identifier) |
GET /kcb/transaction/query/1.0.0/api/v1/payment/query/{identifier} |
Live gateway, not in catalog |
KCBKEeTIMSKraServices and KCBBIIpsP2PTransferStatusInquiry publish a single
wildcard resource with no request schema, so the concrete operation path and body
come from the integration pack KCB issues with your subscription:
$buni->etimsRequest('api/v1/sales', ['invoiceNumber' => 'INV-1']); $buni->etimsRequest('api/v1/sales/INV-1', method: 'GET', query: ['detail' => 'full']); $buni->p2pTransferStatusInquiry(['transactionReference' => 'FT000262556']);
The InstantPaymentNotification API is inbound-only — KCB calls your host. See
KCB Buni IPN Security.
KCB Buni Raw Authorized Helpers
Use these when KCB exposes an endpoint before this package has a named high-level method. They use the same token providers, retries, timeout handling, hooks, and exception mapping as the named APIs and do not normalize or rewrite payloads.
| Method | Behavior |
|---|---|
authorizedPost($path, $payload = []) |
POST on the configured Buni base URL with bearer auth. |
authorizedGet($path, $query = []) |
GET on the configured Buni base URL with bearer auth. |
Paystack Coverage
Paystack uses one API host for test and live mode: https://api.paystack.co. The secret key determines the environment. The client keeps Paystack field names and amount units exactly as Paystack documents them; pass amounts in provider subunits.
Paystack Auth and Webhooks
| API | Behavior |
|---|---|
PaystackClient::getAccessToken() |
Returns the configured secret key or custom token-provider value. |
PaystackClient::authorizedPost()/authorizedGet()/authorizedPut()/authorizedDelete() |
Raw bearer-authenticated helpers for Paystack endpoints not yet represented by named methods. Payloads and query keys are preserved. |
PaystackWebhookVerifier::expectedSignature() |
Computes the HMAC-SHA512 hex digest over the raw request body. |
PaystackWebhookVerifier::verify() |
Validates raw body/signature/IP checks according to configured or per-call toggles. |
PaystackWebhookVerifier::verifyRequest() |
Extracts the raw body, x-paystack-signature, and IP from a Laravel request. |
PaystackWebhookVerifier::isTrustedIp() |
Checks the documented Paystack webhook IP allowlist. |
PaystackWebhookVerifier::trustedIps() |
Returns the active Paystack webhook IP allowlist. |
PaystackWebhookVerifier::verifiesSignature() |
Shows whether signature verification is enabled by default. |
VerifyPaystackWebhook middleware |
Rejects invalid Laravel webhook requests with HTTP 403 according to webhook-security config. |
Paystack Transactions, Charge, Bulk Charge, Subaccounts, Splits
| Method | Endpoint |
|---|---|
initializeTransaction() |
POST /transaction/initialize |
chargeAuthorization() |
POST /transaction/charge_authorization |
partialDebit() |
POST /transaction/partial_debit |
verifyTransaction($reference) |
GET /transaction/verify/{reference} |
listTransactions() |
GET /transaction |
fetchTransaction($id) |
GET /transaction/{id} |
transactionTimeline($id) |
GET /transaction/timeline/{id} |
transactionTotals() |
GET /transaction/totals |
exportTransactions() |
GET /transaction/export |
createCharge() |
POST /charge |
submitChargePin() |
POST /charge/submit_pin |
submitChargeOtp() |
POST /charge/submit_otp |
submitChargePhone() |
POST /charge/submit_phone |
submitChargeBirthday() |
POST /charge/submit_birthday |
submitChargeAddress() |
POST /charge/submit_address |
checkPendingCharge($reference) |
GET /charge/{reference} |
requeryCapitecPayCharge($reference, $publicKey) |
POST /capitec-pay/requery/{ref} — authorised with your public key |
initiateBulkCharge() |
POST /bulkcharge |
listBulkChargeBatches() |
GET /bulkcharge |
fetchBulkChargeBatch($code) |
GET /bulkcharge/{code} |
fetchBulkChargeBatchCharges($code) |
GET /bulkcharge/{code}/charges |
pauseBulkChargeBatch($code) |
GET /bulkcharge/pause/{code} |
resumeBulkChargeBatch($code) |
GET /bulkcharge/resume/{code} |
createSubaccount() |
POST /subaccount |
listSubaccounts() |
GET /subaccount |
fetchSubaccount($code) |
GET /subaccount/{code} |
updateSubaccount($code) |
PUT /subaccount/{code} |
createSplit() |
POST /split |
listSplits() |
GET /split |
fetchSplit($id) |
GET /split/{id} |
updateSplit($id) |
PUT /split/{id} |
addSubaccountToSplit($id) |
POST /split/{id}/subaccount/add |
removeSubaccountFromSplit($id) |
POST /split/{id}/subaccount/remove |
Paystack Terminals
| Method | Endpoint |
|---|---|
sendTerminalEvent($id) |
POST /terminal/{id}/event |
fetchTerminalEventStatus($terminalId, $eventId) |
GET /terminal/{terminal_id}/event/{event_id} |
fetchTerminalStatus($terminalId) |
GET /terminal/{terminal_id}/presence |
listTerminals() |
GET /terminal |
fetchTerminal($terminalId) |
GET /terminal/{terminal_id} |
updateTerminal($terminalId) |
PUT /terminal/{terminal_id} |
commissionTerminal() |
POST /terminal/commission_device |
decommissionTerminal() |
POST /terminal/decommission_device |
createVirtualTerminal() |
POST /virtual_terminal |
listVirtualTerminals() |
GET /virtual_terminal |
fetchVirtualTerminal($code) |
GET /virtual_terminal/{code} |
updateVirtualTerminal($code) |
PUT /virtual_terminal/{code} |
deactivateVirtualTerminal($code) |
PUT /virtual_terminal/{code}/deactivate |
assignVirtualTerminalDestination($code) |
POST /virtual_terminal/{code}/destination/assign |
unassignVirtualTerminalDestination($code) |
POST /virtual_terminal/{code}/destination/unassign |
addVirtualTerminalSplitCode($code) |
PUT /virtual_terminal/{code}/split_code |
removeVirtualTerminalSplitCode($code) |
DELETE /virtual_terminal/{code}/split_code |
Paystack Customers, Direct Debit, Dedicated Accounts, Apple Pay
| Method | Endpoint |
|---|---|
createCustomer() |
POST /customer |
listCustomers() |
GET /customer |
fetchCustomer($code) |
GET /customer/{code} |
updateCustomer($code) |
PUT /customer/{code} |
setCustomerRiskAction() |
POST /customer/set_risk_action |
validateCustomer($code) |
POST /customer/{code}/identification |
initializeAuthorization() |
POST /customer/authorization/initialize |
verifyAuthorization($reference) |
GET /customer/authorization/verify/{reference} |
deactivateAuthorization() |
POST /customer/authorization/deactivate |
initializeDirectDebit($id) |
POST /customer/{id}/initialize-direct-debit |
customerDirectDebitActivationCharge($id) |
PUT /customer/{id}/directdebit-activation-charge |
customerDirectDebitMandateAuthorizations($id) |
GET /customer/{id}/directdebit-mandate-authorizations |
triggerDirectDebitActivationCharge() |
PUT /directdebit/activation-charge |
listDirectDebitMandateAuthorizations() |
GET /directdebit/mandate-authorizations |
createDedicatedAccount() |
POST /dedicated_account |
listDedicatedAccounts() |
GET /dedicated_account |
assignDedicatedAccount() |
POST /dedicated_account/assign |
fetchDedicatedAccount($id) |
GET /dedicated_account/{id} |
deactivateDedicatedAccount($id) |
DELETE /dedicated_account/{id} |
requeryDedicatedAccount() |
GET /dedicated_account/requery |
splitDedicatedAccountTransaction() |
POST /dedicated_account/split |
removeSplitFromDedicatedAccount() |
DELETE /dedicated_account/split |
fetchDedicatedAccountProviders() |
GET /dedicated_account/available_providers |
registerApplePayDomain() |
POST /apple-pay/domain |
listApplePayDomains() |
GET /apple-pay/domain |
unregisterApplePayDomain() |
DELETE /apple-pay/domain |
Paystack Plans, Subscriptions, Transfers
| Method | Endpoint |
|---|---|
createPlan() |
POST /plan |
listPlans() |
GET /plan |
fetchPlan($code) |
GET /plan/{code} |
updatePlan($code) |
PUT /plan/{code} |
createSubscription() |
POST /subscription |
listSubscriptions() |
GET /subscription |
fetchSubscription($code) |
GET /subscription/{code} |
disableSubscription() |
POST /subscription/disable |
enableSubscription() |
POST /subscription/enable |
subscriptionManagementLink($code) |
GET /subscription/{code}/manage/link |
sendSubscriptionManagementEmail($code) |
POST /subscription/{code}/manage/email |
createTransferRecipient() |
POST /transferrecipient |
listTransferRecipients() |
GET /transferrecipient |
bulkCreateTransferRecipients() |
POST /transferrecipient/bulk |
fetchTransferRecipient($code) |
GET /transferrecipient/{code} |
updateTransferRecipient($code) |
PUT /transferrecipient/{code} |
deleteTransferRecipient($code) |
DELETE /transferrecipient/{code} |
initiateTransfer() |
POST /transfer |
listTransfers() |
GET /transfer |
finalizeTransfer() |
POST /transfer/finalize_transfer |
initiateBulkTransfer() |
POST /transfer/bulk |
fetchTransfer($code) |
GET /transfer/{code} |
verifyTransfer($reference) |
GET /transfer/verify/{reference} |
exportTransfers() |
GET /transfer/export |
resendTransferOtp() |
POST /transfer/resend_otp |
disableTransferOtp() |
POST /transfer/disable_otp |
finalizeDisableTransferOtp() |
POST /transfer/disable_otp_finalize |
enableTransferOtp() |
POST /transfer/enable_otp |
balance() |
GET /balance |
balanceLedger() |
GET /balance/ledger |
Paystack Payment Requests, Products, Storefronts, Orders, Pages
| Method | Endpoint |
|---|---|
createPaymentRequest() |
POST /paymentrequest |
listPaymentRequests() |
GET /paymentrequest |
fetchPaymentRequest($id) |
GET /paymentrequest/{id} |
updatePaymentRequest($id) |
PUT /paymentrequest/{id} |
verifyPaymentRequest($id) |
GET /paymentrequest/verify/{id} |
notifyPaymentRequest($id) |
POST /paymentrequest/notify/{id} |
paymentRequestTotals() |
GET /paymentrequest/totals |
finalizePaymentRequest($id) |
POST /paymentrequest/finalize/{id} |
archivePaymentRequest($id) |
POST /paymentrequest/archive/{id} |
createProduct() |
POST /product |
listProducts() |
GET /product |
fetchProduct($id) |
GET /product/{id} |
updateProduct($id) |
PUT /product/{id} |
deleteProduct($id) |
DELETE /product/{id} |
createStorefront() |
POST /storefront |
listStorefronts() |
GET /storefront |
fetchStorefront($id) |
GET /storefront/{id} |
updateStorefront($id) |
PUT /storefront/{id} |
deleteStorefront($id) |
DELETE /storefront/{id} |
verifyStorefront($slug) |
GET /storefront/verify/{slug} |
listStorefrontOrders($id) |
GET /storefront/{id}/order |
addStorefrontProducts($id) |
POST /storefront/{id}/product |
listStorefrontProducts($id) |
GET /storefront/{id}/product |
publishStorefront($id) |
POST /storefront/{id}/publish |
duplicateStorefront($id) |
POST /storefront/{id}/duplicate |
createOrder() |
POST /order |
listOrders() |
GET /order |
fetchOrder($id) |
GET /order/{id} |
listProductOrders($id) |
GET /order/product/{id} |
validateOrder($code) |
GET /order/{code}/validate |
createPage() |
POST /page |
listPages() |
GET /page |
fetchPage($id) |
GET /page/{id} |
updatePage($id) |
PUT /page/{id} |
checkSlugAvailability($slug) |
GET /page/check_slug_availability/{slug} |
addProductsToPage($id) |
POST /page/{id}/product |
Paystack Settlements, Integration, Refunds, Disputes, Verification
| Method | Endpoint |
|---|---|
listSettlements() |
GET /settlement |
listSettlementTransactions($id) |
GET /settlement/{id}/transactions |
fetchPaymentSessionTimeout() |
GET /integration/payment_session_timeout |
updatePaymentSessionTimeout() |
PUT /integration/payment_session_timeout |
createRefund() |
POST /refund |
listRefunds() |
GET /refund |
retryRefundWithCustomerDetails($id) |
POST /refund/retry_with_customer_details/{id} |
fetchRefund($id) |
GET /refund/{id} |
listDisputes() |
GET /dispute |
fetchDispute($id) |
GET /dispute/{id} |
updateDispute($id) |
PUT /dispute/{id} |
disputeUploadUrl($id) |
GET /dispute/{id}/upload_url |
exportDisputes() |
GET /dispute/export |
transactionDisputes($id) |
GET /dispute/transaction/{id} |
resolveDispute($id) |
PUT /dispute/{id}/resolve |
addDisputeEvidence($id) |
POST /dispute/{id}/evidence |
listBanks() |
GET /bank |
resolveBankAccount() |
GET /bank/resolve |
validateBankAccount() |
POST /bank/validate |
resolveCardBin($bin) |
GET /decision/bin/{bin} |
listCountries() |
GET /country |
listAddressVerificationStates() |
GET /address_verification/states |
SasaPay Coverage
The SasaPay client intentionally keeps provider field names as documented. It accepts raw arrays and does not translate MerchantCode to merchantCode, CallBackURL to callbackUrl, or similar. Pass the exact payload expected by the specific SasaPay endpoint.
Methods return parsed JSON or text responses. HTTP 4xx/5xx responses throw ApiException. A SasaPay business failure returned with HTTP 200 is returned to you as the provider sent it, because SasaPay uses fields such as status, responseCode, ResponseCode, and statusCode differently across endpoints.
SasaPay amount fields are string-cast by default for backward compatibility. Pass new RequestOptions(amountNormalization: 'none') or set sasapay.amount_normalization to none when an endpoint requires numeric JSON values.
SasaPay Callback Security
| API | Behavior |
|---|---|
SasaPayCallbackVerifier::message() |
Builds the documented sasapay_transaction_code-merchant_code-account_number-payment_reference-amount message. |
SasaPayCallbackVerifier::expectedSignature() |
Computes the HMAC-SHA512 hex digest. |
SasaPayCallbackVerifier::verify() |
Validates payload/signature/IP checks according to configured or per-call toggles. |
SasaPayCallbackVerifier::verifyRequest() |
Extracts payload, signature, and IP from a Laravel request, then applies the configured or per-call toggles. |
SasaPayCallbackVerifier::callbackValue() |
Reads a canonical callback field from any supported alias, for example third_party_transaction_id. |
SasaPayCallbackVerifier::fieldAliases() |
Returns supported aliases for a canonical callback field. |
SasaPayCallbackVerifier::isTrustedIp() |
Checks the documented SasaPay callback IP allowlist. |
SasaPayCallbackVerifier::verifiesSignature() |
Shows whether signature verification is enabled by default. |
VerifySasaPayCallback middleware |
Rejects invalid Laravel callback requests with HTTP 403 according to callback-security config. |
SasaPay v1 Auth
| Method | Endpoint |
|---|---|
getAccessToken() |
GET /oauth/v1/generate?grant_type=client_credentials |
SasaPay v1 Payments
| Method | Endpoint |
|---|---|
requestPayment() |
POST /payments/request-payment/ |
processPayment() |
POST /payments/process-payment/ |
b2cPayment() |
POST /payments/b2c/ |
b2bPayment() |
POST /payments/b2b/ |
cardPayment() |
POST /payments/card-payments/ |
preApprovedPayment() |
POST /payments/approved/ |
remittancePayment() |
POST /remittances/remittance-payments/ |
businessToBeneficiary() |
POST /payments/b2c/beneficiary/ |
registerIpnUrl() |
POST /payments/register-ipn-url/ |
lipaFare() |
POST /payments/lipa-fare/ |
bulkPayment() |
POST /payments/bulk-payments/ |
bulkPaymentStatus() |
POST /payments/bulk-payments/status/ |
SasaPay v1 Transactions, Balances, Validation, Utilities
| Method | Endpoint |
|---|---|
accountValidation() |
POST /accounts/account-validation/ |
internalFundMovement() |
POST /transactions/fund-movement/ |
transactionStatus() |
POST /transactions/status/ |
transactionStatusQuery() |
POST /transactions/status-query/ |
transactionStatusExact() |
POST /transactions/status/ |
requestPaymentStatus() |
POST /payments/request-payment/status/ |
merchantBalance($merchantCode) |
GET /payments/check-balance/?MerchantCode=... |
verifyTransaction() |
POST /transactions/verify/ |
transactions() |
GET /transactions/ |
channelCodes() |
GET /payments/channel-codes/ |
utilityPayment() |
POST /utilities/ |
utilityBillQuery() |
POST /utilities/bill-query |
SasaPay v1 Dealer Onboarding
| Method | Endpoint |
|---|---|
dealerBusinessTypes() |
GET /accounts/business-types/ |
dealerCountries() |
GET /accounts/countries/ |
dealerSubCounties($countyId) |
GET /accounts/sub-counties/?county_id=... |
dealerIndustries() |
GET /accounts/industries/ |
availableBillNumber() |
GET /accounts/available-bill-number/ |
merchantOnboarding() |
POST /accounts/merchant-onboarding/ |
SasaPay WAAS Auth
| Method | Endpoint |
|---|---|
getWaasAccessToken() |
GET /oauth/v1/generate?grant_type=client_credentials on the configured authentication host |
SasaPay WAAS Onboarding and Customers
| Method | Endpoint |
|---|---|
waasPersonalOnboarding() |
POST /personal-onboarding/ |
waasConfirmPersonalOnboarding() |
POST /personal-onboarding/confirmation/ |
waasPersonalKyc() |
POST /personal-onboarding/kyc/; sends multipart when files are provided. |
waasBusinessOnboarding() |
POST /business-onboarding/ |
waasConfirmBusinessOnboarding() |
POST /business-onboarding/confirmation/ |
waasBusinessKyc() |
POST /business-onboarding/kyc/; sends multipart when files are provided. |
waasCustomers() |
GET /customers/ |
waasCustomerDetails() |
POST /customer-details/ |
waasUpdateCustomerDetails() |
POST /customer-details/update/ |
waasCreateSubWallet() |
POST /sub-wallets/ |
SasaPay WAAS Payments
| Method | Endpoint |
|---|---|
waasRequestPayment() |
POST /payments/request-payment/ |
waasProcessPayment() |
POST /payments/process-payment/ |
waasMerchantTransfer() |
POST /payments/merchant-transfers/ |
waasSendMoney() |
POST /payments/send-money/ |
waasPayBill() |
POST /payments/pay-bills/ |
waasBulkPayment() |
Alias of v1 bulkPayment() because the current WAAS docs point to /api/v1/payments/bulk-payments/. |
waasBulkPaymentStatus() |
Alias of v1 bulkPaymentStatus() for the same reason. |
SasaPay WAAS Transactions, Balances, Lookups, Utilities
| Method | Endpoint |
|---|---|
waasTransactions() |
GET /transactions/ |
waasTransactionStatus() |
POST /transactions/status/ |
waasVerifyTransaction() |
POST /transactions/verify/ |
waasMerchantBalance($merchantCode) |
GET /merchant-balances/?merchantCode=... |
waasChannelCodes() |
GET /channel-codes/ |
waasCountries() |
GET /countries/ |
waasCountrySubRegions($callingCode) |
GET /countries/sub-regions/?callingCode=... |
waasIndustries() |
GET /industries/ |
waasSubIndustries($industryId) |
GET /sub-industries/?industryId=... |
waasBusinessTypes() |
GET /business-types/ |
waasProducts() |
GET /products/ |
waasNearestAgents($longitude, $latitude) |
GET /nearest-agent/?Longitude=...&Latitude=... |
waasUtilityPayment() |
POST /utilities/ |
waasUtilityBillQuery() |
Alias of v1 utilityBillQuery() because the current WAAS utilities docs point bill query to /api/v1/utilities/bill-query. |
SasaPay Raw Authorized Helpers
Use these when SasaPay exposes an endpoint before this package has a named high-level method. They use the same token providers, retries, timeout handling, hooks, and exception mapping as the named APIs and do not normalize or rewrite payloads.
| Method | Behavior |
|---|---|
authorizedPost($path, $payload = []) |
POST on the v1 base URL with bearer auth. |
authorizedGet($path, $query = []) |
GET on the v1 base URL with bearer auth. |
authorizedMultipartPost($path, $fields = [], $files = []) |
Multipart POST on the v1 base URL with bearer auth. |
waasAuthorizedPost($path, $payload = []) |
POST on the WAAS base URL with bearer auth. |
waasAuthorizedGet($path, $query = []) |
GET on the WAAS base URL with bearer auth. |
waasAuthorizedMultipartPost($path, $fields = [], $files = []) |
Multipart POST on the WAAS base URL with bearer auth. |
The SasaPay docs also contain status-code pages. Those pages document static values, not API endpoints, so they are not represented as HTTP methods.
M-PESA Coverage
| Method | Endpoint |
|---|---|
getAccessToken() |
GET /oauth/v1/generate?grant_type=client_credentials |
stkPush() |
POST /mpesa/stkpush/v1/processrequest |
stkPushQuery() |
POST /mpesa/stkpushquery/v1/query |
registerC2BUrls() |
POST /mpesa/c2b/v2/registerurl by default for backward compatibility; pass version: 'v1' for the current documented C2B Register URL path. |
registerC2BUrlsV1() |
POST /mpesa/c2b/v1/registerurl |
c2bSimulate() |
POST /mpesa/c2b/v1/simulate |
b2cPayment() |
POST /mpesa/b2c/{version}/paymentrequest |
b2cPaymentV3() |
POST /mpesa/b2c/v3/paymentrequest |
b2bPayment() |
POST /mpesa/b2b/v1/paymentrequest |
b2cAccountTopUp() |
POST /mpesa/b2b/v1/paymentrequest with CommandID=BusinessPayToBulk unless already supplied. |
businessPayBill() |
POST /mpesa/b2b/v1/paymentrequest with CommandID=BusinessPayBill unless already supplied. |
businessBuyGoods() |
POST /mpesa/b2b/v1/paymentrequest with CommandID=BusinessBuyGoods unless already supplied. |
b2bExpressCheckout() |
POST /v1/ussdpush/get-msisdn |
reversal() |
POST /mpesa/reversal/v1/request |
transactionStatus() |
POST /mpesa/transactionstatus/v1/query |
accountBalance() |
POST /mpesa/accountbalance/v1/query |
generateQrCode() |
POST /mpesa/qrcode/v1/generate |
taxRemittance() |
POST /mpesa/b2b/v1/remittax |
billManagerOptIn() |
POST /v1/billmanager-invoice/optin |
billManagerSingleInvoice() |
POST /v1/billmanager-invoice/single-invoicing |
billManagerBulkInvoicing() |
POST /v1/billmanager-invoice/bulk-invoicing |
billManagerReconciliation() |
POST /v1/billmanager-invoice/reconciliation |
billManagerCancelSingleInvoice() |
POST /v1/billmanager-invoice/cancel-single-invoice |
billManagerCancelBulkInvoice() |
POST /v1/billmanager-invoice/cancel-bulk-invoice |
billManagerUpdateOnboardingDetails() |
POST /v1/billmanager-invoice/change-optin-details |
billManagerUpdateSingleInvoice() |
POST /v1/billmanager-invoice/change-invoice |
billManagerUpdateBulkInvoice() |
POST /v1/billmanager-invoice/change-invoices |
ratibaStandingOrder() |
POST /standingorder/v1/createStandingOrderExternal |
registerPullTransactions() |
POST /pulltransactions/v1/register |
pullTransactions() |
POST /pulltransactions/v1/query |
M-PESA methods intentionally preserve Daraja field names. The client only string-casts Amount or amount when present by default and, for the named B2B product helpers, adds the documented CommandID only when the caller has not supplied one. Set amount_normalization to none globally or per request when you need to preserve raw JSON number types.
Endpoint overrides are available when a Daraja tenant is provisioned with a different path:
use NoriaLabs\Payments\Facades\Payments; $mpesa = Payments::mpesa([ 'endpoints' => [ 'b2c_payment' => '/mpesa/b2c/v3/paymentrequest', ], ]);
Helper methods:
MpesaClient::buildTimestamp(?DateTimeInterface $dateTime = null): stringMpesaClient::buildStkPassword(string $businessShortCode, string $passkey, string $timestamp): stringMpesaClient::authorizedPost(string $path, array $payload = [], array|RequestOptions|null $options = null): mixedMpesaClient::authorizedGet(string $path, array $query = [], array|RequestOptions|null $options = null): mixed
Request Options
Every provider method accepts either:
nullarrayNoriaLabs\Payments\Support\RequestOptions
Fields:
| Field | Description |
|---|---|
headers |
Request-specific headers. |
timeout_seconds |
Request-specific timeout. |
retry |
Request-specific retry policy or false to disable retries. |
access_token |
Explicit bearer token override. |
force_token_refresh |
Forces the next token lookup to refresh. |
amount_normalization |
Per-request override for providers that normalize amount fields by default. Use none to preserve raw numeric Amount and amount values for SasaPay, M-PESA, and KCB Buni M-PESA Express calls. |
validate |
Per-request override for KCB Buni payload validation. false sends the payload through untouched. |
throw_on_business_error |
Per-request override for business-level failure handling. true throws BusinessException when the provider reports a failure inside an HTTP 200 body. |
Example:
use NoriaLabs\Payments\Support\RequestOptions; use NoriaLabs\Payments\Support\RetryPolicy; $response = $sasapay->requestPayment($payload, new RequestOptions( headers: ['X-Request-Id' => 'abc-123'], timeoutSeconds: 15.0, retry: new RetryPolicy( maxAttempts: 2, retryMethods: ['POST'], retryOnStatuses: [500, 502, 503, 504], baseDelaySeconds: 0.25, ), ));
Custom Token Providers
Implement NoriaLabs\Payments\Contracts\AccessTokenProvider:
use NoriaLabs\Payments\Contracts\AccessTokenProvider; class MyTokenProvider implements AccessTokenProvider { public function getAccessToken(bool $forceRefresh = false): string { return 'my-token'; } }
Inject via the manager:
$client = app(\NoriaLabs\Payments\PaymentsManager::class)->paystack( overrides: [], tokenProvider: new MyTokenProvider(), );
When you supply a custom token provider:
- the package does not call a provider OAuth token endpoint or use a configured static secret for that client
- provider credentials become optional for that runtime client
- you own token freshness
Request Hooks
Use NoriaLabs\Payments\Support\Hooks to observe and mutate transport behavior:
use NoriaLabs\Payments\Support\Hooks; $hooks = new Hooks( beforeRequest: function ($context): void { $context->headers['X-Correlation-Id'] = 'corr-123'; }, afterResponse: function ($context): void { logger()->info('payment response', [ 'url' => $context->url, 'status' => $context->response->status(), ]); }, onError: function ($context): void { logger()->error('payment error', [ 'url' => $context->url, 'error' => $context->error->getMessage(), ]); }, );
Hook contexts expose:
BeforeRequestContext:url,path,method,headers,body,attemptAfterResponseContext: plusresponse,responseBodyErrorContext: pluserror, optionalresponse, optionalresponseBody
Error Classes
The package throws:
NoriaLabs\Payments\Exceptions\ConfigurationExceptionNoriaLabs\Payments\Exceptions\AuthenticationExceptionNoriaLabs\Payments\Exceptions\TimeoutExceptionNoriaLabs\Payments\Exceptions\NetworkExceptionNoriaLabs\Payments\Exceptions\ApiExceptionNoriaLabs\Payments\Exceptions\ValidationExceptionNoriaLabs\Payments\Exceptions\BusinessException
All of them extend PaymentsException and expose a machine-readable codeName.
ApiException includes:
statusCoderesponseBodydetails
ValidationException includes errors, the list of individual field failures.
BusinessException includes provider, statusCode and responseBody.
Business-Level Failures
Every provider in this package can answer HTTP 200 while reporting a failure in
the body. HttpTransport only maps transport and HTTP-status failures, so by
default a rejected transfer is returned to you as an ordinary response array:
| Provider | Failure marker |
|---|---|
| KCB Buni | header.statusCode other than "0", or response.ResponseCode other than 0 |
| M-PESA Daraja | an errorCode field, or ResponseCode / ResultCode other than 0 |
| SasaPay | "status": false |
| Paystack | "status": false |
The default is unchanged for backwards compatibility — you must opt in. There are two ways.
Inspect explicitly. Each client exposes static readers that never throw and
return null for a shape they do not recognise, so a new response format is
never misread as a failure:
$response = $buni->transferFunds($payload); if (KcbBuniClient::succeeded($response) === false) { report(new RuntimeException(KcbBuniClient::statusMessage($response))); }
MpesaClient, SasaPayClient and PaystackClient expose the same succeeded()
and statusMessage() readers (statusCode() too, except on Paystack).
Or let the client throw. Enable it per provider, or per request:
// config/payments.php 'kcb_buni' => ['throw_on_business_error' => true], // or per call $buni->transferFunds($payload, ['throw_on_business_error' => true]);
use NoriaLabs\Payments\Exceptions\BusinessException; try { $buni->transferFunds($payload); } catch (BusinessException $e) { $e->provider; // 'kcb_buni' $e->statusCode; // '1' $e->responseBody; // the full decoded body }
Enabling this is strongly recommended for anything that moves money. It is off by default only so that upgrading the package cannot silently change how an existing integration handles responses.
Async Settlement
For all supported providers, most important operations are asynchronous.
Treat the immediate response as accepted, queued, or processing unless the provider explicitly says otherwise. Final status usually arrives by callback, IPN, transaction-status query, or verification endpoint.
For SasaPay callbacks, KCB Buni IPNs, and Paystack webhooks, verify the provider signature before mutating local order, wallet, or ledger state.
Development Quality
Run the same quality gates locally that CI enforces:
composer quality
# Or run each gate separately:
composer format:test
composer analyse
composer test:coverage
Use composer format to apply Laravel Pint formatting.
M-PESA Documentation References
The M-PESA Daraja endpoint matrix was aligned with Safaricom's public Daraja portal and official Safaricom SDK references:
- https://developer.safaricom.co.ke/
- https://developer.safaricom.co.ke/apis
- https://developer.safaricom.co.ke/c2b/apis/post/registerurl
- https://developer.safaricom.co.ke/lipa-na-m-pesa-online/apis/post/stkpush/v1/processrequest
- https://github.com/safaricom/mpesa-node-library
KCB Buni Documentation References
The KCB Buni endpoint matrix, field constraints, IPN contracts and host list were re-verified on August 18, 2026 against the DevPortal API catalog (which returns exactly six APIs), the OpenAPI document of each, and live probes of the UAT and production gateways.
Per-API OpenAPI documents (.../api/am/devportal/v3/apis/{id}/swagger):
| API | Context | ID |
|---|---|---|
MpesaExpressAPIService |
/mm/api/request |
6396efd5-de10-4b04-adec-128f54349614 |
FundsTransferAPIService |
/fundstransfer |
372552ef-5ebd-4921-9a0d-2f3b1da8cb86 |
VENDINGGATEWAYAPIS |
/kcb/vendingGateway/v1 |
906bbc54-0f39-4271-a912-290346764be7 |
InstantPaymentNotification |
/ipn |
01b3ebbd-1452-4baf-a068-2913ecd3af73 |
KCBKEeTIMSKraServices |
/kcb/ke/kra/etims |
d7a2b6ad-8047-4da6-9f90-54e50c9f1d8e |
KCBBIIpsP2PTransferStatusInquiry |
/kcb/bi/ips/p2p/transfer/status/inquiry |
7cf05ab8-5b43-42a0-ba9d-951bc5eb128e |
Findings that are not in KCB's published documentation and were established by probing the live gateway are labelled as such in KCB Buni hosts and KCB Buni Endpoint Provenance. Confirm them with KCB before relying on them in production.
SasaPay Documentation References
The SasaPay endpoint matrix was aligned with the public SasaPay docs:
- https://docs.sasapay.app/docs/introduction/
- https://docs.sasapay.app/docs/authentication/
- https://docs.sasapay.app/docs/customerTobusiness/
- https://docs.sasapay.app/docs/b2c/
- https://docs.sasapay.app/docs/b2b/
- https://docs.sasapay.app/docs/channel-codes/
- https://docs.sasapay.app/docs/waas/introduction/
- https://docs.sasapay.app/docs/waas/auth/
- https://docs.sasapay.app/docs/waas/getchannelcodes/
- https://developer.sasapay.app/docs/apis/callback-security?country=ke
Paystack Documentation References
The Paystack endpoint matrix and webhook security behavior were aligned with Paystack's public developer docs and official OpenAPI repository: