Skip to content

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:

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.

You register a URL once, recieve an ipn_id, and then reference that id as notification_id on every order you create.

  1. Get an access token from RequestToken.

  2. 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"
    }'

    ipn_notification_type accepts GET or POST and decides how Pesapal calls your endpoint. Values are case insensitive, so get also works.

  3. Store the ipn_id from the response. That value is your notification_id when 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"
    }

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

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.

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.

GetIpnList returns every IPN URL registered against your account.

Terminal window
curl --request GET \
--url https://cybqa.pesapal.com/pesapalv3/api/URLSetup/GetIpnList \
--header 'Accept: application/json' \
--header 'Authorization: Bearer YOUR_ACCESS_TOKEN'

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"
}
]

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.

A correct handler should follow these 4 rules:

  1. Respond 200 quickly. Do the work after acknoledging, not before.

  2. Ignore the parameter as evidence. Take OrderTrackingId from the notification and nothing else.

  3. Call GetTransactionStatus and read status_code.

  4. 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);
}
});
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'

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:

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.

Full schemas for both operations are in the API reference: registerIpn and getIpnList.

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

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

Reading an existing registration back returns the same instant with trailing zeros trimmed and no Z:

2026-09-03T11:29:08.61

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

Parameter Notes
OrderTrackingId Pesapal’s order identifier.
OrderMerchantReference Your id for the order.
OrderNotificationType IPNCHANGE in every notification observed.