STK Push
Most Pesapal integrations send the customer to a hosted payment page and wait for them to come back. An STK push skips it: Pesapal sends a PIN prompt straight to the customer’s phone, and they approve the payment without ever leaving your app.
The name comes from M-Pesa, but the same endpoint pushes prompts to Airtel Money in Kenya and Uganda.
Use it when a redirect would break the experience, which is most of the time in a mobile app, and when you already know the customer’s phone number.
| Hosted page | STK push | |
|---|---|---|
| Customer leaves your app | Yes | No |
| Payment methods | Cards, M-Pesa, Airtel Money | M-Pesa and Airtel Money only |
| You need their phone number first | No | Yes |
| Response gives you | A redirect_url |
A prompt already sent |
Sending the prompt
Section titled “Sending the prompt”curl --request POST \ --url https://cybqa.pesapal.com/pesapalv3/api/transactions/stk \ --header 'Accept: application/json' \ --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ --header 'Content-Type: application/json' \ --data '{ "id": "ORDER-1234", "currency": "KES", "amount": 100, "description": "Test payment", "callback_url": "https://example.com/payment-complete", "notification_id": "YOUR_IPN_ID", "billing_address": { "email_address": "customer@example.com" }, "msisdn": "0712345678", "payment_method": "MpesaKE"}'const url = 'https://cybqa.pesapal.com/pesapalv3/api/transactions/stk';const options = { method: 'POST', headers: { Authorization: 'Bearer YOUR_ACCESS_TOKEN', Accept: 'application/json', 'Content-Type': 'application/json' }, body: '{"id":"ORDER-1234","currency":"KES","amount":100,"description":"Test payment","callback_url":"https://example.com/payment-complete","notification_id":"YOUR_IPN_ID","billing_address":{"email_address":"customer@example.com"},"msisdn":"0712345678","payment_method":"MpesaKE"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}import requests
url = "https://cybqa.pesapal.com/pesapalv3/api/transactions/stk"
payload = { "id": "ORDER-1234", "currency": "KES", "amount": 100, "description": "Test payment", "callback_url": "https://example.com/payment-complete", "notification_id": "YOUR_IPN_ID", "billing_address": { "email_address": "customer@example.com" }, "msisdn": "0712345678", "payment_method": "MpesaKE"}headers = { "Authorization": "Bearer YOUR_ACCESS_TOKEN", "Accept": "application/json", "Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.json())<?php
$curl = curl_init();
curl_setopt_array($curl, [ CURLOPT_URL => "https://cybqa.pesapal.com/pesapalv3/api/transactions/stk", CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => "", CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_POSTFIELDS => json_encode([ 'id' => 'ORDER-1234', 'currency' => 'KES', 'amount' => 100, 'description' => 'Test payment', 'callback_url' => 'https://example.com/payment-complete', 'notification_id' => 'YOUR_IPN_ID', 'billing_address' => [ 'email_address' => 'customer@example.com' ], 'msisdn' => '0712345678', 'payment_method' => 'MpesaKE' ]), CURLOPT_HTTPHEADER => [ "Accept: application/json", "Authorization: Bearer YOUR_ACCESS_TOKEN", "Content-Type: application/json" ],]);
$response = curl_exec($curl);$err = curl_error($curl);
curl_close($curl);
if ($err) { echo "cURL Error #:" . $err;} else { echo $response;}package main
import ( "fmt" "strings" "net/http" "io")
func main() {
url := "https://cybqa.pesapal.com/pesapalv3/api/transactions/stk"
payload := strings.NewReader("{\n \"id\": \"ORDER-1234\",\n \"currency\": \"KES\",\n \"amount\": 100,\n \"description\": \"Test payment\",\n \"callback_url\": \"https://example.com/payment-complete\",\n \"notification_id\": \"YOUR_IPN_ID\",\n \"billing_address\": {\n \"email_address\": \"customer@example.com\"\n },\n \"msisdn\": \"0712345678\",\n \"payment_method\": \"MpesaKE\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer YOUR_ACCESS_TOKEN") req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close() body, _ := io.ReadAll(res.Body)
fmt.Println(res) fmt.Println(string(body))
}Full schema: directMobileMoneyStk.
The body is an ordinary order plus two fields:
| Field | Required | What it does |
|---|---|---|
msisdn |
Yes | The phone number to prompt. |
payment_method |
Yes | MpesaKE, AirtelKE or AirtelUG. |
A successful push returns:
{ "business_number": "220222", "payment_message": "Request processed successfully", "account_number": "92266923", "order_tracking_id": "68691e1c-e014-44ad-a9ee-d9eefc38f1b3", "merchant_reference": "2962192609085431", "redirect_url": null, "error": null, "status": "200"}There is no redirect_url, because there is nowhere to send anyone. What you
get instead is business_number and account_number: the paybill and account
reference the customer can use to pay manually.
Show both on screen. Prompts get missed, dismissed by accident, or arrive while the phone is off. Displaying the paybill details is the difference between a customer completing the payment another way and abandoning it.
What the customer sees
Section titled “What the customer sees”The prompt arrives on the handset within a second or two. It names the amount
and the payee and asks for the M-Pesa PIN, and that is all. Your description,
your id and the account reference do not appear on it.
That has a practical consequence: the prompt gives the customer almost nothing to recognise the payment by. Tell them what to expect before you send it, and show the amount on your own screen so the two match.
They can approve it, decline it, or ignore it until it times out.
Confirming the outcome
Section titled “Confirming the outcome”Confirm exactly as you would for a hosted payment, using the order_tracking_id
and GetTransactionStatus.
{ "payment_method": "MpesaKE", "amount": 100, "confirmation_code": "24152319", "payment_status_description": "Failed", "description": null, "payment_account": "2547xxx02697", "status_code": 2, "merchant_reference": "ORDER-1234", "payment_status_code": "request_terminated_by_user", "currency": "KES"}That is a payment the customer declined. A completed one differs only in the fields that describe the outcome:
{ "confirmation_code": "UI76G5D93D", "payment_status_description": "Completed", "status_code": 1, "payment_status_code": ""}Three fields are worth knowing here.
status_code is the outcome: 1 completed, 2 failed. merchant_reference is
your id, correctly, unlike in the push response. And payment_status_code
sometimes names the reason for a failure, which is the only way to tell a
customer who refused from a prompt that never arrived:
| Situation | payment_status_code |
|---|---|
| Completed | "" |
| Customer declined the prompt | request_terminated_by_user |
| Number unreachable | "" |
Note it is empty in two of those three, so treat a value as useful detail rather than something to depend on.