Voucher Flow
This guide walks through the complete integration lifecycle: how a customer buys a voucher using M-Pesa, how your platform receives it, validates it, redeems it, and credits the user — automatically.
You can explore every screen interactively at the live demo: 👉 demo-flows.pesavoucher.com
PesaVoucher sits between your platform and M-Pesa. Instead of integrating M-Pesa directly, your users pay through PesaVoucher and receive a branded voucher code that you redeem via API.
Customer ──M-Pesa STK──▶ PesaVoucher ──voucher_code──▶ Your PlatformYour Platform ──redeem──▶ PesaVoucher ──credits──▶ Your merchant wallet
There are two directions:
| Direction | Who creates the voucher | Who redeems it | Use case |
|---|---|---|---|
| Customer → You (Deposit) | Customer pays via M-Pesa on PesaVoucher | Your platform via API | User deposits funds |
| You → Customer (Withdrawal) | You call /create_voucher | Customer redeems on pesavoucher.com | Payouts, bonuses, refunds |
This is the primary integration path. The customer pays via M-Pesa on PesaVoucher's hosted page, then your platform receives the voucher automatically.
The deposit flow starts on your platform's deposit page. Two payment options are shown side by side: M-Pesa direct and PesaVoucher.
Demo: Payment method selection → demo-flows.pesavoucher.com Select the Deposit tab and choose Pesa Voucher to follow this flow interactively.
When the user selects Pesa Voucher, a voucher code input appears with a Buy Voucher Now button. Clicking this redirects them to PesaVoucher's hosted payment page.
On PesaVoucher, the user enters their M-Pesa phone number and amount, then taps Confirm. A real M-Pesa STK push is sent to their phone — they enter their PIN in the M-Pesa prompt that slides up from the bottom of the screen.
After the PIN is submitted:
- A green confirmation SMS banner slides down showing the M-Pesa receipt
- PesaVoucher generates the branded voucher code (e.g.
LBT-ABCD1234EFGH5678) - The user sees the voucher code and taps Return to Linebet
- On your platform, the balance updates instantly showing the credited amount
1. User clicks "Deposit via PesaVoucher" on your platform2. Your backend generates a signed redirect URL3. User is sent to app.pesavoucher.com/buy-voucher4. User enters M-Pesa number → STK push sent to phone5. User approves payment on phone6. PesaVoucher generates branded voucher (e.g. LBT-XXXXXXXXXXXXXX)7. PesaVoucher POSTs voucher_code + payment_id to your callback_url8. Your backend validates → redeems voucher → credits user balance9. User is redirected back to your platform
Your backend generates the URL and sends the user there. Store session_id in your database — you'll need it to match the callback.
async function buildRedirectUrl({ userId, amount, currency = 'KES' }) {const sessionId = crypto.randomUUID();// Store session in your DB for callback matchingawait db.createSession({session_id: sessionId,user_id: userId,amount,status: 'pending',created_at: new Date(),});const params = new URLSearchParams({merchant_id: process.env.PV_MERCHANT_ID,amount: amount.toFixed(2),currency,session_id: sessionId,user_ref: userId,prefix: 'LBT', // your voucher prefix — issued by PesaVouchercallback_url: `${YOUR_BASE_URL}/api/pesavoucher/callback`,return_url: `${YOUR_BASE_URL}/deposit?status=success&session_id=${sessionId}`,cancel_url: `${YOUR_BASE_URL}/deposit?status=cancelled`,});return `https://app.pesavoucher.com/buy-voucher?${params.toString()}`;}// Express route — call this when user initiates a depositrouter.post('/api/deposit/initiate-voucher', requireAuth, async (req, res) => {const redirectUrl = await buildRedirectUrl({userId: req.user.id,amount: parseFloat(req.body.amount),});res.json({ success: true, redirect_url: redirectUrl });});
Redirect URL parameters:
| Parameter | Required | Description |
|---|---|---|
merchant_id | Yes | Your merchant ID issued by PesaVoucher |
amount | Yes | Deposit amount in KES |
currency | Yes | Always KES |
session_id | Yes | Your unique session identifier for reconciliation |
user_ref | No | Your internal user ID |
prefix | Yes | Voucher code prefix (e.g. LBT) — must be pre-approved |
callback_url | Yes | Where PesaVoucher POSTs the voucher after payment |
return_url | Yes | Where to send user after success |
cancel_url | Yes | Where to send user if they cancel |
PesaVoucher POSTs here once the M-Pesa payment is confirmed and the voucher is generated. Always respond 200 OK immediately, then process asynchronously.
router.post('/api/pesavoucher/callback', express.json(), async (req, res) => {// Respond immediately — PesaVoucher waits max 10 secondsres.status(200).json({ received: true });const { success, voucher_code, payment_id, session_id, amount } = req.body;// Verify source header (whitelist PesaVoucher IPs in production)if (req.headers['x-callback-source'] !== 'Pesavoucher') return;const session = await db.getSession(session_id);if (!session || session.status === 'completed') return; // duplicate guardif (!success) {await db.updateSession(session_id, { status: 'failed' });return;}// Step 1: Validate (read-only — safe to always call first)const validation = await validateVoucher(voucher_code);if (!validation.success || !validation.valid) {await db.updateSession(session_id, { status: 'validation_failed' });return;}// Step 2: Redeem (marks voucher used + credits your merchant wallet)const redemption = await redeemVoucher(voucher_code, session.user_id);if (!redemption.success) {await db.updateSession(session_id, { status: 'redemption_failed' });return;}// Step 3: Credit your userconst credited = redemption.redemption.amount;await db.creditUserBalance(session.user_id, credited);await db.updateSession(session_id, {status: 'completed',voucher_code,payment_id,amount_credited: credited,completed_at: new Date(),});// Notify user in real-time (WebSocket / SSE)notifyUser(session.user_id, { event: 'deposit_confirmed', amount: credited });});
Callback payload from PesaVoucher:
{"success": true,"voucher_code": "LBT-XXXXXXXXXXXXXXXX","payment_id": "uuid-from-pesavoucher","session_id": "your-session-id","amount": 500.00,"currency": "KES","mpesa_receipt": "QGH2XK...","timestamp": "2025-11-26 14:30:45"}
The user is redirected back to your platform after payment. The webhook may have already fired — avoid double-crediting.
router.get('/deposit', requireAuth, async (req, res) => {const { status, voucher, session_id, payment_id } = req.query;if (status === 'success' && session_id) {const session = await db.getSession(session_id);if (session?.status !== 'completed') {// Webhook hasn't fired yet — try manual validate + redeem as fallbackif (voucher) {const validation = await validateVoucher(voucher);if (validation.valid) {const redemption = await redeemVoucher(voucher, req.user.id);if (redemption.success) {const credited = redemption.redemption.amount;await db.creditUserBalance(req.user.id, credited);await db.updateSession(session_id, { status: 'completed', voucher_code: voucher });}}} else if (payment_id) {// Poll payment_id as last resortconst result = await pollPaymentStatus(payment_id, { maxAttempts: 5 });// handle result...}}}const balance = await db.getUserBalance(req.user.id);res.render('deposit', { status, balance });});
Why both webhook and return URL? The webhook is the primary path. The return URL handler is your fallback for cases where the user arrives back before the webhook fires, or if the webhook was missed entirely.
Use payment_id to poll the status of a payment when the webhook is delayed or you need a synchronous response.
async function checkPaymentStatus(paymentId) {const res = await fetch(`${PV_BASE_URL}/check_payment_status`, {method: 'POST',headers: pvHeaders,body: JSON.stringify({ payment_id: paymentId }),});return res.json();}// Poll with backoff until terminal stateasync function pollPaymentStatus(paymentId, { intervalMs = 3000, maxAttempts = 20 } = {}) {for (let attempt = 0; attempt < maxAttempts; attempt++) {await new Promise(r => setTimeout(r, intervalMs));const result = await checkPaymentStatus(paymentId);if (!result.success) throw new Error('Status check failed');const { payment_status } = result.payment;if (payment_status === 'SUCCESS') return { success: true, result };if (payment_status === 'FAILED') return { success: false, result };if (payment_status === 'CANCELLED') return { success: false, result };if (payment_status === 'TIMEOUT') return { success: false, result };// PENDING → keep polling}throw new Error(`Payment still pending after ${maxAttempts} attempts`);}
Payment status values:
payment_status | Meaning | Terminal? |
|---|---|---|
PENDING | Awaiting M-Pesa confirmation | No — keep polling |
SUCCESS | Payment confirmed, voucher ready | Yes |
FAILED | M-Pesa payment failed | Yes |
CANCELLED | User cancelled the M-Pesa prompt | Yes |
TIMEOUT | M-Pesa prompt timed out | Yes |
Prefer webhooks over polling. Polling adds latency and load. Use it only as a fallback when the webhook hasn't arrived within a reasonable window (~30 seconds).
When you need to send funds to a customer (payout, bonus, refund), you create a voucher on their behalf. They receive a branded email and can redeem it on pesavoucher.com — or through your own redemption UI if you implement one.
Demo: demo-flows.pesavoucher.com Select the Withdrawal tab and choose Pesa Voucher to see the payout flow. The customer receives a dark-themed email notification showing their gift amount, voucher code, and a Redeem Voucher button.
The customer receives:
- A branded email with the voucher code (
NLV-XXXXXXXXXXXXXXXX) - Their PesaVoucher account is automatically created if they don't have one
- They redeem it at pesavoucher.com, which credits their M-Pesa or wallet
async function createVoucherForUser({ email, amount, userId, message }) {const res = await fetch(`${PV_BASE_URL}/create_voucher`, {method: 'POST',headers: pvHeaders,body: JSON.stringify({email,amount: amount.toFixed(2),currency: 'KES',unique_id: `YOUR-PREFIX-${userId}-${Date.now()}`,callback_url: `${YOUR_BASE_URL}/api/pesavoucher/voucher-created-webhook`,message: message ?? 'Your withdrawal is ready!',}),});return res.json();}
Success response:
{"success": true,"voucher_code": "NLV-53SKM7C0HNDSAZAM","amount": 750.00,"currency": "KES","transaction_id": "463G4HDIF3","unique_id": "YOUR-PREFIX-123-1700000000000"}
These are the three API calls your backend needs for the deposit flow:
const pvHeaders = {'Content-Type': 'application/json','X-API-KEY': process.env.PV_API_KEY,'X-API-SECRET': process.env.PV_API_SECRET,};// Validate — read-only, always safe to call firstasync function validateVoucher(voucherCode) {const res = await fetch(`${PV_BASE_URL}/validate_voucher`, {method: 'POST',headers: pvHeaders,body: JSON.stringify({ voucher_code: voucherCode }),});return res.json();}// Redeem — marks voucher used and credits your merchant walletasync function redeemVoucher(voucherCode, redeemedBy) {const res = await fetch(`${PV_BASE_URL}/redeem_voucher`, {method: 'POST',headers: pvHeaders,body: JSON.stringify({voucher_code: voucherCode,currency: 'KES',callback_url: `${YOUR_BASE_URL}/api/pesavoucher/redemption-webhook`,redeemed_by: String(redeemedBy),}),});return res.json();}
Callbacks can fire more than once. Always guard against double-crediting:
// Check before processingconst session = await db.getSession(session_id);if (!session || session.status === 'completed') return; // already handled// Use a DB unique constraint on voucher_codeawait db.createRedemption({ voucher_code, user_id, amount });// If this throws a unique constraint violation, the voucher was already redeemed — safe to ignore
Three-layer protection:
- Session status check — skip if
status === 'completed' /validate_voucher— returns"Voucher already redeemed"if called twice/redeem_voucher— returns an error if already redeemed; PesaVoucher is the source of truth
async function safeRedeem(voucherCode, userId) {const validation = await validateVoucher(voucherCode);if (!validation.success || !validation.valid) {const reason = validation.validation_errors?.[0] ?? 'Unknown validation error';if (reason.includes('already redeemed')) return { success: false, reason: 'already_redeemed' };if (reason.includes('not found')) return { success: false, reason: 'not_found' };if (reason.includes('only redeemable')) return { success: false, reason: 'platform_only' };if (reason.includes('does not belong')) return { success: false, reason: 'wrong_merchant' };return { success: false, reason };}const redemption = await redeemVoucher(voucherCode, userId);if (!redemption.success) return { success: false, reason: redemption.error };return { success: true, amount: redemption.redemption.amount };}
- [ ] Store
session_idin your DB before redirecting the user - [ ] Callback endpoint responds
200 OKimmediately (before processing) - [ ] Idempotency guard on callback handler (check
session.status !== 'completed') - [ ] Always call
/validate_voucherbefore/redeem_voucher - [ ] Verify
X-Callback-Source: Pesavoucherheader on all callbacks - [ ] Return URL handler as fallback if webhook fires late
- [ ] Polling fallback for payment status if needed
- [ ] HTTPS on all callback and return URLs
- [ ] Tested happy path + cancelled + failed + duplicate callback scenarios in sandbox
Use the sandbox to test all flows without real money:
Base URL: https://sandbox.payments.pesavoucher.com/api/v1App URL: https://sandbox.app.pesavoucher.comDemo: https://demo-flows.pesavoucher.com
Test scenarios to cover before launch:
- Happy path — redirect → M-Pesa approve → callback → credit user
- User cancels — decline M-Pesa prompt → callback with
success: false - Webhook before return URL — normal case, no double-credit
- Return URL before webhook — fallback validate + redeem kicks in
- Duplicate callback — send same payload twice, ensure user credited once
- Expired/invalid voucher — validate returns
valid: false - Withdrawal flow —
/create_voucher→ customer receives email