PayPal Voucher Purchase Flow
This guide walks through the complete integration lifecycle for PayPal deposits: how a customer "purchases" credit through PayPal Checkout, how your platform captures the payment, and how the funds land in your merchant wallet — automatically. It follows the same redirect + webhook pattern as the M-Pesa Voucher Flow, with PayPal as the payment rail instead of M-Pesa STK Push.
See the PayPal API Reference for full endpoint details, and Currencies for how USD deposits convert into the paying user's local currency.
Instead of integrating PayPal directly, your customer pays through PayPal Checkout on an order you create via the PesaVoucher API. Once approved and captured, the amount is credited to your merchant wallet — the same wallet the float model uses for M-Pesa deposits.
If the paying user's preferred currency is KES, TZS, or UGX (not USD), PesaVoucher converts the captured USD amount into that user's preferred currency before crediting their wallet — the user always pays in USD, they're always credited in their own currency. This is per-user, so different customers on your platform can be credited in different currencies. See Currencies for the conversion mechanics.
Customer ──PayPal Checkout──▶ PesaVoucher ──capture confirmation──▶ Your PlatformYour Platform ──credits──▶ Customer's wallet balance
- Customer selects PayPal as a deposit option on your platform and enters an amount
- Your backend calls Create Order, receives an
approval_url, and redirects the customer there - Customer logs in to PayPal (or approves with their saved session) and confirms the payment
- PayPal redirects the customer back to your
return_urlwith the ordertokenattached - Your backend calls Capture Order, which finalizes the payment and credits your merchant wallet
- You credit the customer's balance and show a confirmation screen
1. Customer clicks "Deposit via PayPal" on your platform2. Your backend calls POST /paypal/create_order with amount, currency, order_id3. Response returns paypal_order_id + approval_url4. Redirect customer to approval_url5. Customer approves on PayPal6. PayPal redirects to your return_url with ?token={paypal_order_id}7. Your backend calls GET /paypal/capture?token=...&order_id=...8. Merchant wallet is credited — credit your customer's balance9. (Fallback) CHECKOUT.ORDER.APPROVED / PAYMENT.CAPTURE.COMPLETED webhooksauto-capture or confirm the deposit if the return URL is missed
async function createPayPalOrder({ userId, amount, currency = 'USD' }) {// currency must be 'USD' or 'KES' — other codes are rejectedconst orderId = `DEP-${userId}-${Date.now()}`;// Store a pending session for reconciliationawait db.createSession({order_id: orderId,user_id: userId,amount,currency,status: 'pending',created_at: new Date(),});const res = await fetch(`${PV_BASE_URL}/paypal/create_order`, {method: 'POST',headers: pvHeaders,body: JSON.stringify({amount: amount.toFixed(2),currency,order_id: orderId,metadata: { user_id: userId },}),});const { success, data } = await res.json();if (!success) throw new Error('Failed to create PayPal order');await db.updateSession(orderId, {paypal_order_id: data.paypal_order_id,transaction_id: data.transaction_id,});return data.approval_url; // redirect the customer here}// Express routerouter.post('/api/deposit/initiate-paypal', requireAuth, async (req, res) => {const approvalUrl = await createPayPalOrder({userId: req.user.id,amount: parseFloat(req.body.amount),});res.json({ success: true, redirect_url: approvalUrl });});
router.get('/deposit/paypal/return', requireAuth, async (req, res) => {const { token, order_id } = req.query; // PayPal appends `token` automaticallyconst session = await db.getSessionByOrderId(order_id);if (!session || session.status === 'completed') {return res.redirect('/deposit?status=already_processed');}const captureRes = await fetch(`${PV_BASE_URL}/paypal/capture?token=${token}&order_id=${order_id}`,{ headers: pvHeaders });const { success, data } = await captureRes.json();if (!success || data.status !== 'Completed') {await db.updateSession(order_id, { status: 'failed' });return res.redirect('/deposit?status=failed');}// data.amount/data.currency reflect the captured USD transaction.// If this user's preferred currency isn't USD, PesaVoucher converts// before crediting their wallet — read the actual credited amount from// the user's wallet balance or the webhook payload rather than assuming// data.amount is what landed in their account. See /docs/currencies.await db.creditUserBalance(session.user_id, data.amount, data.currency);await db.updateSession(order_id, {status: 'completed',capture_id: data.capture_id,completed_at: new Date(),});res.redirect(`/deposit?status=success&amount=${data.amount}`);});
CHECKOUT.ORDER.APPROVED and PAYMENT.CAPTURE.COMPLETED arrive on your configured webhook URL as a safety net for customers who approve on PayPal but never make it back to your return_url (closed tab, network drop, etc.).
router.post('/api/pesavoucher/webhook', express.json(), async (req, res) => {res.status(200).json({ received: true }); // ack immediatelyconst { order_id, status, amount, capture_id } = req.body;const session = await db.getSessionByOrderId(order_id);if (!session || session.status === 'completed') return; // idempotency guardif (status !== 'Completed') {await db.updateSession(order_id, { status: 'failed' });return;}await db.creditUserBalance(session.user_id, amount);await db.updateSession(order_id, {status: 'completed',capture_id,completed_at: new Date(),});notifyUser(session.user_id, { event: 'deposit_confirmed', amount });});
Why both? Same pattern as the M-Pesa voucher flow: the return URL is the primary path since it resolves synchronously in the customer's browser. The webhook is the fallback that guarantees the deposit is captured even if the customer never comes back.
Both the return URL handler and the webhook can fire for the same order, and either can be retried — build your integration assuming both will happen more than once:
- Session status check — skip if
status === 'completed' - Unique constraint on
order_id— prevents crediting the same deposit twice /paypal/captureis idempotent — calling it twice for an already-completed order returns the existing result instead of capturing (and crediting) again- Webhook events are deduplicated by
id— resending the same eventidis safe and will not reprocess
const session = await db.getSessionByOrderId(order_id);if (!session || session.status === 'completed') return; // already handled
- [ ] Store
order_idin your DB before redirecting the customer toapproval_url - [ ] Redirect immediately — PayPal order links expire after a few hours
- [ ] Return URL handler calls
/paypal/captureand credits the user - [ ] Webhook handler treats
CHECKOUT.ORDER.APPROVED/PAYMENT.CAPTURE.COMPLETEDas a fallback, not a duplicate credit - [ ] Idempotency guard on both the return URL and webhook handlers
- [ ] Handle
PAYMENT.CAPTURE.DENIEDandPAYMENT.CAPTURE.REFUNDEDin your webhook - [ ] Tested happy path, denied, refunded, and duplicate-webhook scenarios in sandbox
- [ ] HTTPS on all return and webhook URLs
Base URL: https://sandbox.payments.pesavoucher.com/api/v1
Test scenarios to cover before launch:
- Happy path — create order → approve as PayPal sandbox buyer → capture → credit user
- KES happy path — repeat with a
KESamount instead ofUSD - Return URL before webhook — normal case, no double-credit
- Webhook before return URL — simulate
CHECKOUT.ORDER.APPROVEDfirst via the Postman collection's webhook folder, confirm auto-capture - Duplicate capture — call
/paypal/capturetwice for the same order, confirm the user is credited once - Duplicate webhook — resend the same
PAYMENT.CAPTURE.COMPLETEDeventidtwice, confirm it's ignored the second time - Denied capture — simulate
PAYMENT.CAPTURE.DENIED, confirm transaction marked Failed - Negative tests — unsupported currency, amount below minimum — confirm each is rejected with a clear error
Use the PesaVoucher PayPal Payment System Postman collection (Deposits, Payouts, and Webhook folders) to run through Create Order, Capture, Payouts, and the simulated webhook events without needing a live PayPal callback.