PayPal Payments API
Live URL: https://payments.pesavoucher.com/api/v1/ Sandbox URL: https://sandbox.payments.pesavoucher.com/api/v1/
Live Testing Note To perform real live transactions or trigger a live test webhook, please contact us: support@pesavoucher.com Telegram: Join our channel → t.me/Dev_PesaVoucher
PesaVoucher's PayPal integration lets you accept international deposits (a customer "purchasing" credit via PayPal) and send PayPal payouts, using the same prepaid float model as M-Pesa. The flow is a standard PayPal Checkout redirect: you create an order, the customer approves it on PayPal, and you capture it to credit your merchant wallet.
If the paying user's preferred currency is KES, TZS, or UGX, their PayPal deposit (always in USD) is automatically converted into their preferred currency before their wallet is credited — see Currencies for how that conversion works and what rate is used.
All requests require these headers:
X-API-KEY: your_api_keyX-API-SECRET: your_api_secretContent-Type: application/json
Get your credentials from your merchant dashboard.
Endpoint: POST /paypal/create_order
Creates a PayPal order and returns an approval_url. Redirect the customer there to log in to PayPal and approve the payment.
curl -X POST https://payments.pesavoucher.com/api/v1/paypal/create_order \-H "Content-Type: application/json" \-H "X-API-KEY: pk_live_xxxxxxxxxxxxxxxxxxxx" \-H "X-API-SECRET: sk_live_xxxxxxxxxxxxxxxxxxxx" \-d '{"amount": 10.00,"currency": "USD","order_id": "ORDER-2026-0417","metadata": {"customer_id": "CUST-8871","description": "Wallet top-up"}}'
KES example:
{"amount": 500,"currency": "KES","order_id": "ORDER-KES-2026-0417"}
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | float | Yes | Deposit amount, 2 decimal places. Minimum $1.00 (or currency equivalent) |
currency | string | Yes | ISO currency code. USD and KES are confirmed supported — other codes (e.g. JPY) are rejected |
order_id | string | Yes | Your internal order/reference ID |
metadata | object | No | Any custom data you want attached to the transaction |
{"success": true,"data": {"paypal_order_id": "8AC13285XA1234567","transaction_id": "TXN-20260417-0001","order_id": "ORDER-2026-0417","approval_url": "https://www.sandbox.paypal.com/checkoutnow?token=8AC13285XA1234567","status": "Pending","amount": 10.00,"currency": "USD"}}
Next step: Redirect the customer's browser to data.approval_url. After they approve, PayPal redirects them to your configured return_url with the order token attached — see Capture Order below.
Endpoint: GET /paypal/capture
Finalizes the payment and credits your merchant wallet. PayPal automatically appends token (the PayPal order ID) to your return_url after the buyer approves — call this endpoint (or point your return_url directly at it) to complete the deposit.
GET /paypal/capture?token={paypal_order_id}&order_id={order_id}
{"success": true,"data": {"capture_id": "3JT12345AB6789012","status": "Completed","amount": 10.00,"currency": "USD","order_id": "ORDER-2026-0417","paypal_order_id": "8AC13285XA1234567"}}
Idempotent by design. Calling
/paypal/captureagain with the sameorder_id/tokenafter it has already completed is safe — it returns the existing captured result rather than capturing (and crediting) a second time.
Cross-currency users: the
amount/currencyin this response reflect the captured PayPal transaction (USD). If the paying user's preferred currency isn't USD, the amount actually credited to their wallet is the converted value in their preferred currency, not the USD figure shown here — check the user's wallet balance or Order Status for the credited amount, and see Currencies for how the conversion rate is applied.
| Scenario | HTTP Status | Notes |
|---|---|---|
Missing amount | 400 | { "success": false, "error": "..." } |
| Amount below minimum ($1.00) | 400 | Minimum deposit is $1.00 (or currency equivalent) |
Unsupported currency (e.g. JPY) | 400 | Only USD and KES are currently supported |
| Missing API credentials | 400 | No X-API-KEY / X-API-SECRET headers sent |
| Duplicate capture attempt | 200 | Safe to retry — returns the already-captured result, does not double-credit |
{"success": false,"error": "Human-readable error message"}
Endpoint: POST /paypal/create_payout
Sends funds from your float to a PayPal recipient. Admin-only — your merchant account needs is_admin or can_payout permission enabled.
curl -X POST https://payments.pesavoucher.com/api/v1/paypal/create_payout \-H "Content-Type: application/json" \-H "X-API-KEY: pk_live_xxxxxxxxxxxxxxxxxxxx" \-H "X-API-SECRET: sk_live_xxxxxxxxxxxxxxxxxxxx" \-d '{"amount": 2.00,"currency": "USD","recipient_email": "customer@example.com","order_id": "PAYOUT-2026-0417","note": "Withdrawal payout","metadata": {"customer_id": "CUST-8871"}}'
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | float | Yes | Payout amount, 2 decimal places |
currency | string | Yes | ISO currency code (e.g. USD) |
recipient_email | string | Yes | Recipient's PayPal email address |
order_id | string | Yes | Your internal reference ID |
note | string | No | Note shown to the recipient |
metadata | object | No | Any custom data attached to the payout |
{"success": true,"data": {"payout_id": "PAYOUT-0001","paypal_batch_id": "BATCH-2026-0417-001"}}
| Scenario | HTTP Status | Notes |
|---|---|---|
Missing recipient_email | 400 | recipient_email is required |
| Invalid email format | 400 | Must be a valid email address |
| Missing payout permission | 401/403 | API key lacks can_payout / is_admin — not authorized to send payouts |
| Insufficient float balance | 400 | Payout amount exceeds your available merchant balance |
PesaVoucher receives PayPal's webhook events directly and processes them internally to keep transaction status current. You don't need to build a listener for raw PayPal event types — deposit and payout status updates are delivered to your own configured webhook URL (see Webhooks) in PesaVoucher's normalized format.
The table below documents what each PayPal event does on PesaVoucher's side, so you understand the status changes you'll see:
| Event | Trigger | Effect |
|---|---|---|
PAYMENT.CAPTURE.COMPLETED | PayPal confirms a capture | Marks the transaction Completed and credits the merchant wallet. Idempotent — skipped if the return URL already credited it |
CHECKOUT.ORDER.APPROVED | Buyer approves in PayPal | Triggers an automatic capture if the return URL hasn't already captured the order |
PAYMENT.CAPTURE.DENIED | Capture is declined | Transaction marked Failed |
PAYMENT.CAPTURE.REFUNDED | A refund is issued | Transaction marked Refunded; refund_id and refund_amount recorded |
PAYMENT.PAYOUTSBATCH.SUCCESS | A payout batch completes | All payout rows in the batch updated to Success |
PAYOUTS.PAYMENT.SUCCESS | An individual payout item succeeds | That specific payout row marked Success |
PAYOUTS.PAYMENT.FAILED | An individual payout item fails | Row marked Failed with the error reason (e.g. RECEIVER_UNREGISTERED) |
PAYOUTS.PAYMENT.UNCLAIMED | Recipient has no PayPal account | PayPal emails them to claim; funds are held 30 days, then returned |
Why both a return URL and a webhook? Same reasoning as the M-Pesa flow: the return URL is the primary capture path.
CHECKOUT.ORDER.APPROVEDandPAYMENT.CAPTURE.COMPLETEDare the fallback safety net for cases where the customer closes the tab before returning, or the return URL request never reaches your server.
- Sandbox mode: the webhook handler bypasses PayPal's signature check, so simulated or unsigned events are still processed — this is expected for testing.
- Production: requests must carry a valid PayPal signature or they are rejected.
Each webhook event has a unique id. Resending the same event id (e.g. a redelivery from PayPal) is safe — duplicates are recognized and ignored rather than reprocessed.
https://sandbox.payments.pesavoucher.com/api/v1/
- Use your sandbox API key/secret plus a PayPal sandbox buyer account to approve test payments
- Run Create Order, open the returned
approval_urlin a browser, log in as the sandbox buyer, and approve - Run Capture Order to complete the deposit
- Use the Webhook (manual event simulation) requests in the Postman collection to POST event payloads directly to
/paypal/webhookand observe status updates — no live PayPal callback required
In sandbox mode the webhook handler bypasses PayPal's signature check, so simulated events are processed without a real signature. In production, unsigned or invalid-signature requests are rejected. To test real signature verification, use PayPal's Simulate webhook event tool in the Developer Dashboard, or expose your local server with ngrok.
- Create order (try both a
USDand aKESamount) → copyapproval_url - Approve as sandbox buyer in browser
- Capture order — retry the same capture request once to confirm it's idempotent
- Confirm your merchant wallet balance updated
- Run the negative-test requests (below-minimum amount, unsupported currency, missing/invalid payout email, insufficient balance, missing payout permission) to confirm each returns the expected error
- Resend a webhook event with the same
idto confirm duplicates are ignored
- Redirect the customer to
approval_urlimmediately — PayPal order links expire after a few hours - Treat the return URL capture as primary and the webhook as a fallback, never the other way around
- Use a unique
order_idfor every attempt to avoid capture collisions - Store
paypal_order_id,transaction_id, andorder_idtogether so you can reconcile Create Order → Capture → Webhook - Only send
USDorKESas the deposit currency — other codes are rejected - Retrying a capture or resending a webhook is safe — both are idempotent, so build your integration to retry rather than guess
- Confirm your API key has
can_payout/is_adminbefore attempting payouts, and check available float balance before sending large payouts - Never expose
X-API-SECRETin frontend code - Test the full happy path plus denied, refunded, and duplicate-webhook scenarios in sandbox before going live