Card Payments Voucher Purchase Flow
This guide walks through the complete integration lifecycle for card deposits: how a customer "purchases" credit by paying with a card (Visa, Mastercard, and more) through a hosted iframe checkout, 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 this rail doesn't yet deliver a server-to-server webhook to merchants — you confirm completion by polling instead.
See the Card Payments API Reference for full endpoint details, and Currencies for how deposits convert into the paying user's local currency.
Customer ──Card Checkout (Visa/Mastercard, etc.)──▶ PesaVoucher ──▶ Payment ProcessorYour Platform ──polls Check Transaction Status──▶ PesaVoucherYour Platform ──credits, once COMPLETED──▶ Customer's wallet balance
The customer never leaves your page — they enter their card details and pay inside an embedded iframe. Once they finish, your backend polls PesaVoucher to confirm the result rather than waiting for a push notification.
No merchant webhook for this rail yet. Unlike M-Pesa/PayPal/Hosted Checkout, card payments don't currently forward a completion webhook to your configured URL — the processor's own IPN only updates PesaVoucher's internal records. Until that changes, polling
POST /transaction_status.phpis your source of truth, not a webhook.
- Customer selects Card Payment 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 enters their card details and completes payment inside the iframe
- The processor redirects the iframe to your
callback_url— UX only, don't trust it - Your backend polls Check Transaction Status with
order_tracking_iduntil it returnsCOMPLETED - Your backend credits the user's balance once status is confirmed
COMPLETED
1. Customer clicks "Deposit via Card"2. Your backend calls POST /submit_order.php with merchant_id, merchant_ref,amount, currency, description, callback_url, billing_address3. Response returns redirect_url + order_tracking_id4. Embed redirect_url in an iframe5. Customer completes payment inside the iframe6. The processor redirects the iframe to your callback_url — UX only7. Your backend polls POST /transaction_status.php with order_tracking_id8. Your backend credits the user's balance once status is COMPLETED
async function initiateCardPayment({ userId, amount, currency, customer }) {// currency must be THIS user's preferred currency (KES, TZS, UGX, or USD)// — read it from the user's own profile, don't default or hardcode it.const merchantRef = `DEP-${userId}-${Date.now()}`;await db.createSession({order_id: merchantRef,user_id: userId,amount,currency,status: 'pending',created_at: new Date(),});const res = await fetch(`${PV_PESAPAL_BASE_URL}/submit_order.php`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({merchant_id: MERCHANT_ID,merchant_ref: merchantRef,amount,currency,description: 'Wallet top-up',callback_url: `${PUBLIC_BASE_URL}/deposit/card/return`,billing_address: {phone_number: customer.phone,email_address: customer.email,first_name: customer.firstName,last_name: customer.lastName,},}),});const { success, redirect_url, order_tracking_id, message } = await res.json();if (!success) throw new Error(message || 'Failed to initiate card payment');await db.updateSession(merchantRef, { order_tracking_id });return { redirectUrl: redirect_url, orderId: merchantRef }; // embed redirectUrl in an iframe}// Express routerouter.post('/api/deposit/initiate-card', requireAuth, async (req, res) => {const { redirectUrl, orderId } = await initiateCardPayment({userId: req.user.id,amount: parseFloat(req.body.amount),currency: req.user.preferred_currency, // THIS user's own currencycustomer: 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/card/return', requireAuth, async (req, res) => {const { OrderTrackingId, OrderMerchantReference } = req.query;// Don't credit here — just show a "processing" screen and kick off polling// (from the frontend, or a background job) against Check Transaction Status.res.render('deposit-processing', { orderId: OrderMerchantReference });});
async function pollCardPaymentStatus(orderId) {const session = await db.getSessionByOrderId(orderId);if (!session || session.status === 'completed') return session; // idempotency guardconst res = await fetch(`${PV_PESAPAL_BASE_URL}/transaction_status.php`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ order_tracking_id: session.order_tracking_id }),});const data = await res.json();if (data.status !== 'COMPLETED') {await db.updateSession(orderId, { status: (data.status || 'pending').toLowerCase() });return session;}await db.creditUserBalance(session.user_id, data.amount, data.currency);await db.updateSession(orderId, { status: 'completed', completed_at: new Date() });notifyUser(session.user_id, { event: 'deposit_confirmed', amount: data.amount, currency: data.currency });return session;}// Poll on an interval from the frontend, or from a background job —// either way, this function (not the browser redirect) is what credits the user.router.get('/api/deposit/card/status/:orderId', requireAuth, async (req, res) => {const session = await pollCardPaymentStatus(req.params.orderId);res.json({ status: session.status });});
Why polling and not a webhook? This rail's IPN currently only updates PesaVoucher's own internal records — it isn't forwarded to your configured webhook URL yet. Until that's available, treat Check Transaction Status as authoritative and never credit a user from the browser redirect handler alone.
Polling can observe the same COMPLETED status more than once if you poll on an interval:
- Session status check — skip if
status === 'completed' - Unique constraint on your own
merchant_ref— prevents crediting the same deposit twice - Only credit on the first poll that sees
COMPLETEDfor a given order — treat subsequent polls as no-ops
const session = await db.getSessionByOrderId(orderId);if (!session || session.status === 'completed') return; // already handled
- [ ] Store
merchant_ref/order_tracking_idbefore rendering the iframe - [ ] Embed
redirect_urlin an iframe, not a full-page redirect - [ ] Always supply your own
callback_url— PesaVoucher's default handler doesn't return the customer to your site - [ ] Polling logic credits the user on
COMPLETED— never the browser redirect handler - [ ] Idempotency guard on the polling handler
- [ ] Handle
FAILED,INVALID, andREVERSEDstatuses - [ ] Confirm sandbox/demo credentials with support before testing (no sandbox base URL is documented for this endpoint)
- [ ] HTTPS on your callback URL
No sandbox base URL is documented yet for this endpoint — contact support@pesavoucher.com for sandbox credentials before going live.
Test scenarios to cover before launch:
- Happy path — initiate → pay inside iframe → poll returns
COMPLETED→ credit user - Failed payment — confirm
FAILEDstatus is handled without crediting - Duplicate poll — poll after the user is already credited, confirm it's a no-op
- Redirect without a poll — confirm you don't credit the user from the redirect handler alone
- Cross-currency — repeat the happy path for a
TZSorUGXuser and confirm the credited amount uses that user's currency
Use the Card Payments test collection's endpoints to exercise Initiate Payment and Check Transaction Status without a live processor account. Contact support for advanced direct-integration testing options.