Developer reference

Prime Pay API

Send M-Pesa STK prompts from your application. Collection routing depends on the provider and channel configured for your account.

Two separate balances

The Service Wallet tracks Prime Pay service fees separately. Collection destination depends on your configured provider and channel.

What you are integrating

Prime Pay sends an M-Pesa STK prompt on behalf of your app. Customer money goes to the PayBill, Till, or bank you saved as a payment channel. It never sits in Prime Pay.

The Service Wallet is a separate KES ledger. You top it up to cover the STK service fee (currently KES 2.00 unless changed in settings). The fee is reserved when you initiate STK, captured when the customer pays, and released if the request fails, is cancelled, or times out.

Quickstart

  1. Create a merchant account and add an active payment channel.
  2. Fund the Service Wallet so available balance covers the fee.
  3. Create an API key. Copy the secret immediately—it is shown once.
  4. POST http://localhost/primePay/api/stk/push.php with a Kenyan phone, whole-shilling amount, and reference.
  5. Poll status, or receive a webhook when the request becomes terminal.
1

POST request

Validate credentials, channel, amount, and fee balance.
2

Pending

The customer or provider is completing the prompt.
3

Terminal result

Successful captures the fee; other outcomes release it.

Authentication

Send the key ID and secret on every API call. Prefer Bearer:

Authorization: Bearer {key_id}:{secret}

Also accepted: HTTP Basic with the same key_id:secret, or headers X-Api-Key and X-Api-Secret.

pp_test_ and pp_live_ keys are separate. The STK driver follows push_provider (sandbox by default). Sandbox records the request and does not send a live prompt to your Till.

Payment channels

Manage PayBill, Till, and bank destinations over the API. Same validation as the dashboard. Collection routing is provider-dependent; the Service Wallet is used for Prime Pay service fees.

List channels

GET http://localhost/primePay/api/channels/list.php

Optional query: status=active|inactive|archived or active=1.

curl -X GET "http://localhost/primePay/api/channels/list.php?status=active" \
  -H "Authorization: Bearer pp_test_YOUR_KEY_ID:YOUR_API_SECRET"

Create channel

POST http://localhost/primePay/api/channels/create.php

FieldRequiredRules
name Yes 2–80 characters.
type Yes paybill, till, or bank.
paybill / account_reference PayBill PayBill 5–10 digits. Account reference optional.
till_number Till Till 5–10 digits.
bank_code / account_number / beneficiary Bank Bank code from the Kenyan banks list, account 6–20 chars, beneficiary required.
make_default No Boolean. First channel is default automatically.
provider_channel_id No Aggregator channel ID (e.g. PayHero). Not your Till/PayBill number.
$ch = curl_init('http://localhost/primePay/api/channels/create.php');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer pp_test_YOUR_KEY_ID:YOUR_API_SECRET',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'name' => 'Shop PayBill',
        'type' => 'paybill',
        'paybill' => '247247',
        'account_reference' => 'INV',
        'make_default' => true,
    ]),
]);
$response = curl_exec($ch);
curl_close($ch);

Update channel

POST http://localhost/primePay/api/channels/update.php

Require channel_id. Then send status (active / inactive / archived), make_default, and/or the same destination fields as create.

curl -X POST "http://localhost/primePay/api/channels/update.php" \
  -H "Authorization: Bearer pp_test_YOUR_KEY_ID:YOUR_API_SECRET" \
  -H "Content-Type: application/json" \
  -d "{\"channel_id\":1,\"make_default\":true}"

Channel object

{
  "id": 1,
  "name": "Shop PayBill",
  "type": "paybill",
  "paybill": "247247",
  "till_number": null,
  "account_reference": "INV",
  "bank_code": null,
  "account_number": null,
  "beneficiary": null,
  "provider": "sandbox",
  "provider_channel_id": null,
  "is_default": true,
  "status": "active",
  "created_at": "2026-08-24 10:00:00",
  "updated_at": "2026-08-24 10:00:00"
}

Initiate STK

POST

http://localhost/primePay/api/stk/push.php

FieldRequiredRules
phone Yes Kenyan M-Pesa. 0712…, 712…, 254712…, or +254712… are accepted and stored as 2547… / 2541….
amount Yes Whole Kenyan shillings. Min KES 1.00, max KES 150,000.00.
reference Yes 1–64 characters: letters, numbers, dots, dashes, underscores. Must start with a letter or number. Must be unique among your open requests unless it is the same phone, amount, and channel (then you get the existing request).
description No Up to 255 characters.
channel_id No Your channel. Omitted uses the default active channel. Another merchant’s ID returns 404.

cURL

curl -X POST http://localhost/primePay/api/stk/push.php \
  -H "Authorization: Bearer pp_test_YOUR_KEY_ID:YOUR_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"phone":"0712345678","amount":100,"reference":"ORDER-1042","description":"Invoice 1042"}'

PHP

$ch = curl_init('http://localhost/primePay/api/stk/push.php');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer pp_test_YOUR_KEY_ID:YOUR_API_SECRET',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone' => '0712345678',
        'amount' => 100,
        'reference' => 'ORDER-1042',
        'description' => 'Invoice 1042',
        // 'channel_id' => 12, // optional; default channel is used if omitted
    ]),
]);
$response = curl_exec($ch);
curl_close($ch);

Node.js

const res = await fetch('http://localhost/primePay/api/stk/push.php', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer pp_test_YOUR_KEY_ID:YOUR_API_SECRET',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    phone: '0712345678',
    amount: 100,
    reference: 'ORDER-1042',
    description: 'Invoice 1042',
  }),
});
const body = await res.json();

Success response

201, or 200 if duplicate is true:

{
  "success": true,
  "duplicate": false,
  "request_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "status": "pending",
  "provider": "sandbox",
  "environment": "test",
  "amount": "100.00",
  "currency": "KES",
  "phone": "254712345678",
  "reference": "ORDER-1042",
  "channel_id": 1,
  "fee": "2.00",
  "fee_status": "reserved",
  "receipt": "",
  "result_code": "",
  "message": "STK recorded. No live prompt was sent. Merchant STK provider is sandbox."
}

Check status

GET or POST

GET http://localhost/primePay/api/stk/status.php?request_id={uuid} or POST with JSON {"request_id":"{uuid}"}. Same auth as push. Response shape matches initiate (without forcing duplicate).

Open PayHero requests are refreshed from the provider. Sandbox requests older than 15 minutes time out and the reserved fee is released.

Statuses

StatusMeaning
createdRecorded, not yet accepted by a provider.
submitted / pendingWaiting on the customer or provider.
successfulCustomer paid. Fee captured. Receipt may be present.
failed / cancelled / timeoutDid not complete. Reserved fee released.

fee_status is none, reserved, captured, or released.

Sandbox simulation

Test only

Sandbox does not send a live STK. To finish a request on localhost:

POST http://localhost/primePay/api/stk/simulate.php

{
  "request_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "outcome": "successful",
  "receipt": "SBXTEST1"
}

outcome may be successful, failed, cancelled, or timeout. The same terminal outcome twice does not capture or release the fee again. Live PayHero requests cannot be simulated.

Errors

HTTP errors

Error bodies look like:

{
  "success": false,
  "error": "invalid_phone",
  "message": "Enter a Kenyan M-Pesa number."
}
HTTPErrorWhen
400invalid_phone / invalid_amount / invalid_reference / channel_required / channel_inactive / invalid_jsonRequest validation.
401unauthenticatedMissing or wrong API credentials.
402insufficient_walletService Wallet available balance is below the fee.
403forbiddenMerchant account is not active.
404channel_not_found / not_foundChannel or request is not yours.
409duplicate_referenceOpen request already uses that reference with different phone, amount, or channel.
429rate_limitedToo many API calls from this IP or key (about 60/minute per key, 120/minute per IP).
502provider_rejectedProvider did not queue STK. Body includes request_id; reserved fee is released.
405 / 413 / 500method_not_allowed / payload_too_large / initiate_failedMethod, size, or server error.

Webhooks

Signed events

Set a URL under Webhooks. Prime Pay POSTs when a request becomes successful, failed, cancelled, or timed out. We send our own JSON, not the upstream provider payload. One delivery is queued per STK request.

Headers: Content-Type: application/json, X-PrimePay-Event (for example stk.successful), X-PrimePay-Signature: t={unix},v1={hex}.

v1 is HMAC-SHA256 of {timestamp}.{raw_body} using your signing secret. Reject signatures whose timestamp is more than five minutes off. Respond 2xx quickly. Failed deliveries retry at 1m, 5m, 25m, then 2h.

{
  "id": "event-uuid",
  "event": "stk.successful",
  "created_at": "2026-08-22T05:16:00+03:00",
  "data": {
    "request_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "status": "successful",
    "provider": "sandbox",
    "environment": "test",
    "amount": "100.00",
    "currency": "KES",
    "phone": "254712345678",
    "reference": "ORDER-1042",
    "channel_id": 1,
    "fee": "2.00",
    "fee_status": "captured",
    "receipt": "SBXTEST1",
    "result_code": "0",
    "message": "Sandbox simulated successful"
  }
}

Verify in PHP (store the secret as PRIMEPAY_WEBHOOK_SECRET, never in the repo):

$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PRIMEPAY_SIGNATURE'] ?? '';
if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) {
    http_response_code(400);
    exit;
}
$signed = hash_hmac('sha256', $m[1] . '.' . $raw, getenv('PRIMEPAY_WEBHOOK_SECRET'));
if (!hash_equals($signed, $m[2])) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
// Handle $event['event'] e.g. stk.successful
http_response_code(200);
echo '{"ok":true}';

Providers

sandbox Default test driver

Records STK only. Use simulate or the request page.

payhero Live merchant prompt

Sends a live prompt only if that channel has a PayHero aggregator channel ID—not the Till or PayBill number. PAYHERO_CHANNEL_ID in .env is not a merchant destination.

daraja Service Wallet funding

Funds the Service Wallet. It cannot push STK to an arbitrary merchant Till. Collection callbacks use payments/walletCallback.php. Merchant STK callbacks use payments/stkCallback.php.

On localhost, Safaricom and PayHero cannot reach XAMPP. Use Check status or simulate after the customer completes the prompt.