Skip to Content
Pesa VoucherDeveloper Documentation

Card Payments Voucher Purchase

End-to-end guide for letting customers pay by card via a hosted iframe checkout — from initiation to instant wallet credit.


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.


Business Model at a Glance

Customer ──Card Checkout (Visa/Mastercard, etc.)──▶ PesaVoucher ──▶ Payment Processor
Your Platform ──polls Check Transaction Status──▶ PesaVoucher
Your 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.php is your source of truth, not a webhook.


Flow — Customer Deposit (Iframe + Polling Flow)

What the user sees

  1. Customer selects Card Payment 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 enters their card details and completes payment inside the iframe
  5. The processor redirects the iframe to your callback_url — UX only, don't trust it
  6. Your backend polls Check Transaction Status with order_tracking_id until it returns COMPLETED
  7. Your backend credits the user's balance once status is confirmed COMPLETED

Step-by-step integration

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_address
3. Response returns redirect_url + order_tracking_id
4. Embed redirect_url in an iframe
5. Customer completes payment inside the iframe
6. The processor redirects the iframe to your callback_url — UX only
7. Your backend polls POST /transaction_status.php with order_tracking_id
8. Your backend credits the user's balance once status is COMPLETED

1. Initiate the payment and render the iframe

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 route
router.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 currency
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/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 });
});

3. Poll for completion (authoritative — credit here)

async function pollCardPaymentStatus(orderId) {
const session = await db.getSessionByOrderId(orderId);
if (!session || session.status === 'completed') return session; // idempotency guard
const 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.


Idempotency & Duplicate Prevention

Polling can observe the same COMPLETED status more than once if you poll on an interval:

  1. Session status check — skip if status === 'completed'
  2. Unique constraint on your own merchant_ref — prevents crediting the same deposit twice
  3. Only credit on the first poll that sees COMPLETED for a given order — treat subsequent polls as no-ops
const session = await db.getSessionByOrderId(orderId);
if (!session || session.status === 'completed') return; // already handled

Checklist Before Go-Live

  • [ ] Store merchant_ref / order_tracking_id before rendering the iframe
  • [ ] Embed redirect_url in 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, and REVERSED statuses
  • [ ] Confirm sandbox/demo credentials with support before testing (no sandbox base URL is documented for this endpoint)
  • [ ] HTTPS on your callback URL

Sandbox Testing

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:

  1. Happy path — initiate → pay inside iframe → poll returns COMPLETED → credit user
  2. Failed payment — confirm FAILED status is handled without crediting
  3. Duplicate poll — poll after the user is already credited, confirm it's a no-op
  4. Redirect without a poll — confirm you don't credit the user from the redirect handler alone
  5. Cross-currency — repeat the happy path for a TZS or UGX user 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.