Hosted Checkout (SDK / Iframe)
If you don't want to build your own phone-number form, STK polling screen, and redirect handling (see Voucher Flow), Hosted Checkout gives you a single iframe that handles the entire payment UI for you. Your server creates a checkout session; you embed the returned URL in an iframe; PesaVoucher's widget handles the rest — including issuing the voucher automatically once payment completes. You do not call create_voucher.php for Hosted Checkout sessions — see the important note in Step 4.
- Your server creates a checkout session — server-to-server, using your API key/secret (never exposed to the browser)
- You embed the returned
checkout_urlin an iframe on your page — no custom payment UI needed - The iframe handles everything: collecting payment details, running the payment, polling for status, and showing success/failure to the customer. PesaVoucher issues the voucher automatically on the server the moment the session completes — you read the result, you don't trigger issuance yourself
Your Server ──create_session──▶ PesaVoucher ──checkout_url──▶ Your FrontendYour Frontend ──renders iframe(checkout_url)──▶ Customer pays inside the iframePesaVoucher ──automatically issues voucher server-side on completion──▶ Customer creditedYour Frontend ──postMessage 'payment.success' (voucher_code)──▶ Your app shows the result
Endpoint: POST /api/v1/checkout/create_session
Server-to-server only. Credentials go in headers — the endpoint deliberately rejects credentials sent in the request body, so you can't accidentally ship your API secret in frontend code.
curl -X POST https://payments.pesavoucher.com/api/v1/checkout/create_session \-H "X-API-KEY: pk_live_xxxxxxxxxxxxxxxxxxxx" \-H "X-API-SECRET: sk_live_xxxxxxxxxxxxxxxxxxxx" \-H "Content-Type: application/json" \-d '{"order_id": "ORDER-2026-0417","amount": 1500,"currency": "KES","payment_methods": ["mpesa", "wallet", "card"],"description": "Order payment","customer_phone": "254712345678","customer_email": "customer@example.com","metadata": { "order_id": "ORDER-2026-0417" }}'
| Parameter | Type | Required | Description |
|---|---|---|---|
order_id | string | Yes | Your internal order/reference ID |
amount | float | Yes | Payment amount, in the session's currency. See Amount Rules below |
currency | string | Yes | The paying customer's own preferred currency — KES, UGX, TZS, USD, EUR, or GBP. Always pass this specific customer's currency, never a fixed value. See Currencies |
payment_methods | array | No | Which options to show in the iframe. See Payment Methods below. Defaults to ["mpesa"] if omitted |
description | string | No | Shown to the customer inside the checkout UI |
customer_phone | string | No | Pre-fills the phone number field |
customer_email | string | No | Pre-fills the email field. Also used, if it matches an existing PesaVoucher account, to automatically attach the resulting voucher to that account |
metadata | object | No | Any custom data you want attached to the session |
| Value | What the customer sees |
|---|---|
mpesa | Phone-number mobile money entry. Routes transparently by currency — KES goes over Safaricom, UGX/TZS go over IntaSend's cross-border network. The customer never sees or chooses which rail handled it. |
wallet | "Sign in to PesaVoucher" — pays from the customer's own pesavoucher_tokens balance. Requires a PesaVoucher account. |
card | Visa/Mastercard entry via Pesapal, in a secure popup |
voucher | Redeem an existing voucher code to settle this checkout — the code must match the session's exact amount and currency |
buy_voucher | Lets the customer purchase a new voucher of any amount they choose (via M-Pesa, Card, or PesaVoucher tokens) — independent of this session's own total, for the customer to spend now or keep. Not a way to settle this particular checkout unless the amounts happen to match |
paypal | ⚠️ Currently has a known limitation — voucher issuance on PayPal completion is not yet wired to the automatic issuance path described in this doc. Avoid enabling this method until it's confirmed fixed |
| Currency | Minimum | Maximum | Decimals |
|---|---|---|---|
| KES | 1 | 250,000 | 2 |
| UGX | 500 | 5,000,000 | 0 (whole numbers) |
| TZS | 500 | 5,000,000 | 0 (whole numbers) |
| USD / EUR / GBP | — | — | 2 (no additional bound enforced today — send a realistic value) |
UGX/TZS bounds are conservative starting points and may be tuned as real transaction volume comes in.
{"success": true,"data": {"session_token": "cs_live_1a2b3c4d5e6f7089...","checkout_url": "https://payments.pesavoucher.com/checkout/cs_live_1a2b3c4d5e6f7089..."}}
Requires at least one registered embedding domain for your merchant account — contact support to register the domain(s) you'll host the iframe on before going live.
Embed checkout_url directly — no further frontend work needed:
<iframesrc="{{ checkout_url }}"width="100%"height="600"frameborder="0"allow="payment"></iframe>
The iframe renders PesaVoucher's own payment UI for every method you enabled — all built and maintained by PesaVoucher. You don't build or maintain any of it.
You don't call these directly — the iframe does. Documented here so you understand what's happening and can debug if something looks off:
| Call | What it does |
|---|---|
GET .../session_api.php?action=info | Loads session details (amount, currency, description) on load, authenticated with Authorization: Bearer {session_token} — no API keys ever reach the browser. Merchant-internal fields (metadata, token_hash, merchant_id) are stripped from this response before the iframe sees it. |
POST .../session_api.php?action=stk_initiate | Triggered when the customer submits their phone number (mpesa method). Amount, order, and merchant are locked to the session server-side. Routes to Safaricom or IntaSend XB depending on the session's currency. Rate limited to 5 attempts per 10 minutes per session. |
POST .../session_api.php?action=card_initiate | Starts a Pesapal card payment for the session's own locked amount/order. |
POST .../session_api.php?action=voucher_validate | Checks a customer-entered voucher code (voucher method) — read-only, confirms it matches this session's exact amount and currency before anything is charged. |
POST .../session_api.php?action=voucher_redeem | Actually redeems the voucher checked above, settling the session. Re-validates everything server-side rather than trusting the earlier check. |
POST .../session_api.php?action=buy_voucher_mpesa_initiate / buy_voucher_card_initiate | Purchases a standalone voucher of the customer's own chosen amount (buy_voucher method) — independent of this session's total, never settles it directly. |
GET .../session_api.php?action=buy_voucher_status | Polls a standalone voucher purchase started above. |
GET .../session_api.php?action=status | Polled every 3 seconds until session_status is completed (or a terminal failure state), then shown to the customer. |
Voucher issuance is automatic. The moment a Hosted Checkout session's payment completes — on any method — PesaVoucher issues the voucher server-side and attaches it to the session. You do not call create_voucher.php for Hosted Checkout sessions. Doing so mints a second, unrelated voucher, since create_voucher.php's own idempotency key (unique_id) has no relationship to the internal reference Hosted Checkout already used — the two will not deduplicate against each other.
The iframe emits a postMessage event to your page as the payment resolves:
window.addEventListener('message', (event) => {if (event.origin !== 'https://payments.pesavoucher.com') return;if (event.data?.source !== 'pesavoucher') return;switch (event.data.event) {case 'ready':// { order_id }break;case 'payment.pending':// { method }break;case 'payment.success':// { method, receipt } — for most methods, the voucher_code is// available from the same status poll that reported completion;// if you're polling session status from your own backend, read it// from there rather than solely from this event.break;case 'payment.failed':// { method, reason }break;case 'session.expired':break;}});
Known limitation: only the
walletpayment method currently sends a server-to-server webhook to yourcallback_urlon completion. M-Pesa and Card completions do not yet have an outbound merchant webhook — thepostMessageevent above is the only real-time signal for those methods today. BecausepostMessageoriginates from the customer's browser, treat it as a UX signal to update your interface, not as your sole source of truth for fulfillment-critical logic (crediting an account, releasing goods, etc.) until server-to-server delivery is available for all methods. If your integration needs guaranteed server-side delivery today, pollaction=statusfrom your own backend as a fallback, or reach out about early access to the broader webhook rollout.
- Credentials never touch the browser.
create_sessiononly acceptsX-API-KEY/X-API-SECRETin headers — sending them in the body is rejected outright - The iframe only ever sees a
session_token(formatcs_live_.../cs_test_...), never your API key or secret - Merchant internals are stripped before the iframe sees them —
metadata,token_hash, andmerchant_idnever appear in the session-info response - Amount and currency are locked server-side at session creation. Even if a customer intercepts and modifies a payment-initiation request body, the session's original values are what's actually charged
- Rate limited: 5 payment-initiation attempts per 10-minute window, per session, per action
- Invalid session tokens return a generic
400— the API deliberately doesn't reveal whether a token is malformed vs. simply not found, so it can't be used to probe for valid tokens
The PesaVoucher Hosted Checkout Postman collection runs the full flow end-to-end:
- Create Checkout Session — auto-saves
session_tokenandcheckout_urlto collection variables - Get Session Info — confirms what the iframe would load
- Initiate M-Pesa STK Push — sends a real prompt to the test phone. The collection defaults to
KES 1, and this hits the production M-Pesa rail even during testing — it's a real shilling - Poll Payment Status — run repeatedly (or use the Collection Runner with a delay) until
session_statusiscompleted, and readvoucher_codefrom the response — no separate issuance call needed
The collection's Negative tests folder also verifies: invalid session tokens are rejected without leaking why, the browser can't tamper with the amount, credentials sent in the request body are rejected, and the STK rate limit engages after 5 attempts in 10 minutes.
- Prefer Hosted Checkout over the raw redirect flow (Voucher Flow) if you don't want to build your own payment UI
- Always create the session using the paying customer's own currency — see Currencies
- Register your embedding domain(s) with support before going live — session creation fails without at least one registered domain
- Do not call
create_voucher.phpfor Hosted Checkout sessions — the voucher is already issued automatically; calling it yourself creates a duplicate - Listen for the iframe's
postMessageevents to update your UI in real time, but don't treat them as your only source of truth for fulfillment until server-to-server webhook delivery covers every payment method (currentlywalletonly) - Never read
amountorcurrencyback from anything the browser sent — only from your own session record or PesaVoucher's response