Search by

tukupay / tukupay-php

tukupay

Server-side PHP library for the TukuPay API

v0.1.0-beta.1 2026-10-12 16:50 UTC

This package is auto-updated.

Last update: 2026-10-19 05:33:06 UTC


README

Official PHP library for the TukuPay API: register users, give them wallets, collect and send money over M-Pesa, and send SMS, from your own server.

The library handles authentication, errors and safe retries for you, and returns TukuPay's responses unchanged, as arrays.

Beta. The interface may still change before 1.0. Pin the version you install.

Contents

Install

composer require tukupay/tukupay-php:^0.1@beta

Requires PHP 8.1 or later and uses Guzzle 7.

The @beta flag is needed while the library is in beta; without it Composer refuses pre-release versions.

Before you start

TukuPay runs two separate environments. They share nothing: each has its own apps, users, wallets and API keys.

Environment Server Keys it issues Use it for Limit
Test https://test.api.tuku.money sk_test_... Building and testing your integration 60 requests a minute
Production https://api.tuku.money sk_live_... Real customers 600 requests a minute

To use an environment you need, on that environment:

  1. A TukuPay app. Your integration is an app. Users you register belong to it, and payments made through it are recorded against it.
  2. An API key for that app. Keys are issued per app from your TukuPay account; if you do not have one yet, ask your TukuPay contact. The full key is shown once, so store it in an environment variable or secret manager.

A key only exists on the environment that issued it. The library reads the prefix and talks to the matching server, so going live is a matter of creating a sk_live_ key on production and changing one environment variable:

TUKUPAY_API_KEY=sk_test_...   # testing and development
TUKUPAY_API_KEY=sk_live_...   # production

Users, wallets and ids do not carry over: a user registered on the test environment does not exist on production.

The test environment is not a sandbox. A payout made with a test key moves real money. When testing, only pay phone numbers you control, and use small amounts.

How it works

Every request carries up to two credentials. Knowing which is which explains the whole library.

Credential Answers Sent as Who holds it
API key Which app is calling? X-API-Key header Your server, and nobody else
User token Which user is acting? Authorization: Bearer header Your server, one per signed-in user
  • With the key alone you can read things about your app as a whole: its wallets, transactions and users, and send SMS. These live under $tuku->app.
  • To act as a user (open a wallet, pay, upload KYC) you need the key and that user's token, plus their PIN whenever money leaves a wallet. These live on the object returned by $tuku->asUser($tokens).

Your users never see the API key. They register and sign in with their phone number and password as on TukuPay itself; your server passes those to TukuPay and keeps the tokens it gets back.

Never put the API key in a web page, a mobile app or a public repository. Anyone holding it can act as your app.

Quick start

Check that your key works:

<?php
require 'vendor/autoload.php';

use TukuPay\TukuPay;

$tuku = new TukuPay(getenv('TUKUPAY_API_KEY'));

$app = $tuku->app->retrieve();
echo $app['name'], ' ', $app['status'], ' ', $app['environment'];
// My Shop active test

If this prints your app's name, the key is valid and you are talking to the right environment.

Guide

The steps below take one user from nothing to a completed payment. Each builds on the one before.

1. Register a user

$tuku->auth->register([
    'phone_number'   => '0712345678',
    'password'       => 'a-strong-password',
    'accepted_terms' => true,
]);
  • Leave app_id out. The user is placed under your app because the request carries your key.
  • phone_number accepts 07XXXXXXXX, 01XXXXXXXX or 2547XXXXXXXX.
  • accepted_terms must be true; the user has to have agreed to TukuPay's terms.

TukuPay texts the user a one-time code. Ask them for it and confirm their phone:

$tuku->auth->verifyPhone('0712345678', '123456');

A user cannot sign in until their phone is verified. If the code did not arrive, send another with $tuku->auth->sendPhoneVerification($phoneNumber).

2. Sign in and keep the session

$tokens = $tuku->auth->login('0712345678', 'a-strong-password');

$user = $tuku->asUser($tokens, function (array $fresh) use ($db, $userId) {
    // Called whenever the library renews the tokens. Save them.
    $db->saveTokens($userId, $fresh['access_token'], $fresh['refresh_token']);
});

$me = $user->account->me();

login returns two tokens:

Token Lifetime Use
access_token short sent with every call made as the user
refresh_token longer, single use exchanged for a new pair when the access token expires

You do not manage this by hand. When a call fails because the access token has expired, the library exchanges the refresh token, repeats the call once, and gives you the new pair through the callback. Store the new pair every time: the old refresh token no longer works, so a stale one means the user has to sign in again.

Sign in once and reuse the tokens; signing a user in on every request will hit TukuPay's limit on sign-ins per hour. On a later request, rebuild the user from the tokens you stored:

$saved = $db->loadTokens($userId);

$user = $tuku->asUser(
    ['access_token' => $saved->accessToken, 'refresh_token' => $saved->refreshToken],
    fn (array $fresh) => $db->saveTokens($userId, $fresh['access_token'], $fresh['refresh_token']),
);

3. Give the user a wallet

A new user has no wallet. Opening one is a three-step identity check.

Step 1: send their details. TukuPay texts the user an OTP.

$user->onboarding->initial([
    'first_name'      => 'John',
    'middle_name'     => 'K',
    'last_name'       => 'Doe',
    'country_code'    => '254',
    'document_type'   => '1',
    'document_number' => '12345678',
    'email'           => 'john@example.com',
]);

document_type is "1" for a national ID, "2" for a passport and "3" for an alien ID.

Step 2: confirm with the OTP.

$user->onboarding->confirm('123456');

A wrong or expired OTP throws GatewayException; the message says why.

Step 3: upload the identity documents.

$user->onboarding->kyc([
    ['name' => 'passport_size_photo',  'contents' => fopen('photo.jpg', 'r'),    'filename' => 'photo.jpg'],
    ['name' => 'document_image_front', 'contents' => fopen('id-front.jpg', 'r'), 'filename' => 'id-front.jpg'],
    ['name' => 'document_image_back',  'contents' => fopen('id-back.jpg', 'r'),  'filename' => 'id-back.jpg'],
]);

Check progress at any time:

$status = $user->onboarding->status()['status'];
status Meaning
not_started No onboarding yet
pending_otp Step 1 done; waiting for the OTP
wallet_created Step 2 done; the wallet exists, documents still needed
kyc_uploaded Documents sent; verification in progress
verified Approved; the wallet is fully usable
rejected Verification failed; the user must correct their documents

Once the wallet exists you can read it:

$wallets = $user->wallets->list();
$wallet = $wallets[0];
echo $wallet['id'], ' ', $wallet['alias'], ' ', $wallet['balance'];

4. Set the payment PIN

Every payment that takes money out of a wallet needs the user's PIN (above KES 50,000, a one-time code sent to their phone instead; see step 6). Set it once:

$user->account->setupPin('1234', '1234');

The library passes PINs straight to TukuPay and never stores or logs them. Do not store them either; ask the user each time.

5. Collect money by M-Pesa

This sends an M-Pesa prompt to a phone and puts the money in the user's wallet.

$payment = $user->payments->direct([
    'transaction_data' => [
        'transaction_type'      => 'WALLET_TOPUP',
        'amount'                => 150,
        'destination_wallet_id' => $wallet['id'],
    ],
    'payment_source' => [
        'payment_method' => 'mpesa',
        'phone_number'   => '0712345678',     // the phone that receives the M-Pesa prompt
    ],
    'description' => 'Order 1042',
]);

echo $payment['transaction_id'], ' ', $payment['status'];
$instructions = $payment['type_result']['customer_message'] ?? null;   // show this to the payer

Two things to do with the response:

  • Show the payer type_result.customer_message word for word, if present. Some payers do not receive the M-Pesa prompt, and this text gives them the PayBill steps to pay manually.
  • Do not treat this as paid. A successful response means the request was accepted. The payer still has to enter their M-Pesa PIN. Go to step 7 for the outcome.

No TukuPay PIN is needed here, because money is coming in.

6. Send money

This pays from the user's wallet to an M-Pesa number. Money is leaving a wallet, so the PIN is required.

$payout = $user->payments->direct([
    'transaction_data' => [
        'transaction_type'  => 'USER_TRANSFER',
        'amount'            => 500,
        'transfer_method'   => 'mpesa',
        'destination_phone' => '0798765432',
    ],
    'payment_source' => [
        'payment_method'   => 'tukupay_wallet',
        'source_wallet_id' => $wallet['id'],
    ],
    'pin' => '1234',                           // asked from the user, never stored
]);

Check first, if you want to show fees or catch problems early. validate takes the same payment and reports fees, limits and whether the balance covers it, without moving money:

$check = $user->payments->validate($paymentBody);

Above KES 50,000 a one-time code replaces the PIN. Request the code, collect it from the user, and send the payment with otp_code and the payment_hash you were given, in place of pin:

$otp = $user->payments->requestOtp(75000, 'USER_TRANSFER', $wallet['id']);

$user->payments->direct($paymentBody + [
    'otp_code'     => '654321',
    'payment_hash' => $otp['payment_hash'],
]);

Other payment types use the same call with a different transaction_data:

transaction_type Does Key fields
WALLET_TOPUP M-Pesa into the user's own wallet amount, destination_wallet_id
STK_POS M-Pesa prompt to any payer, into a wallet amount, destination_wallet_id
USER_TRANSFER Wallet to an M-Pesa number or a TukuPay wallet alias amount, transfer_method, destination_phone or destination_alias
WALLET_TRANSFER Between two TukuPay wallets amount, destination_wallet_id
BANK_TRANSFER Wallet to a bank account amount, bank_name, account_number, account_name
BILL_PAYMENT Pay a Paybill or Till amount, receiver_merchant_code, account_reference, reason

$user->payments->transactionTypes() lists every type available to you with a short description. For a type or field not covered here, ask your TukuPay contact. To pay many M-Pesa numbers from one wallet in one call, use $user->payments->bulkTransfer().

Paying twice by accident

Networks fail at the worst moment: your request may reach TukuPay while the reply never reaches you. To make a repeat safe, every payment carries an idempotency key. TukuPay remembers it, and a second request with the same key returns the first result without paying again.

  • The library generates one per call and reuses it for its own automatic retries. You get this for free.
  • If your code may retry (a queue job that can run twice, a user double-clicking), pass your own key built from something stable such as your order id:
$user->payments->direct($paymentBody, "order-{$order->id}-attempt-1");
  • A deliberate new attempt after a failure is a new payment and needs a new key. Reusing the old key returns the old failure.

7. Find out what happened

A payment starts as pending and ends as completed or failed. wait reads it until it settles:

use TukuPay\Exception\WaitTimeoutException;
use TukuPay\TukuPay;

try {
    $transaction = $user->transactions->wait(
        $payment['transaction_id'],
        timeout: 120,     // give up after 2 minutes
        interval: 3,      // check every 3 seconds
    );

    if ($transaction['status'] === 'completed') {
        markOrderPaid($order->id);
    } else {
        $failure = TukuPay::failureOf($transaction);
        echo $failure['category'] ?? '', ' ', $failure['hint'] ?? '';
        // cancelled_by_payer The payer cancelled the STK prompt on their handset.
    }
} catch (WaitTimeoutException $e) {
    // Still pending. Keep the id and check again later.
}

wait returns for both outcomes, so always check status. If time runs out it throws WaitTimeoutException; the payment may still complete later, so keep the transaction id and check again rather than assuming failure.

wait blocks the PHP request while it polls. In a web request it is usually better to return to the user straight away and check the outcome from a queue job or when they reload the page, using transactions->get($id).

When a payment fails, TukuPay::failureOf($transaction) gives the reason in a form you can act on:

Field Meaning
category A stable label, e.g. cancelled_by_payer
gateway_code The gateway's specific code, e.g. 1032. Use this, not code
hint A plain-language explanation
retryable Whether trying again could succeed
code The gateway's general code; nearly always the same generic value

8. See everything your app has done

These calls need only the API key and cover every user of your app. They suit back-office screens, reconciliation and reports.

$wallets = $tuku->app->wallets->list(['limit' => 50]);
$wallet  = $tuku->app->wallets->get(28);

$page = $tuku->app->transactions->list([
    'status'     => 'completed',
    'start_date' => new DateTimeImmutable('2026-10-01'),
    'limit'      => 100,
]);
echo $page['total'], ' ', $page['has_more'] ? 'more' : 'done';

$transaction = $tuku->app->transactions->get(1042);
$users = $tuku->app->users->list(['limit' => 20])['users'];

Lists return at most 100 rows per call. To walk a whole list without handling pages yourself:

foreach ($tuku->app->transactions->iterate(['status' => 'completed']) as $transaction) {
    echo $transaction['id'], ' ', $transaction['amount'], PHP_EOL;
}

Transaction filters: transaction_type, status (pending, completed, failed), wallet_id, start_date, end_date, limit, offset.

Your key only ever sees your own app. Asking for another app's wallet or transaction gives NotFoundException, the same as one that does not exist.

9. Send SMS

Messages go out on the app owner's TukuPay account and use the owner's SMS credits.

$queued = $tuku->app->sms->send(['0712345678', '0798765432'], 'Your order has shipped.');

$outbox = $tuku->app->sms->outbox(['limit' => 20]);
$one = $tuku->app->sms->status($outbox[0]['id']);
echo $one['phone_number'], ' ', $one['status'];
  • send queues the message. The message_id it returns identifies the queued batch, not the individual texts.
  • To see what was delivered, read the outbox. Each row is one recipient, with a status of success, failed or pending.
  • Credits are taken when the message is actually sent, not when it is queued.
  • A custom sender_id must be one approved on the owner's account.

Errors

Every failure throws a TukuPayException or one of its subclasses. Each carries:

  • $e->getMessage(): a readable message.
  • $e->status: the HTTP status, or null if no response arrived.
  • $e->errors: exactly what the API sent in errors. For a validation failure this is a list with one entry per bad field.
  • $e->body: the whole response body.
Class HTTP When What to do
AuthenticationException 401 Key or token missing, wrong, revoked, expired, or for the other environment Check the key; sign the user in again
PermissionDeniedException 403 Not allowed: a wrong or locked PIN, an unverified phone, an app that is suspended or pending Read the message. For a wrong PIN, ask the user again; never retry on your own
NotFoundException 404 Does not exist, or belongs to another app Check the id
ValidationException 400, 422 The request was refused as sent Fix the fields named in errors
RateLimitException 429 Too many requests this minute Wait $e->retryAfter seconds
GatewayException 200 The payment gateway refused, e.g. a wrong onboarding OTP Show the message; let the user retry
ApiException 5xx A fault on TukuPay's side Retry later; contact TukuPay if it persists
ConnectionException none No response: network failure or timeout Retry; for a payment, reuse the same idempotency key
WaitTimeoutException none wait ran out of time Check the transaction again later
use TukuPay\Exception\RateLimitException;
use TukuPay\Exception\TukuPayException;
use TukuPay\Exception\ValidationException;

try {
    $user->payments->direct($paymentBody);
} catch (ValidationException $e) {
    showToUser($e->getMessage());              // "amount: Input should be greater than 0"
} catch (RateLimitException $e) {
    retryIn($e->retryAfter);
} catch (TukuPayException $e) {
    error_log($e->status . ' ' . json_encode($e->errors));
}

Retries, timeouts and rate limits

Retries. A failed call is retried up to twice, waiting a little longer each time.

Situation Retried?
A read (GET) fails with a network error or 502/503/504 Yes
Any call gets 429 Yes, after the wait the API asks for, if that is 20 seconds or less
A payment (direct, bulkTransfer) fails with a network error or 502/503/504 Yes, with the same idempotency key
Any other write (register, SMS, onboarding) fails No. It is sent once, because repeating it could do the thing twice

Timeouts. Each request is given 30 seconds by default.

Rate limits. Limits are per key, per minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. If you are regularly limited, spread the work out or ask TukuPay to raise the limit on your key.

Configuration

$tuku = new TukuPay(getenv('TUKUPAY_API_KEY'), [
    'timeout'     => 30.0,
    'max_retries' => 2,
    'debug'       => fn (string $line) => error_log($line),
]);
Option Default Meaning
base_url from the key Override the server, e.g. http://localhost:8000 for local development
timeout 30.0 Seconds allowed for each request
max_retries 2 Retries after the first attempt; 0 turns retrying off
max_retry_delay 20.0 A rate-limit wait longer than this many seconds is thrown instead of waited out
http_client a new one Your own GuzzleHttp\\Client, for a proxy or for tests
debug none A function that receives each call's method, path and status. Keys, tokens, PINs and bodies are never passed to it

Reference

Every call returns the API's JSON as a PHP array. Exceptions are in the TukuPay\\Exception namespace.

$tuku->app: the API key alone

Call Returns
$tuku->app->retrieve() The app this key belongs to: id, name, status, app_type, key_type, environment
$tuku->app->wallets->list(['limit' => , 'offset' => ]) Array of the app's wallets
$tuku->app->wallets->get($walletId) One wallet
$tuku->app->wallets->iterate($pageSize = 100) Generator over every wallet
$tuku->app->transactions->list($filters) transactions, total, size, page, has_more
$tuku->app->transactions->get($transactionId) One transaction; never cached
$tuku->app->transactions->wait($transactionId, $timeout = 120, $interval = 3) The transaction once it is completed or failed
$tuku->app->transactions->iterate($filters = [], $pageSize = 100) Generator over every matching transaction
$tuku->app->users->list(['limit' => , 'offset' => ]) app_id, users, total, size, page, has_more
$tuku->app->users->iterate($pageSize = 100) Generator over every user
$tuku->app->sms->send($phoneNumbers, $message, $senderId = null) success, message, message_id, recipient_count, queued_at
$tuku->app->sms->outbox(['limit' => , 'offset' => ]) Array of sent messages, newest first
$tuku->app->sms->status($smsId) One message from the outbox

$tuku->auth: the API key alone

Call Returns
$tuku->auth->register(['phone_number' => , 'password' => , 'accepted_terms' => ]) message; the user is texted an OTP
$tuku->auth->verifyPhone($phoneNumber, $otpCode) message
$tuku->auth->sendPhoneVerification($phoneNumber) message; sends the OTP again
$tuku->auth->login($phoneNumber, $password) access_token, refresh_token, token_type
$tuku->auth->refresh($refreshToken) A new token pair. Normally the library does this for you
$tuku->asUser($tokens, $onTokenRefresh = null) A client that acts as that user

As a user: key and token

Obtained from $tuku->asUser($tokens).

Call Returns
$user->tokens() The user's current access_token and refresh_token
$user->account->me() The user's profile
$user->account->setupPin($pin, $confirmPin) message
$user->account->verifyPin($pin) message; throws if the PIN is wrong
$user->account->logout() message; the tokens stop working
$user->onboarding->initial($details) The gateway's reply; the user is texted an OTP
$user->onboarding->confirm($otp, $requestId = null) The gateway's reply; the wallet now exists
$user->onboarding->kyc($multipart) The gateway's reply
$user->onboarding->status() status, wallet_count, ...; see the status table in step 3
$user->kyc->upload($multipart) document, analysis, message. Parts: doc_type, file
$user->kyc->list() Array of the user's KYC documents
$user->kyc->get($documentId) One KYC document
$user->wallets->list() Array of the user's wallets
$user->wallets->get($walletId) One wallet
$user->wallets->create($wallet) A further wallet for a user who already has one
$user->payments->methods($includeUnavailable = null) The payment methods currently enabled
$user->payments->transactionTypes() The transaction types and their fields
$user->payments->validate($payment) Fees, limits and balance check; no money moves
$user->payments->requestOtp($amount, $transactionType, $sourceWalletId = null) message, payment_hash, expires_in
$user->payments->direct($payment, $idempotencyKey = null) status, message, transaction_id, transaction_type, amount, payment_method, type_result, ...
$user->payments->bulkTransfer($transfer, $idempotencyKey = null) status, transaction_id, total_recipients, successful_transfers, failed_transfers, transfer_results, ...
$user->transactions->list($filters) transactions, total, size, page, has_more
$user->transactions->get($transactionId) One transaction; never cached
$user->transactions->wait($transactionId, $timeout = 120, $interval = 3) The transaction once it is completed or failed
$user->transactions->iterate($filters = [], $pageSize = 100) Generator over every matching transaction
$user->sms->send($phoneNumbers, $message, $senderId = null) Queues an SMS on the user's own credits
$user->sms->outbox($filters) sms_records, total, size, page, has_more
$user->sms->balance() sms_balance, total_spent, total_earned, ...

Helpers

Call Returns
TukuPay::failureOf($transaction) The failure details of a failed transaction, or null
TukuPay::VERSION The library version

Troubleshooting

Invalid or revoked API key. One of:

  • The key is mistyped or was revoked.
  • It was rotated more than 24 hours ago, so the old key has expired.
  • It belongs to the other environment. A sk_test_ key is unknown to production and a sk_live_ key is unknown to the test environment. Remove any base URL override so the library picks the server from the key.

App not approved or not found. Your app has not been approved yet or has been suspended. Contact TukuPay.

Please verify your phone number before logging in. Finish step 1: confirm the OTP.

Too many login attempts. TukuPay allows a limited number of sign-ins per user per hour (about 10). Sign a user in once, store the tokens and reuse them; do not sign in on every request.

The user is asked to sign in again unexpectedly. A refresh token was used twice. Make sure the token callback saves the new pair, and that two servers are not refreshing the same user from the same old token.

Incorrect PIN or PIN is locked. The user entered the wrong PIN. Ask them again. After several wrong attempts the PIN locks for a while, so never retry a payment automatically after this error.

A payment stays pending. The payer has not responded to the M-Pesa prompt, or the gateway has not reported back yet. Keep checking the same transaction; do not start a second payment for the same order.

I retried and got the same failure. You reused the idempotency key. A new attempt needs a new key.

Versioning

This library follows semantic versioning. While it is 0.x, a minor release may change the interface; see CHANGELOG.md. It ignores response fields it does not know, so additions to the API do not break it.

Support

Report problems at https://github.com/tukupay/tukupay-php/issues. Never include an API key, token or PIN in an issue.

License

MIT