Payment Lifecycle
Taking a payment with Pesapal means creating an order, sending your customers to a hosted payment page, and then asking Pesapal what happened. Your server never touches sensitive customer information such as the card details.
An order moves through these states:
┌──> Completed ──> Refund requestedCreated (unpaid) ───┤ └──> CancelledCreating an Order
Section titled “Creating an Order”An order tells Pesapal how much to collect, in which currency, and where to send the customer afterwards:
curl --request POST \ --url https://cybqa.pesapal.com/pesapalv3/api/Transactions/SubmitOrderRequest \ --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" }}'const url = 'https://cybqa.pesapal.com/pesapalv3/api/Transactions/SubmitOrderRequest';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"}}'};
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/SubmitOrderRequest"
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" }}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/SubmitOrderRequest", 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' ] ]), 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/SubmitOrderRequest"
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}")
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))
}Every field this endpoint accepts is listed in the API reference.
| Field | Required | What it does |
|---|---|---|
id |
Yes | Your reference for this order, which you generate. Must be unique and at most 50 characters. |
currency |
Yes | Three-letter ISO code, for example KES. |
amount |
Yes | Accepts a number or a numeric string. Must be greater than zero. |
description |
Yes | Shown to the customer on the payment page. |
callback_url |
Yes | Where the customer’s browser is sent after they pay. |
notification_id |
Yes | The ipn_id you got from registering an IPN URL. |
billing_address |
Yes | Must be present. May be an empty object {}. |
cancellation_url |
No | Where the browser goes if the customer abandons payment. |
branch |
No | A store or branch label, up to 30 characters. |
redirect_mode |
No | TOP_WINDOW or PARENT_WINDOW, if you embed the payment page in an iframe. |
billing_address is an object. Every field in it is optional, but the object
itself must be present:
| Field | Notes |
|---|---|
email_address |
Up to 60 characters. Prefilled on the payment page. |
phone_number |
Any format. |
first_name |
Up to 50 characters. |
middle_name |
Up to 50 characters. |
last_name |
Up to 50 characters. |
country_code |
Two-letter ISO 3166-1 code, for example KE. |
line_1 |
Street address. |
line_2 |
Street address, second line. |
city |
|
state |
|
postal_code |
|
zip_code |
A successful order returns a payment link and Pesapal’s own identifier for the order:
{ "order_tracking_id": "523a...", "merchant_reference": "ORDER-1234", "redirect_url": "https://cybqa.pesapal.com/pesapaliframe/PesapalIframe3/Index?OrderTrackingId=523a...", "error": null, "status": "200"}You now have two identifiers for the same order. merchant_reference is the id you sent,
echoed back. order_tracking_id is Pesapal’s, and it is what every other endpoint expects.
Save it against your order before redirecting the customer.
Recurring Payments
Section titled “Recurring Payments”A subscription is a normal order with two extra fields. There is no separate
subscriptions endpoint, and the response, the redirect_url and the payment page
all work exactly as they do for a one-off payment:
{ "id": "SUB-1024", "currency": "KES", "amount": 100, "description": "Monthly subscription", "callback_url": "https://example.com/payment-complete", "notification_id": "YOUR_IPN_ID", "billing_address": { "email_address": "customer@example.com" }, "account_number": "CUSTOMER-001", "subscription_details": { "start_date": "01-01-2027", "end_date": "01-01-2028", "frequency": "MONTHLY" }}| Field | Required | What it does |
|---|---|---|
account_number |
Yes | Your identifier for the subscriber, up to 50 characters. |
subscription_details |
Yes | When the subscription runs and how often. |
subscription_details is an object, and all three of its fields are needed:
| Field | Notes |
|---|---|
start_date |
When billing begins, formatted dd-MM-yyyy. Must be in the future. |
end_date |
When billing stops, formatted dd-MM-yyyy. |
frequency |
DAILY, WEEKLY, MONTHLY, QUARTERLY or YEARLY, in any case. |
This creates the subscription and takes the first payment, which behaves like any
other order: redirect the customer, then confirm with GetTransactionStatus. How
subsequent charges are delivered, whether each generates its own order and
notification, and how a subscription is stopped, are not covered here.
Checking Payment Status
Section titled “Checking Payment Status”To find out whether the order you created has been paid, you ask Pesapal directly using the
order_tracking_id you received.
curl --request GET \ --url 'https://cybqa.pesapal.com/pesapalv3/api/Transactions/GetTransactionStatus?orderTrackingId=YOUR_ORDER_TRACKING_ID' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'const url = 'https://cybqa.pesapal.com/pesapalv3/api/Transactions/GetTransactionStatus?orderTrackingId=YOUR_ORDER_TRACKING_ID';const options = { method: 'GET', headers: {Authorization: 'Bearer YOUR_ACCESS_TOKEN', Accept: 'application/json'}};
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/GetTransactionStatus"
querystring = {"orderTrackingId":"YOUR_ORDER_TRACKING_ID"}
headers = { "Authorization": "Bearer YOUR_ACCESS_TOKEN", "Accept": "application/json"}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())<?php
$curl = curl_init();
curl_setopt_array($curl, [ CURLOPT_URL => "https://cybqa.pesapal.com/pesapalv3/api/Transactions/GetTransactionStatus?orderTrackingId=YOUR_ORDER_TRACKING_ID", CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => "", CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => "GET", CURLOPT_HTTPHEADER => [ "Accept: application/json", "Authorization: Bearer YOUR_ACCESS_TOKEN" ],]);
$response = curl_exec($curl);$err = curl_error($curl);
curl_close($curl);
if ($err) { echo "cURL Error #:" . $err;} else { echo $response;}package main
import ( "fmt" "net/http" "io")
func main() {
url := "https://cybqa.pesapal.com/pesapalv3/api/Transactions/GetTransactionStatus?orderTrackingId=YOUR_ORDER_TRACKING_ID"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", "Bearer YOUR_ACCESS_TOKEN") req.Header.Add("Accept", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close() body, _ := io.ReadAll(res.Body)
fmt.Println(res) fmt.Println(string(body))
}| Query Param | Notes |
|---|---|
orderTrackingId |
The order_tracking_id from the order creation response. |
The response for a completed payment looks like this:
{ "payment_method": "Visa", "amount": 100.00, "created_date": "2026-09-03T15:47:34.567", "confirmation_code": "7884396530786173704004", "order_tracking_id": "523a...", "payment_status_description": "Completed", "description": "Transaction successfully processed.", "message": "Request processed successfully", "payment_account": "476173XXXXXX0010", "call_back_url": "https://example.com/callback?OrderTrackingId=523a...&OrderMerchantReference=ORDER-1234", "status_code": 1, "merchant_reference": "ORDER-1234", "account_number": null, "payment_status_code": "", "currency": "KES", "error": { "error_type": null, "code": null, "message": null }, "status": "200"}| Field | What it tells you |
|---|---|
status_code |
The outcome. 1 completed, 2 failed. |
payment_status_description |
The same outcome in words, for display. |
confirmation_code |
Pesapal’s identifier for the payment. Required to issue a refund. |
payment_method |
How they paid, for example Visa or MpesaKE. |
payment_account |
The masked card or the phone number used. |
amount |
What was actually charged. |
currency |
The currency charged. |
merchant_reference |
The id you sent when creating the order. |
created_date |
When the payment was made. |
When to check
Section titled “When to check”Three things can tell you a payment finished, and a solid integration uses all three for different jobs:
| Trigger | Good for | Limitation |
|---|---|---|
| An IPN arrives | Driving your order state | Needs a publicly reachable URL |
The customer returns to your callback_url |
Showing the customer a result immediately | Only happens if their browser follows the redirect |
| A scheduled sweep of unresolved orders | Catching anything the other two missed | Adds latency, and is wasteful if used alone |
A pattern that works
Section titled “A pattern that works”Drive your order state from your own database, and treat Pesapal as the thing you consult when your database does not yet know the answer.
When an IPN arrives, call GetTransactionStatus, write the outcome to your
order, and run whatever fulfilment the payment triggers. This is the path that
does the real work, because it reaches you whether or not the customer’s browser
cooperates.
When the customer returns to your callback_url, read your own order record
first. If the IPN has already landed, you know the outcome and can show it
immediately without calling Pesapal at all. Only if the order is still unresolved
do you check with Pesapal, update your record, and then show the result. That
keeps the customer-facing path fast in the common case and still correct in the
case where the IPN never arrived.
On a schedule, take orders that have been unresolved for more than a few
minutes and check those. This catches anything the first two missed. Sweep on an
interval rather than polling every order continuously: most requests would return
Pending Payment for orders nobody intends to pay, and while no rate limit has
been observed on this endpoint, none is published either.
Cancelling an Order
Section titled “Cancelling an Order”Cancelling invalidates an order that has not been paid, so the payment link can no longer be used. Use it when a customer abandons checkout, or when an order expires on your side and you do not want a stale link to be paid days later.
curl --request POST \ --url https://cybqa.pesapal.com/pesapalv3/api/Transactions/CancelOrder \ --header 'Accept: application/json' \ --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ --header 'Content-Type: application/json' \ --data '{ "order_tracking_id": "YOUR_ORDER_TRACKING_ID"}'const url = 'https://cybqa.pesapal.com/pesapalv3/api/Transactions/CancelOrder';const options = { method: 'POST', headers: { Authorization: 'Bearer YOUR_ACCESS_TOKEN', Accept: 'application/json', 'Content-Type': 'application/json' }, body: '{"order_tracking_id":"YOUR_ORDER_TRACKING_ID"}'};
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/CancelOrder"
payload = { "order_tracking_id": "YOUR_ORDER_TRACKING_ID" }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/CancelOrder", 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([ 'order_tracking_id' => 'YOUR_ORDER_TRACKING_ID' ]), 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/CancelOrder"
payload := strings.NewReader("{\n \"order_tracking_id\": \"YOUR_ORDER_TRACKING_ID\"\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: cancelOrder.
| Field | Required | What it does |
|---|---|---|
order_tracking_id |
Yes | The order_tracking_id of the order to cancel. |
A successful response returns:
{ "status": "200", "message": "Order successfully cancelled."}Note that there’s no error key in this response, on success or on failure, so the usual
check for error.code never fires here. Read status instead, which is "200" when the
cancellation worked and "500" when it did not.
Refunding a Payment
Section titled “Refunding a Payment”A refund is requested against a completed payment using its confirmation_code, which is why
that value is worth saving when the payment lands. Partial refunds are accepted, but you only
get one request per payment, so decide the amount before you send it.
curl --request POST \ --url https://cybqa.pesapal.com/pesapalv3/api/Transactions/RefundRequest \ --header 'Accept: application/json' \ --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ --header 'Content-Type: application/json' \ --data '{ "confirmation_code": "YOUR_CONFIRMATION_CODE", "amount": "100.00", "username": "YOUR_USERNAME", "remarks": "Customer requested refund"}'const url = 'https://cybqa.pesapal.com/pesapalv3/api/Transactions/RefundRequest';const options = { method: 'POST', headers: { Authorization: 'Bearer YOUR_ACCESS_TOKEN', Accept: 'application/json', 'Content-Type': 'application/json' }, body: '{"confirmation_code":"YOUR_CONFIRMATION_CODE","amount":"100.00","username":"YOUR_USERNAME","remarks":"Customer requested refund"}'};
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/RefundRequest"
payload = { "confirmation_code": "YOUR_CONFIRMATION_CODE", "amount": "100.00", "username": "YOUR_USERNAME", "remarks": "Customer requested refund"}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/RefundRequest", 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([ 'confirmation_code' => 'YOUR_CONFIRMATION_CODE', 'amount' => '100.00', 'username' => 'YOUR_USERNAME', 'remarks' => 'Customer requested refund' ]), 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/RefundRequest"
payload := strings.NewReader("{\n \"confirmation_code\": \"YOUR_CONFIRMATION_CODE\",\n \"amount\": \"100.00\",\n \"username\": \"YOUR_USERNAME\",\n \"remarks\": \"Customer requested refund\"\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: refundRequest.
| Field | Required | What it does |
|---|---|---|
confirmation_code |
Yes | Identifies the payment. From GetTransactionStatus. |
amount |
Yes | How much to refund. May be less than the original amount. |
username |
Yes | Your identifier for whoever authorised the refund. |
remarks |
Yes | Free text reason, for your own records. |
A successful request returns:
{ "status": "200", "message": "Refund request successfully"}As with cancellation, there is no error key on success or failure, so read
status rather than looking for error.code. Note the wording too: the refund
has been requested, not performed. Nothing in the response confirms money has
moved, and nothing tells you when it will.