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.
- 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 the phone number, sending the STK push, polling for status, and showing success/failure to the customer. You just wait for the webhook (or poll session status yourself) and issue the voucher
Your Server ──create_session──▶ PesaVoucher ──checkout_url──▶ Your FrontendYour Frontend ──renders iframe(checkout_url)──▶ Customer pays inside the iframePesaVoucher ──webhook / session status──▶ Your Server ──issues voucher──▶ Customer credited
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"],"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 |
currency | string | Yes | The paying customer's own preferred currency — KES, TZS, UGX, or USD. Always pass this specific customer's currency, never a fixed value. See Currencies |
payment_methods | array | No | Which options to show in the iframe, e.g. ["mpesa", "wallet"] |
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 |
metadata | object | No | Any custom data you want attached to the session |
{"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 — phone number entry, STK push trigger, and a live status screen — 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 /api/v1/checkout/session_api.php?action=info | The iframe 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 /api/v1/checkout/session_api.php?action=stk_initiate | Triggered when the customer submits their phone number. The amount, order, and merchant are locked to the session server-side — the browser can only supply the phone number, so a customer tampering with the request can't change what's actually charged. Rate limited to 5 attempts per 10 minutes per session. |
GET /api/v1/checkout/session_api.php?action=status | The iframe polls this every 3 seconds until session_status is completed (or a terminal failure state), then shows the result to the customer. |
Once you receive the payment webhook — or see session_status: "completed" if you're polling from your own backend — issue the voucher exactly as in the standard flow (see Create Voucher):
async function issueVoucherForCheckoutSession({ orderId, email, amount, currency }) {// currency must be the same one used to create the checkout session —// this customer's own preferred currency, not a fixed value.const res = await fetch(`${PV_BASE_URL}/create_voucher.php`, {method: 'POST',headers: pvHeaders,body: JSON.stringify({email,amount,currency,unique_id: orderId, // reuse the checkout order_id so retries don't double-issuemessage: 'Payment received!',}),});return res.json();}
Idempotent by
unique_id. Using the checkoutorder_idasunique_idmeans a retried or duplicate webhook returns 409 Conflict instead of issuing a second voucher — safe to call more than once.
- 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 the
stk_initiaterequest body with a fakeamount/currency, the session's original values are what's actually charged - Rate limited: 5 STK push attempts per 10-minute window, per session
- 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 - Issue Voucher (merchant-side simulation) — mirrors what your server does after the webhook fires
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 phone-entry and polling 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
- Treat the webhook as your source of truth for issuing the voucher; use polling only as a fallback
- Use the checkout
order_idas your voucherunique_idso retries are automatically idempotent - Never read
amountorcurrencyback from anything the browser sent — only from your own session record or PesaVoucher's response