Skip to Content
Pesa VoucherDeveloper Documentation

PayPal Voucher Purchase

End-to-end guide for letting customers purchase credit via PayPal — from order creation to instant wallet credit.


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.


Business Model at a Glance

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 Platform
Your Platform ──credits──▶ Customer's wallet balance

Flow — Customer Deposit (Redirect Flow)

What the user sees

  1. Customer selects PayPal as a deposit option on your platform and enters an amount
  2. Your backend calls Create Order, receives an approval_url, and redirects the customer there
  3. Customer logs in to PayPal (or approves with their saved session) and confirms the payment
  4. PayPal redirects the customer back to your return_url with the order token attached
  5. Your backend calls Capture Order, which finalizes the payment and credits your merchant wallet
  6. You credit the customer's balance and show a confirmation screen

Step-by-step integration

1. Customer clicks "Deposit via PayPal" on your platform
2. Your backend calls POST /paypal/create_order with amount, currency, order_id
3. Response returns paypal_order_id + approval_url
4. Redirect customer to approval_url
5. Customer approves on PayPal
6. 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 balance
9. (Fallback) CHECKOUT.ORDER.APPROVED / PAYMENT.CAPTURE.COMPLETED webhooks
auto-capture or confirm the deposit if the return URL is missed

1. Create the order and redirect

async function createPayPalOrder({ userId, amount, currency = 'USD' }) {
// currency must be 'USD' or 'KES' — other codes are rejected
const orderId = `DEP-${userId}-${Date.now()}`;
// Store a pending session for reconciliation
await 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 route
router.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 });
});

2. Handle the return URL (primary capture path)

router.get('/deposit/paypal/return', requireAuth, async (req, res) => {
const { token, order_id } = req.query; // PayPal appends `token` automatically
const 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}`);
});

3. Handle the fallback webhook

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 immediately
const { order_id, status, amount, capture_id } = req.body;
const session = await db.getSessionByOrderId(order_id);
if (!session || session.status === 'completed') return; // idempotency guard
if (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.


Idempotency & Duplicate Prevention

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:

  1. Session status check — skip if status === 'completed'
  2. Unique constraint on order_id — prevents crediting the same deposit twice
  3. /paypal/capture is idempotent — calling it twice for an already-completed order returns the existing result instead of capturing (and crediting) again
  4. Webhook events are deduplicated by id — resending the same event id is safe and will not reprocess
const session = await db.getSessionByOrderId(order_id);
if (!session || session.status === 'completed') return; // already handled

Checklist Before Go-Live

  • [ ] Store order_id in your DB before redirecting the customer to approval_url
  • [ ] Redirect immediately — PayPal order links expire after a few hours
  • [ ] Return URL handler calls /paypal/capture and credits the user
  • [ ] Webhook handler treats CHECKOUT.ORDER.APPROVED / PAYMENT.CAPTURE.COMPLETED as a fallback, not a duplicate credit
  • [ ] Idempotency guard on both the return URL and webhook handlers
  • [ ] Handle PAYMENT.CAPTURE.DENIED and PAYMENT.CAPTURE.REFUNDED in your webhook
  • [ ] Tested happy path, denied, refunded, and duplicate-webhook scenarios in sandbox
  • [ ] HTTPS on all return and webhook URLs

Sandbox Testing

Base URL: https://sandbox.payments.pesavoucher.com/api/v1

Test scenarios to cover before launch:

  1. Happy path — create order → approve as PayPal sandbox buyer → capture → credit user
  2. KES happy path — repeat with a KES amount instead of USD
  3. Return URL before webhook — normal case, no double-credit
  4. Webhook before return URL — simulate CHECKOUT.ORDER.APPROVED first via the Postman collection's webhook folder, confirm auto-capture
  5. Duplicate capture — call /paypal/capture twice for the same order, confirm the user is credited once
  6. Duplicate webhook — resend the same PAYMENT.CAPTURE.COMPLETED event id twice, confirm it's ignored the second time
  7. Denied capture — simulate PAYMENT.CAPTURE.DENIED, confirm transaction marked Failed
  8. 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.