Pesapal is the payment gateway I reach for most often on Kenyan and Ugandan Acumatica projects that need to accept card payments and mobile money through a single integration rather than wiring M-Pesa Daraja and a card processor separately. It's a smaller platform than Interswitch or Flutterwave, and the API reflects that, fewer moving parts, but also a couple of sharp edges around its IPN (Instant Payment Notification) model that I've had to explain to every client's finance team at least once.
Token auth, then a two-step order submission
Pesapal v3's API uses a consumer key/secret pair exchanged for a bearer token, then a SubmitOrderRequest call that registers the transaction and returns a redirect URL for the hosted checkout page, structurally similar to Interswitch's model, but Pesapal's confirmation side leans much more heavily on IPNs than on a query-after-redirect pattern.
var order = new
{
id = invoiceRefId, // your own unique reference, not Pesapal's
currency = "KES",
amount = invoiceTotal,
description = $"Invoice {invoiceNbr}",
callback_url = "https://erp.client.co.ke/pesapal/callback",
notification_id = _settings.RegisteredIpnId, // registered separately, see below
billing_address = new { email_address = customerEmail, phone_number = customerPhone }
};
var resp = await _httpClient.PostAsJsonAsync($"{_baseUrl}/api/Transactions/SubmitOrderRequest", order);
Pesapal requires you to register your notification endpoint URL via a separate RegisterIPN call first, which returns a notification_id GUID you then reference on every order submission. Skipping this, pointing notification_id at a URL string directly, which is the natural first guess coming from other gateways, fails silently in the sense that the order still submits fine, but you simply never receive notifications. This one costs every developer their first afternoon on Pesapal.
The IPN callback is a ping, not a payload
This is the part that surprises developers coming from Stripe-style webhooks: Pesapal's IPN doesn't hand you the transaction details in the notification body. It sends a minimal ping, order tracking ID and merchant reference, and your endpoint is expected to turn around and call GetTransactionStatus to fetch the actual outcome:
[HttpGet, Route("pesapal/ipn")]
public async Task<IHttpActionResult> ReceiveIpn(string OrderTrackingId, string OrderMerchantReference)
{
var status = await _pesapalClient.GetTransactionStatusAsync(OrderTrackingId);
using (var scope = new PXTransactionScope())
{
var staging = new UsrPesapalStaging
{
OrderTrackingId = OrderTrackingId,
MerchantReference = OrderMerchantReference,
StatusCode = status.PaymentStatusCode, // 0=Invalid,1=Completed,2=Failed,3=Reversed
RawPayload = JsonSerializer.Serialize(status),
};
PXCache<UsrPesapalStaging>.Insert(staging);
scope.Complete();
}
// Pesapal expects a specific JSON ack shape back, not just 200 OK
return Ok(new { orderNotificationType = "IPNCHANGE", orderTrackingId = OrderTrackingId,
orderMerchantReference = OrderMerchantReference, status = 200 });
}
Note the response shape on acknowledgment: Pesapal checks the ack body, not just the HTTP status code, and a plain 200 OK with no body gets treated as a failed delivery and retried indefinitely, which floods your endpoint with duplicate ping notifications for a transaction that already resolved.
One API, but mobile money settlement behaves differently from card
Pesapal routes both card payments and mobile money (M-Pesa, Airtel Money) through the same order submission flow, which is genuinely convenient, but the settlement timing differs enough that I still branch reconciliation logic on the payment method returned in the transaction status response. Mobile money transactions typically confirm within seconds; card payments can sit in a pending state longer depending on the issuing bank. If your AR application logic assumes "IPN received means settle now" uniformly, mobile money orders behave fine and card orders occasionally leave an invoice looking unpaid for an uncomfortable few minutes that generates a support ticket. I now set customer-facing order status messaging to reflect "processing" rather than implying instant confirmation for card transactions specifically.
Daily reconciliation against Pesapal's own transaction list
Because IPN delivery, while generally reliable, is not guaranteed exactly-once or even guaranteed-delivered in every network condition, I run a nightly scheduled process that calls Pesapal's transaction list endpoint for the prior day and reconciles it against Acumatica's recorded payments, flagging anything Pesapal shows as completed that never made it into Cash Management. This has caught real gaps, usually a handful of transactions a month on a moderate-volume client, that pure IPN-driven processing missed.
Wrapping up
Pesapal's IPN model requires pre-registering your notification endpoint to get a GUID, treats the IPN callback as a ping you must resolve via a follow-up status call rather than a self-contained payload, and expects a specific JSON ack shape or it will retry forever. Layer a nightly reconciliation job on top regardless of how reliable the IPN feed looks in testing, it's cheap insurance against the handful of notifications that don't make it through in production.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.