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
- Create a merchant account and add an active payment channel.
- Fund the Service Wallet so available balance covers the fee.
- Create an API key. Copy the secret immediately—it is shown once.
POSThttp://localhost/primePay/api/stk/push.php with a Kenyan phone, whole-shilling amount, and reference.- Poll status, or receive a webhook when the request becomes terminal.
POST request
Validate credentials, channel, amount, and fee balance.Pending
The customer or provider is completing the prompt.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
| Field | Required | Rules |
|---|---|---|
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
POSThttp://localhost/primePay/api/stk/push.php
| Field | Required | Rules |
|---|---|---|
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 POSTGET 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
| Status | Meaning |
|---|---|
created | Recorded, not yet accepted by a provider. |
submitted / pending | Waiting on the customer or provider. |
successful | Customer paid. Fee captured. Receipt may be present. |
failed / cancelled / timeout | Did not complete. Reserved fee released. |
fee_status is none, reserved, captured, or released.
Sandbox simulation
Test onlySandbox 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 errorsError bodies look like:
{
"success": false,
"error": "invalid_phone",
"message": "Enter a Kenyan M-Pesa number."
}| HTTP | Error | When |
|---|---|---|
| 400 | invalid_phone / invalid_amount / invalid_reference / channel_required / channel_inactive / invalid_json | Request validation. |
| 401 | unauthenticated | Missing or wrong API credentials. |
| 402 | insufficient_wallet | Service Wallet available balance is below the fee. |
| 403 | forbidden | Merchant account is not active. |
| 404 | channel_not_found / not_found | Channel or request is not yours. |
| 409 | duplicate_reference | Open request already uses that reference with different phone, amount, or channel. |
| 429 | rate_limited | Too many API calls from this IP or key (about 60/minute per key, 120/minute per IP). |
| 502 | provider_rejected | Provider did not queue STK. Body includes request_id; reserved fee is released. |
| 405 / 413 / 500 | method_not_allowed / payload_too_large / initiate_failed | Method, size, or server error. |
Webhooks
Signed eventsSet 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.