Instant Payment Notification
An Instant Payment Notification (IPN) is the request Pesapal makes to your server when something changes an order. It is how you find out that a customer paid without asking them to wait on a page.
This is the part of integration that is most likely to go wrong because of what the notification does not contain:
Callback vs IPN
Section titled “Callback vs IPN”They are easy to confuse, and they are not interchangeable.
| Callback | IPN | |
|---|---|---|
| Who receives it | The customer’s browser | Your server |
| When | Immediately after checkout | When the order changes state |
| Set by | callback_url on each order |
Registered once, referenced by notification_id |
| Fires if the customer closes the tab | No | Yes |
The callback is a redirect for the customer’s benefit, and the customer controls whether it ever happens. If they close the tab after paying, you will never see it. The IPN is the only mechanism that reaches you regardless. Any order state that matters to your business must be driven by the IPN.
Register IPN URL
Section titled “Register IPN URL”You register a URL once, recieve an ipn_id, and then reference that id as
notification_id on every order you create.
-
Get an access token from
RequestToken. -
Register the URL your server listens on.
Terminal window curl --request POST \--url https://cybqa.pesapal.com/pesapalv3/api/URLSetup/RegisterIPN \--header 'Accept: application/json' \--header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \--header 'Content-Type: application/json' \--data '{"url": "https://example.com/pesapal/ipn","ipn_notification_type": "GET"}'const url = 'https://cybqa.pesapal.com/pesapalv3/api/URLSetup/RegisterIPN';const options = {method: 'POST',headers: {Authorization: 'Bearer YOUR_ACCESS_TOKEN',Accept: 'application/json','Content-Type': 'application/json'},body: '{"url":"https://example.com/pesapal/ipn","ipn_notification_type":"GET"}'};try {const response = await fetch(url, options);const data = await response.json();console.log(data);} catch (error) {console.error(error);}import requestsurl = "https://cybqa.pesapal.com/pesapalv3/api/URLSetup/RegisterIPN"payload = {"url": "https://example.com/pesapal/ipn","ipn_notification_type": "GET"}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/URLSetup/RegisterIPN",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(['url' => 'https://example.com/pesapal/ipn','ipn_notification_type' => 'GET']),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 mainimport ("fmt""strings""net/http""io")func main() {url := "https://cybqa.pesapal.com/pesapalv3/api/URLSetup/RegisterIPN"payload := strings.NewReader("{\n \"url\": \"https://example.com/pesapal/ipn\",\n \"ipn_notification_type\": \"GET\"\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))}ipn_notification_typeacceptsGETorPOSTand decides how Pesapal calls your endpoint. Values are case insensitive, sogetalso works. -
Store the
ipn_idfrom the response. That value is yournotification_idwhen you create an order.{"url": "https://example.com/pesapal/ipn","created_date": "2026-09-03T11:29:08.6086628Z","ipn_id": "28f0d439-6b4a-4147-8dc1-d9f2fb681b6a","notification_type": 1,"ipn_notification_type_description": "POST","ipn_status": 1,"ipn_status_decription": "Active","status": "200","message": "Request processed successfully"}
The URL is never checked
Section titled “The URL is never checked”Pesapal only validates that the value looks like a URL. It does no check that the host resolves, that TLS is used, or that anything is listening.
| Check | Enforced |
|---|---|
| Looks like a URL | Yes. not-a-url is rejected with invalid_url |
| Uses HTTPS | No. http:// is accepted |
| Host resolves in DNS | No. A .invalid domain is accepted and marked Active |
| Endpoint responds | No. Nothing is ever sent during registration |
Idempotency
Section titled “Idempotency”Registration is idempotent by URL. Re-registering a URL you already Registered
returns the existing ipn_id and its original created_date rather than creating
a duplicate.
Rate limiting
Section titled “Rate limiting”The endpoint is rate-limited and accepts roughly one request every 8 seconds. Exceeding the rate limit returns:
{ "error": { "error_type": "api_error", "code": "general_system_decline_error", "message": "Request Declined.Too many requests" }, "status": null}The HTTP status for the rate limit is 409, not the usual 429 that rate-limit-aware
clients watch for. It also doesn’t include the Retry-After header.
List Registered IPN URLs
Section titled “List Registered IPN URLs”GetIpnList returns every IPN URL registered against your account.
curl --request GET \ --url https://cybqa.pesapal.com/pesapalv3/api/URLSetup/GetIpnList \ --header 'Accept: application/json' \ --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'const url = 'https://cybqa.pesapal.com/pesapalv3/api/URLSetup/GetIpnList';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/URLSetup/GetIpnList"
headers = { "Authorization": "Bearer YOUR_ACCESS_TOKEN", "Accept": "application/json"}
response = requests.get(url, headers=headers)
print(response.json())<?php
$curl = curl_init();
curl_setopt_array($curl, [ CURLOPT_URL => "https://cybqa.pesapal.com/pesapalv3/api/URLSetup/GetIpnList", 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/URLSetup/GetIpnList"
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))
}This endpoint returns a bare JSON array with no envelope, unlike every other endpoint in
the API. There’s no status and message fields to check.
[ { "url": "https://example.com/pesapal/ipn", "created_date": "2026-09-03T11:29:08.607", "ipn_id": "28f0d439-6b4a-4147-8dc1-d9f2fb681b6a", "notification_type": 1, "ipn_notification_type_description": "POST", "ipn_status": 1, "ipn_status_decription": "Active" }]The Notification
Section titled “The Notification”When an order changes state, Pesapal calls your registered IPN URL. The HTTP method
matches the ipn_notification_type you chose at registration, so a URL registered as
GET receives a GET request and vice versa.
A GET notification carries three query parameters and no request body:
GET /pesapal/ipn?OrderTrackingId=523a3fb1-568b-4c53-b9ff-d9f2c4866653 &OrderNotificationType=IPNCHANGE &OrderMerchantReference=ORDER-1234| Parameter | Description |
|---|---|
OrderTrackingId |
Pesapal’s identifier for the order. Use it to look the payment up. |
OrderMerchantReference |
The id you supplied when creating the order. |
OrderNotificationType |
IPNCHANGE in every notification observed. |
That is the whole notification. No payment status, no amount, no signature, and no auth header.
Handling Notifications
Section titled “Handling Notifications”A correct handler should follow these 4 rules:
-
Respond 200 quickly. Do the work after acknoledging, not before.
-
Ignore the parameter as evidence. Take
OrderTrackingIdfrom the notification and nothing else. -
Call
GetTransactionStatusand readstatus_code. -
Make it safe to run twice. Repeated delivery for same order is entirely plausible, so writes must be idempotent.
import express from 'express';
const app = express();
app.get('/pesapal/ipn', async (req, res) => { const trackingId = req.query.OrderTrackingId;
// 1. Acknowledge before doing any work. res.sendStatus(200);
if (typeof trackingId !== 'string') return;
// 2 and 3. Notification told you nothing. Ask. const status = await getTransactionStatus(trackingId);
// 4. Only act on a state you have not already recorded if (status.status_code === 1) { await markOrderPaidOnce(status.merchant_reference, status.confirmation_code); }});Verification
Section titled “Verification”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))
}The response for a completed payment looks like this:
{ "payment_method": "Visa", "amount": 100, "created_date": "2026-09-03T15:47:34.567", "confirmation_code": "7884396530786173704004", "order_tracking_id": "523a3fb1-568b-4c53-b9ff-d9f2c4866653", "payment_status_description": "Completed", "description": "Transaction successfully processed.", "merchant_reference": "ORDER-1024", "status_code": 1, "currency": "KES", "error": { "error_type": null, "code": null, "message": null }, "status": "200"}There are important details in this response that you need to pay attention to:
Testing
Section titled “Testing”The sandbox will not send an IPN until a payment actually completes, so end-to-end
testing means opening an order’s redirect_url in a browser and paying with a test
card
A request bin such as webhook.site is the fastest way to see a real notification, since it gives you a unique URL and logs the full request details.
Reference
Section titled “Reference”Full schemas for both operations are in the API reference: registerIpn and getIpnList.
RegisterIPN Request
Section titled “RegisterIPN Request”| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | Yes | Must look like a URL. Not checked for reachability. |
ipn_notification_type |
string | Yes | GET or POST, case insensitive, maximum 4 characters. |
A value longer than 4 characters is rejected for length before its value is
checked, so WEBHOOK returns "Invalid length.Maximum 4 characters" rather
than the more useful "Accepted values are POST or GET".
RegisterIPN Response
Section titled “RegisterIPN Response”| Field | Type | Notes |
|---|---|---|
url |
string | The registered URL, echoed back. |
created_date |
string | UTC. Format varies, see below. |
ipn_id |
string (GUID) | Pass as notification_id when creating orders. Store this. |
notification_type |
integer | 0 = GET, 1 = POST. |
ipn_notification_type_description |
string | GET or POST. |
ipn_status |
integer | 1 = Active. No other value has been observed. |
ipn_status_decription |
string | Misspelled in the API. Active. |
status |
string | "200" on success. A quoted string, not a number. |
message |
string | "Request processed successfully" on success. |
created_date cannot be parsed with a fixed format. A newly created registration
returns seven fractional-second digits and a Z:
2026-09-04T12:46:57.2509054ZReading an existing registration back returns the same instant with trailing
zeros trimmed and no Z:
2026-09-03T11:29:08.61So the fractional part can be anywhere from zero to seven digits, and the
timezone designator may be absent. Seven digits also exceeds what Python’s
datetime.fromisoformat accepts before 3.11.
IPN Query Parameters
Section titled “IPN Query Parameters”| Parameter | Notes |
|---|---|
OrderTrackingId |
Pesapal’s order identifier. |
OrderMerchantReference |
Your id for the order. |
OrderNotificationType |
IPNCHANGE in every notification observed. |