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

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 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 Frontend
Your Frontend ──renders iframe(checkout_url)──▶ Customer pays inside the iframe
PesaVoucher ──webhook / session status──▶ Your Server ──issues voucher──▶ Customer credited

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"],
"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
currencystringYesThe 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_methodsarrayNoWhich options to show in the iframe, e.g. ["mpesa", "wallet"]
descriptionstringNoShown to the customer inside the checkout UI
customer_phonestringNoPre-fills the phone number field
customer_emailstringNoPre-fills the email field
metadataobjectNoAny custom data you want attached to the session

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 — 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.


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 /api/v1/checkout/session_api.php?action=infoThe 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_initiateTriggered 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=statusThe iframe polls this every 3 seconds until session_status is completed (or a terminal failure state), then shows the result to the customer.

4. Issue the Voucher (Your Server)

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-issue
message: 'Payment received!',
}),
});
return res.json();
}

Idempotent by unique_id. Using the checkout order_id as unique_id means a retried or duplicate webhook returns 409 Conflict instead of issuing a second voucher — safe to call more than once.


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 the stk_initiate request body with a fake amount/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

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
  5. 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.


Best Practices

  • 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_id as your voucher unique_id so retries are automatically idempotent
  • Never read amount or currency back from anything the browser sent — only from your own session record or PesaVoucher's response