Skip to content

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 requested
Created (unpaid) ───┤
└──> Cancelled

An order tells Pesapal how much to collect, in which currency, and where to send the customer afterwards:

Terminal window
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"
}
}'

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.

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.

To find out whether the order you created has been paid, you ask Pesapal directly using the order_tracking_id you received.

Terminal window
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'
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.

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

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 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.

Terminal window
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"
}'

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.

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.

Terminal window
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"
}'

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.