Joseph Kimenyu

Integrating Pesapal Across Five Frameworks So You Do Not Have To

May 1, 2026
7 min read
207 views

Pesapal is one of the most widely used payment gateways in East Africa. It supports M-Pesa, Airtel Money, Visa, and Mastercard, which makes it a practical choice for any product targeting Kenyan or regional users. The official documentation covers the API reasonably well, but the gap between reading the docs and having a working integration is wider than it looks.

I ran into enough friction during my own integration that I decided to document the entire flow and build working reference implementations in five frameworks. The result is an open-source repo called pesapal-integrations, covering FastAPI, Django, Express (TypeScript), NestJS, and Go (Gin). Every implementation is self-contained with Docker, PostgreSQL, and a .env file.

This article covers what the flow actually looks like, what the non-obvious parts are, and how the repo is structured so you can drop into whichever stack you are already using.


The Payment Flow

The Pesapal API 3.0 flow has more steps than most payment integrations. Here is the full sequence:

  1. Authenticate with your consumer key and secret to get a bearer token. The token is valid for 5 minutes.
  2. Register an IPN URL to get a notification ID. This must happen before you can submit any order.
  3. Submit an order request using the notification ID. You get back a redirect URL.
  4. Redirect the customer to that URL. Pesapal handles the payment UI.
  5. After payment, Pesapal calls your callback URL and your IPN URL, both with the same three parameters: OrderTrackingId, OrderMerchantReference, and OrderNotificationType.
  6. Call GetTransactionStatus with the tracking ID to get the actual payment result.
  7. Your IPN endpoint must respond with a specific JSON confirmation string.

Steps 2 and 6 are the parts that catch people off guard.


The Non-Obvious Parts

You cannot submit an order without registering an IPN first

The notification_id field in the order request is marked as required, and it refers to the ipn_id you receive when you register your IPN URL. If you try to submit an order without it, the request will fail. This means you need to call the RegisterIPN endpoint at least once before your first order, and store the returned ID.

In the repo, every implementation handles this automatically. Before submitting an order, the service checks whether the configured IPN URL is already registered. If it is, it reuses the existing ID. If not, it registers the URL and uses the new ID. This prevents duplicate registrations across server restarts.

The callback and IPN do not include payment status

When Pesapal calls your callback URL and your IPN URL, the parameters contain only the tracking ID and merchant reference. There is no payment status in those parameters. You have to call GetTransactionStatus yourself, using the tracking ID, to find out whether the payment completed, failed, or was reversed.

This is intentional on Pesapal's part for security, but it means you cannot simply check a status field when the callback loads. Every implementation in the repo calls GetTransactionStatus internally in both the callback handler and the IPN handler.

The token expires in 5 minutes

The bearer token has a 5-minute lifetime. If you authenticate once at startup and cache the token indefinitely, requests will start failing after 5 minutes. The pattern to use is: cache the token and its expiry time, then check before each API call whether the token has more than 30 seconds remaining. If not, re-authenticate first.

Every PesapalClient in the repo implements this pattern. You never need to think about token refresh manually.

The IPN endpoint must return a specific JSON response

After your IPN handler processes a notification, it must respond with a JSON body in this exact shape:

{
  "orderNotificationType": "IPNCHANGE",
  "orderTrackingId": "the-tracking-id",
  "orderMerchantReference": "your-merchant-reference",
  "status": 200
}

Use status: 500 if something went wrong on your side. The callback URL, by contrast, should not return this JSON. It should redirect the customer to a receipt page. If you mix these up, Pesapal will keep retrying the IPN call thinking it was not acknowledged.

IPN URLs must be publicly accessible

Pesapal cannot reach localhost. During local development you need a tunneling tool to expose your local server to the internet. Cloudflared and ngrok both work. The generated public URL goes into your .env as PESAPAL_IPN_URL and PESAPAL_CALLBACK_URL.


What Is in the Repo

The repo has five standalone implementations under a single root:

pesapal-integrations/
  docs/
    pesapal-account-setup.md
    api-overview.md
  fastapi/
  python/django/
  javascript/express-ts/
  javascript/nestjs/
  golang/gin/

Each implementation includes:

  • A PesapalClient service that handles token caching, IPN registration, and all API calls
  • A payments router or controller covering: initiate, callback, IPN (GET and POST), status lookup, refund, and cancellation
  • A PostgreSQL-backed payment record that stores the merchant reference, tracking ID, payment status, and confirmation code
  • A Dockerfile and docker-compose.yml that spin up the app and a Postgres instance together
  • A .env.example listing every required environment variable
  • A docs.md covering setup, endpoints, and implementation notes specific to that framework

The docs/ folder at the root covers how to create a Pesapal merchant account, get sandbox credentials, and prepare for a production launch.


Running Any Implementation

Every implementation starts the same way:

cd fastapi   # or python/django, javascript/express-ts, javascript/nestjs, golang/gin
cp .env.example .env
# Fill in your credentials
docker compose up --build

For Django specifically, the Docker command automatically runs migrations before starting the server. For FastAPI, table creation happens on startup via SQLAlchemy. For Go, a raw CREATE TABLE IF NOT EXISTS runs on startup. For the TypeScript implementations, Sequelize and TypeORM handle schema sync in development mode.


Framework Notes

FastAPI uses async httpx for all Pesapal API calls, which fits naturally with the async request lifecycle. Token caching uses instance variables on a module-level singleton.

Django uses synchronous httpx with a threading.Lock to make token caching safe under multi-threaded Gunicorn workers. The admin and auth apps are stripped out since this is API-only.

Express (TypeScript) uses a types.ts file that defines interfaces for every Pesapal request and response shape. The config module throws at startup if any required variable is missing, so misconfiguration surfaces immediately rather than on the first API call.

NestJS uses dependency injection throughout. PesapalService is a singleton injectable that reads config via ConfigService. class-validator DTOs validate incoming request bodies before they reach the service layer.

Go (Gin) uses only the standard library for HTTP calls. Token caching uses a sync.Mutex for concurrency safety. The database layer uses raw SQL with database/sql rather than an ORM.


Getting Started with Pesapal

If you do not have a Pesapal merchant account yet, the repo includes a full setup guide at docs/pesapal-account-setup.md. The short version:

For sandbox testing, you can download test credentials directly from the Pesapal developer documentation without creating an account. For production, you need to complete KYC on the Pesapal dashboard and wait for approval, which typically takes one to three business days. Your live consumer key and secret are sent to your registered email on approval.


What Is Next

The repo currently covers five frameworks. Django REST Framework with token authentication, Next.js API routes, and a plain PHP implementation are on the list. Pull requests for additional frameworks are welcome.

The goal is a single place where a developer can find a working, runnable Pesapal integration for whatever stack they are using, without having to figure out the token flow, IPN registration order, or callback response format from scratch.

The repo is at: https://github.com/kimenyu/pesapal-integrations