Skip to Content
Pesa VoucherDeveloper Documentation

Voucher Flow

End-to-end guide for integrating PesaVoucher into your platform — deposits, withdrawals, and real-time status tracking


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


Business Model at a Glance

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 Platform
Your Platform ──redeem──▶ PesaVoucher ──credits──▶ Your merchant wallet

There are two directions:

DirectionWho creates the voucherWho redeems itUse case
Customer → You (Deposit)Customer pays via M-Pesa on PesaVoucherYour platform via APIUser deposits funds
You → Customer (Withdrawal)You call /create_voucherCustomer redeems on pesavoucher.comPayouts, bonuses, refunds

Flow 1 — Customer Deposit (Redirect Flow)

This is the primary integration path. The customer pays via M-Pesa on PesaVoucher's hosted page, then your platform receives the voucher automatically.

What the user sees

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:

  1. A green confirmation SMS banner slides down showing the M-Pesa receipt
  2. PesaVoucher generates the branded voucher code (e.g. LBT-ABCD1234EFGH5678)
  3. The user sees the voucher code and taps Return to Linebet
  4. On your platform, the balance updates instantly showing the credited amount

Step-by-step integration

1. User clicks "Deposit via PesaVoucher" on your platform
2. Your backend generates a signed redirect URL
3. User is sent to app.pesavoucher.com/buy-voucher
4. User enters M-Pesa number → STK push sent to phone
5. User approves payment on phone
6. PesaVoucher generates branded voucher (e.g. LBT-XXXXXXXXXXXXXX)
7. PesaVoucher POSTs voucher_code + payment_id to your callback_url
8. Your backend validates → redeems voucher → credits user balance
9. User is redirected back to your platform

1. Build the redirect URL

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 matching
await 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 PesaVoucher
callback_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 deposit
router.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:

ParameterRequiredDescription
merchant_idYesYour merchant ID issued by PesaVoucher
amountYesDeposit amount in KES
currencyYesAlways KES
session_idYesYour unique session identifier for reconciliation
user_refNoYour internal user ID
prefixYesVoucher code prefix (e.g. LBT) — must be pre-approved
callback_urlYesWhere PesaVoucher POSTs the voucher after payment
return_urlYesWhere to send user after success
cancel_urlYesWhere to send user if they cancel

2. Handle the incoming callback

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 seconds
res.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 guard
if (!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 user
const 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"
}

3. Handle the return URL

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 fallback
if (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 resort
const 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.


Flow 2 — Polling Payment Status

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 state
async 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_statusMeaningTerminal?
PENDINGAwaiting M-Pesa confirmationNo — keep polling
SUCCESSPayment confirmed, voucher readyYes
FAILEDM-Pesa payment failedYes
CANCELLEDUser cancelled the M-Pesa promptYes
TIMEOUTM-Pesa prompt timed outYes

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).


Flow 3 — Merchant Withdrawal (You → Customer)

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"
}

API Helper Functions

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 first
async 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 wallet
async 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();
}

Idempotency & Duplicate Prevention

Callbacks can fire more than once. Always guard against double-crediting:

// Check before processing
const session = await db.getSession(session_id);
if (!session || session.status === 'completed') return; // already handled
// Use a DB unique constraint on voucher_code
await 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:

  1. Session status check — skip if status === 'completed'
  2. /validate_voucher — returns "Voucher already redeemed" if called twice
  3. /redeem_voucher — returns an error if already redeemed; PesaVoucher is the source of truth

Error Handling Reference

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

Checklist Before Go-Live

  • [ ] Store session_id in your DB before redirecting the user
  • [ ] Callback endpoint responds 200 OK immediately (before processing)
  • [ ] Idempotency guard on callback handler (check session.status !== 'completed')
  • [ ] Always call /validate_voucher before /redeem_voucher
  • [ ] Verify X-Callback-Source: Pesavoucher header 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

Sandbox Testing

Use the sandbox to test all flows without real money:

Base URL: https://sandbox.payments.pesavoucher.com/api/v1
App URL: https://sandbox.app.pesavoucher.com
Demo: https://demo-flows.pesavoucher.com

Test scenarios to cover before launch:

  1. Happy path — redirect → M-Pesa approve → callback → credit user
  2. User cancels — decline M-Pesa prompt → callback with success: false
  3. Webhook before return URL — normal case, no double-credit
  4. Return URL before webhook — fallback validate + redeem kicks in
  5. Duplicate callback — send same payload twice, ensure user credited once
  6. Expired/invalid voucher — validate returns valid: false
  7. Withdrawal flow/create_voucher → customer receives email