Skip to Content
Pesa VoucherDeveloper Documentation

Pesapal Voucher Purchase

End-to-end guide for letting customers pay via Pesapal's hosted iframe checkout — from initiation to instant wallet credit.


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.


Business Model at a Glance

Customer ──Pesapal Iframe Checkout──▶ PesaVoucher ──IPN confirmation──▶ Your Platform
Your 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.


Flow — Customer Deposit (Iframe + IPN Flow)

What the user sees

  1. Customer selects Pesapal as a deposit option and enters an amount
  2. Your backend calls Initiate Payment, receiving a redirect_url and order_tracking_id
  3. You embed redirect_url in an iframe on your deposit page
  4. Customer completes payment inside the iframe (card, mobile money, or bank)
  5. Pesapal redirects the iframe to the callback URL, and separately POSTs an IPN notification to PesaVoucher
  6. PesaVoucher verifies the transaction and forwards a normalized webhook to your configured URL
  7. Your backend credits the user's balance once it receives a COMPLETED webhook

Step-by-step integration

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_name
3. Response returns redirect_url + order_tracking_id
4. Embed redirect_url in an iframe
5. Customer completes payment inside the iframe
6. Pesapal notifies PesaVoucher via IPN (listen_pesapal.php) — authoritative
7. Pesapal also redirects the iframe to callback.php (or your callback_url) — UX only
8. PesaVoucher forwards a normalized webhook to your configured URL
9. Your backend credits the user's balance on COMPLETED status

1. Initiate the payment and render the iframe

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 route
router.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>

2. Handle the browser redirect (UX only)

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 });
});

3. Handle the webhook (authoritative — credit here)

router.post('/api/pesavoucher/webhook', express.json(), async (req, res) => {
res.status(200).json({ received: true }); // ack immediately
const { order_id, payment_status_description, amount } = req.body;
const session = await db.getSessionByOrderId(order_id);
if (!session || session.status === 'completed') return; // idempotency guard
if (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.


Idempotency & Duplicate Prevention

Pesapal can fire more than one IPN for the same order as its status changes (e.g. PENDINGCOMPLETED), and the same terminal status may be delivered more than once:

  1. Session status check — skip if status === 'completed'
  2. Unique constraint on order_id — prevents crediting the same deposit twice
  3. Only credit on the first COMPLETED notification for a given order_id — treat later duplicates as no-ops
const session = await db.getSessionByOrderId(order_id);
if (!session || session.status === 'completed') return; // already handled

Checklist Before Go-Live

  • [ ] Store order_id / order_tracking_id before rendering the iframe
  • [ ] Embed redirect_url in 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, and REVERSED statuses
  • [ ] 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

Sandbox Testing

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:

  1. Happy path — initiate → pay inside iframe → webhook COMPLETED → credit user
  2. Failed payment — confirm FAILED status is handled without crediting
  3. Duplicate IPN — resend the same COMPLETED notification twice, confirm the user is credited once
  4. 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.