Skip to Content
Pesa VoucherDeveloper Documentation

Hosted Checkout (SDK / Iframe)

Embed a single PesaVoucher-hosted checkout iframe instead of building your own payment UI — session creation, embedding, polling, and automatic voucher issuance.


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.

How It Works, In Three Steps

  1. Your server creates a checkout session — server-to-server, using your API key/secret (never exposed to the browser)
  2. You embed the returned checkout_url in an iframe on your page — no custom payment UI needed
  3. 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 Frontend
Your Frontend ──renders iframe(checkout_url)──▶ Customer pays inside the iframe
PesaVoucher ──automatically issues voucher server-side on completion──▶ Customer credited
Your Frontend ──postMessage 'payment.success' (voucher_code)──▶ Your app shows the result

1. Create Checkout Session

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" }
}'

Request Parameters

ParameterTypeRequiredDescription
order_idstringYesYour internal order/reference ID
amountfloatYesPayment amount, in the session's currency. See Amount Rules below
currencystringYesThe 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_methodsarrayNoWhich options to show in the iframe. See Payment Methods below. Defaults to ["mpesa"] if omitted
descriptionstringNoShown to the customer inside the checkout UI
customer_phonestringNoPre-fills the phone number field
customer_emailstringNoPre-fills the email field. Also used, if it matches an existing PesaVoucher account, to automatically attach the resulting voucher to that account
metadataobjectNoAny custom data you want attached to the session

Payment Methods

ValueWhat the customer sees
mpesaPhone-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.
cardVisa/Mastercard entry via Pesapal, in a secure popup
voucherRedeem an existing voucher code to settle this checkout — the code must match the session's exact amount and currency
buy_voucherLets 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

Amount Rules

CurrencyMinimumMaximumDecimals
KES1250,0002
UGX5005,000,0000 (whole numbers)
TZS5005,000,0000 (whole numbers)
USD / EUR / GBP2 (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.

Response

{
"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.


2. Embed the Iframe

Embed checkout_url directly — no further frontend work needed:

<iframe
src="{{ 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.


3. What Happens Inside the Iframe

You don't call these directly — the iframe does. Documented here so you understand what's happening and can debug if something looks off:

CallWhat it does
GET .../session_api.php?action=infoLoads 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_initiateTriggered 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_initiateStarts a Pesapal card payment for the session's own locked amount/order.
POST .../session_api.php?action=voucher_validateChecks 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_redeemActually 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_initiatePurchases 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_statusPolls a standalone voucher purchase started above.
GET .../session_api.php?action=statusPolled every 3 seconds until session_status is completed (or a terminal failure state), then shown to the customer.

4. Getting the Result (Your Server)

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.

How you learn the result today

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 wallet payment method currently sends a server-to-server webhook to your callback_url on completion. M-Pesa and Card completions do not yet have an outbound merchant webhook — the postMessage event above is the only real-time signal for those methods today. Because postMessage originates 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, poll action=status from your own backend as a fallback, or reach out about early access to the broader webhook rollout.


Security

  • Credentials never touch the browser. create_session only accepts X-API-KEY / X-API-SECRET in headers — sending them in the body is rejected outright
  • The iframe only ever sees a session_token (format cs_live_... / cs_test_...), never your API key or secret
  • Merchant internals are stripped before the iframe sees themmetadata, token_hash, and merchant_id never 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

Testing

The PesaVoucher Hosted Checkout Postman collection runs the full flow end-to-end:

  1. Create Checkout Session — auto-saves session_token and checkout_url to collection variables
  2. Get Session Info — confirms what the iframe would load
  3. 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
  4. Poll Payment Status — run repeatedly (or use the Collection Runner with a delay) until session_status is completed, and read voucher_code from 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.


Best Practices

  • 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.php for Hosted Checkout sessions — the voucher is already issued automatically; calling it yourself creates a duplicate
  • Listen for the iframe's postMessage events 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 (currently wallet only)
  • Never read amount or currency back from anything the browser sent — only from your own session record or PesaVoucher's response