Pesapal Voucher Purchase Flow
This guide walks through the complete integration lifecycle for Pesapal deposits: how a customer "purchases" credit through Pesapal's hosted iframe checkout, how your platform is notified, and how the funds land in your merchant wallet. It follows the same purchase pattern as the M-Pesa Voucher Flow and PayPal Voucher Purchase, but Pesapal uses an iframe + IPN pattern rather than a full-page redirect.
See the Pesapal API Reference for full endpoint details.
Customer ──Pesapal Iframe Checkout──▶ PesaVoucher ──IPN confirmation──▶ Your PlatformYour Platform ──credits──▶ Customer's wallet balance
Unlike PayPal's full-page redirect, the customer never leaves your page — they pay inside an embedded iframe. Pesapal notifies PesaVoucher of the result via a server-to-server IPN call, independent of what the customer's browser does.
- Customer selects Pesapal as a deposit option and enters an amount
- Your backend calls Initiate Payment, receiving a
redirect_urlandorder_tracking_id - You embed
redirect_urlin an iframe on your deposit page - Customer completes payment inside the iframe (card, mobile money, or bank)
- Pesapal redirects the iframe to the callback URL, and separately POSTs an IPN notification to PesaVoucher
- PesaVoucher verifies the transaction and forwards a normalized webhook to your configured URL
- Your backend credits the user's balance once it receives a
COMPLETEDwebhook
1. Customer clicks "Deposit via Pesapal"2. Your backend calls POST /initiate.php with merchant_id, order_id, amount,currency, description, phone_number, email_address, first_name, last_name3. Response returns redirect_url + order_tracking_id4. Embed redirect_url in an iframe5. Customer completes payment inside the iframe6. Pesapal notifies PesaVoucher via IPN (listen_pesapal.php) — authoritative7. Pesapal also redirects the iframe to callback.php (or your callback_url) — UX only8. PesaVoucher forwards a normalized webhook to your configured URL9. Your backend credits the user's balance on COMPLETED status
async function initiatePesapalPayment({ userId, amount, currency = 'KES', customer }) {const orderId = `DEP-${userId}-${Date.now()}`;await db.createSession({order_id: orderId,user_id: userId,amount,currency,status: 'pending',created_at: new Date(),});const res = await fetch(`${PV_BASE_URL}/initiate.php`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({merchant_id: MERCHANT_ID,order_id: orderId,amount,currency,description: 'Wallet top-up',phone_number: customer.phone,email_address: customer.email,first_name: customer.firstName,last_name: customer.lastName,}),});const { redirect_url, order_tracking_id } = await res.json();await db.updateSession(orderId, { order_tracking_id });return { redirectUrl: redirect_url, orderId }; // embed redirectUrl in an iframe}// Express routerouter.post('/api/deposit/initiate-pesapal', requireAuth, async (req, res) => {const { redirectUrl, orderId } = await initiatePesapalPayment({userId: req.user.id,amount: parseFloat(req.body.amount),customer: req.user,});res.json({ success: true, iframe_url: redirectUrl, order_id: orderId });});
<!-- Frontend: embed the returned iframe_url --><iframe src="{{ iframe_url }}" width="100%" height="600" frameborder="0"></iframe>
router.get('/deposit/pesapal/return', requireAuth, async (req, res) => {const { OrderTrackingId, OrderMerchantReference } = req.query;// Don't credit here — just show a "processing" screen.// The webhook/IPN below is the authoritative signal.res.render('deposit-processing', { orderId: OrderMerchantReference });});
router.post('/api/pesavoucher/webhook', express.json(), async (req, res) => {res.status(200).json({ received: true }); // ack immediatelyconst { order_id, payment_status_description, amount } = req.body;const session = await db.getSessionByOrderId(order_id);if (!session || session.status === 'completed') return; // idempotency guardif (payment_status_description !== 'COMPLETED') {await db.updateSession(order_id, { status: payment_status_description.toLowerCase() });return;}await db.creditUserBalance(session.user_id, amount);await db.updateSession(order_id, { status: 'completed', completed_at: new Date() });notifyUser(session.user_id, { event: 'deposit_confirmed', amount });});
Why the webhook and not the redirect? Pesapal treats the IPN as the source of truth — the customer can close the browser before the redirect completes, so the redirect is UX feedback only. Never credit a user from the redirect handler alone.
Pesapal can fire more than one IPN for the same order as its status changes (e.g. PENDING → COMPLETED), and the same terminal status may be delivered more than once:
- Session status check — skip if
status === 'completed' - Unique constraint on
order_id— prevents crediting the same deposit twice - Only credit on the first
COMPLETEDnotification for a givenorder_id— treat later duplicates as no-ops
const session = await db.getSessionByOrderId(order_id);if (!session || session.status === 'completed') return; // already handled
- [ ] Store
order_id/order_tracking_idbefore rendering the iframe - [ ] Embed
redirect_urlin an iframe, not a full-page redirect - [ ] Webhook handler credits the user on
COMPLETED— never the browser redirect handler - [ ] Idempotency guard on the webhook handler
- [ ] Handle
FAILED,INVALID, andREVERSEDstatuses - [ ] Confirm sandbox/demo credentials with support before testing (no sandbox base URL is documented for the wrapper endpoints)
- [ ] HTTPS on your webhook and callback URLs
No sandbox base URL is documented yet for the PesaVoucher Pesapal wrapper endpoints (/initiate.php, /pesapal/callback.php, /listen_pesapal.php) — contact support@pesavoucher.com for sandbox credentials before going live.
If you're integrating with Pesapal directly (see the Direct Pesapal API Reference in the Pesapal API doc), Pesapal's own demo environment is available at https://cybqa.pesapal.com/pesapalv3 using their public demo consumer key/secret.
Test scenarios to cover before launch:
- Happy path — initiate → pay inside iframe → webhook
COMPLETED→ credit user - Failed payment — confirm
FAILEDstatus is handled without crediting - Duplicate IPN — resend the same
COMPLETEDnotification twice, confirm the user is credited once - Redirect without webhook — confirm you don't credit the user from the redirect handler alone
Use the PesaVoucher — Pesapal API v2 Postman collection's "PesaVoucher Pesapal Endpoints" folder to exercise Initiate Payment, the callback redirect, and the IPN listener without a live Pesapal account. The "Pesapal Direct" folder documents the underlying Pesapal v3 calls if you need to test against Pesapal's own API.