Payza Payment API
Accept M-Pesa, Airtel, Telkom, Equitel, card, bank, and crypto payments across Africa with one REST endpoint.
# fires an M-Pesa prompt in ~2s
curl -X POST payzaapi.co.ke/api/v1/pay \
-H "X-Public-Key: pk_live_•••" \
-H "X-Secret-Key: sk_live_•••" \
-d '{"amount":500,"currency":"KES",
"customer":{"phone":"2547•••••••"}}'
Recent changes
- USD card payments (Paystack):
payment_urlnow offers card, Apple Pay, mobile money, and Visa QR for USD, alongside the existing crypto option. - stk_push: false: suppresses the M-Pesa prompt and returns a hosted checkout page instead. Enables Airtel, Telkom, and Equitel on KES without auto-prompting Safaricom.
- redirect_url & cancel_url: documented below. Required for the hosted checkout flow.
- KES phone with stk_push:false: accepts all Kenyan network formats, not just Safaricom.
- payment_url always returned: even for KES STK push. Redirecting to it shows a network-selection checkout page.
Payzaapi is a SaaS payment platform built for Africa. Integrate one API to collect payments: Payzaapi handles the M-Pesa STK push, hosted checkout, card processing, webhooks, and wallet crediting.
How it works
- Your server calls
POST /api/v1/paywith the customer's details and amount. - Payza triggers the payment (M-Pesa prompt, or returns a
payment_urlfor hosted checkout). - When payment completes, Payza POSTs to your
callback_urlwith the result. - Optionally verify the webhook signature and fulfil the order.
Base URL
Quickstart
Step 1: Register at payzaapi.co.ke/auth/register.php and verify your email.
Step 2: Generate API keys from your dashboard under API Keys. You get a public key (pk_test_…) and a secret key (sk_test_…).
Step 3: Make a test payment using the keys in test mode (no real money moves).
Minimal example: M-Pesa STK push
# KES 1000 STK push: fires M-Pesa prompt to phone
curl -X POST https://payzaapi.co.ke/api/v1/pay \
-H "X-Public-Key: pk_test_YOUR_KEY" \
-H "X-Secret-Key: sk_test_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"amount": 1000,
"currency": "KES",
"reference": "ORDER_001",
"customer": {
"email": "[email protected]",
"name": "Jane Wanjiku",
"phone": "254712345678"
},
"callback_url": "https://yoursite.com/webhook/payza"
}'
Minimal example: Hosted checkout (all networks)
# KES hosted checkout: no STK push, customer picks network on Payza page
curl -X POST https://payzaapi.co.ke/api/v1/pay \
-H "X-Public-Key: pk_live_YOUR_KEY" \
-H "X-Secret-Key: sk_live_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"amount": 1000,
"currency": "KES",
"stk_push": false,
"reference": "ORDER_002",
"customer": {
"email": "[email protected]",
"name": "Jane Wanjiku",
"phone": "254733123456"
},
"callback_url": "https://yoursite.com/webhook/payza",
"redirect_url": "https://yoursite.com/payment/confirm?ref=ORDER_002",
"cancel_url": "https://yoursite.com/addfunds"
}'
Then redirect the customer to data.payment_url from the response.
Authentication
Every API request requires two headers:
| Header | Description |
|---|---|
| X-Public-Key | Your public key (pk_test_… or pk_live_…) |
| X-Secret-Key | Your secret key (sk_test_… or sk_live_…). Keep this private: never expose it in frontend code. |
Never put your secret key in client-side JavaScript or anywhere publicly visible. All API calls must be made from your server.
Environments
| Environment | Key prefix | Behaviour |
|---|---|---|
| Test | pk_test_… / sk_test_… |
No real money moves. Payments auto-succeed instantly: full webhook lifecycle works exactly as live. |
| Live | pk_live_… / sk_live_… |
Real M-Pesa STK pushes and card charges. |
The same endpoint (/api/v1/pay) is used for both environments. The key prefix determines the mode.
POST /api/v1/pay: Initialize Payment
Creates a new payment transaction. Behaviour depends on the currency and stk_push parameters: see table below.
Request headers
| Header | Required | Description |
|---|---|---|
| X-Public-Key | ✅ | Your public key |
| X-Secret-Key | ✅ | Your secret key |
| Content-Type | ✅ | application/json |
Request body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| amount | float | ✅ | Payment amount in the specified currency. |
| currency | string | Currency code. Default: KES. See Currencies. | |
| reference | string | Your unique order ID (max 64 chars). Auto-generated if omitted. Duplicate returns HTTP 409. | |
| stk_push | boolean | NEW Default: true. Set to false to suppress the automatic M-Pesa STK push and receive a payment_url hosted checkout instead. With stk_push: false, all Kenyan network formats are accepted in customer.phone (not just Safaricom). See KES All Networks. | |
| customer.email | string | ✅ | Customer email address. |
| customer.name | string | Customer full name. | |
| customer.phone | string | ✅ for KES | Phone number. Format: 0712345678 or 254712345678. With stk_push: true (default): Safaricom lines only. With stk_push: false: any Kenyan network. |
| callback_url | string | ✅ | Your URL that Payza will POST the payment result to. Must be publicly reachable. |
| redirect_url | string | NEW URL to redirect the customer to after completing payment on the hosted checkout page. Include your reference as a query parameter so you can verify on return. | |
| cancel_url | string | NEW URL to redirect the customer to if they cancel on the hosted checkout page. | |
| description | string | Payment description shown on the checkout page (max 100 chars). | |
| metadata | object | Any key-value pairs you want passed back unchanged in the webhook. |
Example: KES STK push (Safaricom)
{
"amount": 500,
"currency": "KES",
"reference": "ORDER_001",
"customer": {
"email": "[email protected]",
"name": "Jane Wanjiku",
"phone": "254712345678"
},
"callback_url": "https://yoursite.com/payment/payza"
}
Example: KES hosted checkout, all networks
{
"amount": 500,
"currency": "KES",
"stk_push": false,
"reference": "ORDER_002",
"customer": {
"email": "[email protected]",
"name": "Jane Wanjiku",
"phone": "254733123456"
},
"callback_url": "https://yoursite.com/payment/payza_ke_checkout",
"redirect_url": "https://yoursite.com/payment/payza_ke_checkout?reference=ORDER_002",
"cancel_url": "https://yoursite.com/addfunds"
}
Example: NGN card / bank transfer
{
"amount": 5000,
"currency": "NGN",
"reference": "ORDER_003",
"customer": {
"email": "[email protected]",
"name": "Emeka Obi"
},
"callback_url": "https://yoursite.com/payment/payza_ngn",
"redirect_url": "https://yoursite.com/payment/payza_ngn?reference=ORDER_003",
"cancel_url": "https://yoursite.com/addfunds"
}
Example: Multi-currency (PawaPay mobile money) NEW
Every currency other than KES and NGN: Ghanaian Cedi, Tanzanian Shilling, West African CFA, Ugandan Shilling, Rwandan Franc, Zambian Kwacha, and more: is handled the same way as NGN above: call /api/v1/pay, then redirect your customer to payment_url. They enter their mobile money number there and PawaPay sends them a payment prompt directly on their phone. See Supported Currencies for the full, current list.
{
"amount": 50000,
"currency": "UGX",
"reference": "ORDER_004",
"customer": {
"email": "[email protected]",
"name": "Amara Nakato"
},
"callback_url": "https://yoursite.com/payment/payza_ugx",
"redirect_url": "https://yoursite.com/payment/payza_ugx?reference=ORDER_004",
"cancel_url": "https://yoursite.com/addfunds"
}
Requesting a currency that isn't live yet (see Supported Currencies) returns 400 with a message telling you it's coming soon: check before charging in it.
Response
{
"success": true,
"message": "M-Pesa STK push sent to 254712345678.",
"data": {
"reference": "ORDER_001",
"payment_url": "https://payzaapi.co.ke/payment/checkout.php?ref=ORDER_001",
"crypto_payment_url": "https://payzaapi.co.ke/payment/cryptomus-checkout.php?type=api&reference=ORDER_001",
"amount": 500,
"currency": "KES",
"gateway": "payzaapi",
"actual_gateway": "tuma", // NEW: the real processor: tuma, korapay, test, or pending
"status": "pending",
"stk_sent": true,
"accepted_methods": ["mpesa", "airtel", "bank_transfer", "card", "crypto"]
}
}
payment_url is always returned, even for KES STK push. Redirecting to it shows Payza's hosted checkout page with all network options. With stk_push: false, stk_sent will be false and no prompt fires.
gateway is always "payzaapi". Use actual_gateway to see the real processor behind a transaction: "tuma" (M-Pesa STK push), "korapay" (multi-currency), "test" (test-mode key), or "pending" (KES hosted checkout: no network chosen yet). This field is the same for both /pay and /verify responses on a given transaction.
| HTTP Code | Condition |
|---|---|
| 400 | Missing or invalid field |
| 401 | Missing or invalid API credentials |
| 409 | Duplicate reference |
| 429 | Rate limit exceeded |
| 502 | Upstream gateway rejected the request |
KES: Accepting All Networks NEW
By default, KES payments fire an M-Pesa STK push. To show Payza's hosted checkout page instead: where the customer selects their own network: set "stk_push": false in your request.
With stk_push: false the phone field accepts any Kenyan network number. Payza's checkout page detects the network and shows the correct payment flow.
How the hosted checkout flow works
- You call
POST /api/v1/paywithstk_push: false,redirect_url, andcancel_url. - Payza returns
payment_url: no prompt fires on the customer's phone. - You redirect the customer to
payment_url. - Customer sees a network selector: Safaricom / Airtel / Telkom / Equitel. They pick their network, enter their number, and tap Pay.
- Payment completes → Payza fires your
callback_urlwebhook → Payza redirects the customer to yourredirect_url. - On your
redirect_urlpage, callGET /api/v1/verify/{reference}to confirm the status (webhook may have arrived already).
Full example
<?php
$reference = 'PZKEC-' . time() . '-' . bin2hex(random_bytes(5));
$payload = [
'amount' => 500,
'currency' => 'KES',
'stk_push' => false,
'reference' => $reference,
'customer' => [
'email' => '[email protected]',
'name' => 'John Doe',
'phone' => '254733123456', // any Kenyan network
],
'callback_url' => 'https://yoursite.com/payment/payza_ke_checkout',
'redirect_url' => 'https://yoursite.com/payment/payza_ke_checkout?reference=' . $reference,
'cancel_url' => 'https://yoursite.com/addfunds',
'description' => 'Add Funds',
];
$ch = curl_init('https://payzaapi.co.ke/api/v1/pay');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Public-Key: ' . $publicKey,
'X-Secret-Key: ' . $secretKey,
],
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($result['success']) {
// Redirect customer to hosted checkout: they pick their network there
header('Location: ' . $result['data']['payment_url']);
exit;
}
Important: With stk_push: true (default), Safaricom numbers will still receive an STK push even if you redirect to payment_url. Use stk_push: false whenever you want the customer to initiate payment themselves on Payza's checkout page.
GET /api/v1/verify/{reference}: Verify Payment
Returns the current status of a transaction. For live pending transactions, Payza polls the upstream gateway before responding: the status is always up to date.
Use /verify as a fallback when a user returns to your site after the hosted checkout redirect. The webhook may have already arrived and credited the balance: check your database first before calling this endpoint.
curl https://payzaapi.co.ke/api/v1/verify/ORDER_001 \
-H "X-Public-Key: pk_live_YOUR_KEY" \
-H "X-Secret-Key: sk_live_YOUR_SECRET"
{
"success": true,
"data": {
"reference": "ORDER_001",
"status": "success", // pending | success | failed | cancelled
"amount": 500,
"currency": "KES",
"gateway": "payzaapi",
"actual_gateway": "tuma", // same value /pay returned for this reference
"paid_at": "2024-01-01T12:05:12+03:00"
}
}
| Status | Meaning |
|---|---|
| pending | Awaiting customer action |
| success | Payment confirmed |
| failed | Wrong PIN, insufficient funds, or timeout |
| cancelled | Customer cancelled |
₿ Accepting Crypto Payments
Every transaction created via POST /api/v1/pay automatically includes a crypto_payment_url in the response. No extra integration step required.
For USD, payment_url (the standard checkout redirect used by every currency) now also offers card, Apple Pay, mobile money, and Visa QR via Paystack: crypto isn't the only USD option anymore. Use crypto_payment_url specifically when you want to send a customer straight to the crypto flow, skipping the card option. See Supported Currencies.
How it works
- Call
POST /api/v1/payin any currency (USD recommended for crypto). - Response includes
data.crypto_payment_url. - Redirect to
crypto_payment_url: customer picks coin and network on Cryptomus-hosted page. - On-chain confirmation → Payza fires webhook with
"gateway": "cryptomus".
| Coin | Networks |
|---|---|
| USDT | TRON (TRC-20), Ethereum (ERC-20), BSC (BEP-20), Polygon |
| USDC | Ethereum, Polygon, BSC |
| BTC | Bitcoin |
| ETH | Ethereum |
| BNB | BSC |
| SOL | Solana |
| TRX | TRON |
| LTC | Litecoin |
| DOGE | Dogecoin |
| TON | TON |
Crypto payments use the same verify endpoint and webhook system: no separate flow.
Webhooks
When a payment completes, Payza POSTs to your callback_url. Respond with HTTP 200 within 10 seconds.
{
"event": "payment.success",
"reference": "ORDER_001",
"status": "success",
"amount": 500,
"currency": "KES",
"gateway": "tuma", // tuma | korapay | pawapay | paystack | cryptomus
"customer": { "email": "[email protected]", "name": "Jane Wanjiku" },
"metadata": { "user_id": "123" },
"paid_at": "2024-01-01T12:05:12+03:00"
}
No retry policy currently: if your endpoint misses a webhook, use GET /api/v1/verify/{reference} to poll for status. Also check your database before crediting: Payza may POST the same event twice in some edge cases.
Signature verification (optional)
Every account has its own Webhook Signing Secret, unique to you — find it in Dashboard → API Keys. If you use it, Payza includes an X-Payza-Signature header on every webhook: HMAC-SHA256 of the raw body using that secret.
<?php
$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_PAYZA_SIGNATURE'] ?? '';
// Only verify if you have an encryption key configured
if (!empty($received) && !empty($encryptionKey)) {
$expected = hash_hmac('sha256', $rawBody, $encryptionKey);
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('Invalid signature');
}
}
$data = json_decode($rawBody, true);
if ($data['event'] === 'payment.success') {
// Check your DB first to avoid double-crediting
fulfillOrder($data['reference']);
}
http_response_code(200);
echo 'OK';
Supported Currencies
This table is generated live from our current PawaPay/Paystack configuration: not a hand-maintained list: so it reflects exactly what's payable right now.
| Currency | Code | Payment method | Minimum | Notes |
|---|---|---|---|---|
| Kenyan Shilling | KES |
M-Pesa STK push or hosted checkout (all networks) | KES 1 | Use stk_push: false for Airtel/Telkom/Equitel |
| Nigerian Naira | NGN |
Card, bank transfer, OPay | NGN 100 | Returns payment_url: redirect customer |
| Ghanaian Cedi | GHS |
Mobile money (PawaPay) | GHS 1 | |
| Tanzanian Shilling | TZS |
Mobile money (PawaPay) | TZS 500 | |
| West African CFA Franc | XOF |
Mobile money (PawaPay) | XOF 100 | Ivory Coast (Orange, MTN, Wave) and Senegal (Orange, Free) |
| US Dollar | USD |
Card, Apple Pay, Visa QR, mobile money (Paystack) or crypto (BTC, ETH, USDT + more) | USD 0.50 | See Crypto Payments for the crypto rail. Which Paystack channels actually appear (Apple Pay, Visa QR, mobile money) depends on the customer's card/device: card is always available. |
| Rwandan Franc | RWF |
Mobile money (PawaPay) | Set by PawaPay* | MTN, Airtel |
| Ugandan Shilling | UGX |
Mobile money (PawaPay) | Set by PawaPay* | MTN, Airtel |
| Zambian Kwacha | ZMW |
Mobile money (PawaPay) | Set by PawaPay* | Airtel, MTN, Zamtel |
| Malawian Kwacha | MWK |
Mobile money (PawaPay) | Set by PawaPay* | Airtel, TNM |
| Sierra Leonean Leone | SLL |
Mobile money (PawaPay) | Set by PawaPay* | Orange |
| Congolese Franc | CDF |
Mobile money (PawaPay) | Set by PawaPay* | DRC: Airtel, MTN, Orange |
| Mozambican Metical | MZN |
Mobile money (PawaPay) | Set by PawaPay* | ⚠️ Recently enabled: if you hit unexpected errors on MZN specifically, contact [email protected] before assuming it's your integration |
| Central African CFA Franc | XAF |
Mobile money (PawaPay) | Set by PawaPay* | Cameroon: MTN, Orange |
* Payza doesn't enforce its own minimum for currencies marked "Set by PawaPay": an amount that's too small is rejected directly by PawaPay's API with an error you can surface to the customer. If you need an exact floor to validate client-side before submitting, check PawaPay's current published limits or ask support rather than relying on a guessed figure.
Coming soon: accepted as a currency value in some contexts but not live yet on our account; requesting these returns 400 with a "coming soon" message rather than silently failing:
ZAR (South African Rand), GNF (Guinean Franc), BIF (Burundian Franc), GMD (Gambian Dalasi), SZL (Eswatini Lilangeni), LRD (Liberian Dollar).
Rate Limits
100 requests per 60 seconds per API key pair. Exceeding returns HTTP 429 with a Retry-After header.
| Header | Description |
|---|---|
| X-RateLimit-Limit | 100 |
| X-RateLimit-Remaining | Requests left in current window |
| X-RateLimit-Reset | Unix timestamp when window resets |
| Retry-After | Seconds to wait (only on 429) |
Error Handling
{ "success": false, "message": "A valid customer.email is required." }
| HTTP Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad request: missing or invalid parameters |
| 401 | Unauthorised: invalid or missing API keys |
| 403 | Forbidden: account suspended |
| 404 | Transaction not found |
| 409 | Duplicate reference |
| 429 | Rate limit exceeded |
| 502 | Upstream gateway rejected the request |
| 500 | Internal server error |
Code Examples
PHP: KES STK push
<?php
$payload = [
'amount' => 500,
'currency' => 'KES',
'reference' => 'ORD_' . uniqid(),
'customer' => ['email' => '[email protected]', 'name' => 'Jane', 'phone' => '254712345678'],
'callback_url' => 'https://yoursite.com/payment/payza',
];
$ch = curl_init('https://payzaapi.co.ke/api/v1/pay');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Public-Key: ' . $publicKey,
'X-Secret-Key: ' . $secretKey,
],
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
// STK push sent: store reference, wait for webhook
PHP: KES hosted checkout (all networks)
<?php
$ref = 'PZKEC-' . time() . '-' . bin2hex(random_bytes(5));
$payload = [
'amount' => 500,
'currency' => 'KES',
'stk_push' => false, // no auto STK push
'reference' => $ref,
'customer' => ['email' => '[email protected]', 'name' => 'User', 'phone' => '254733123456'],
'callback_url' => 'https://yoursite.com/payment/payza_ke_checkout',
'redirect_url' => 'https://yoursite.com/payment/payza_ke_checkout?reference=' . $ref,
'cancel_url' => 'https://yoursite.com/addfunds',
];
// ... same curl call ...
// On success:
header('Location: ' . $result['data']['payment_url']);
exit;
PHP: NGN card / bank transfer
<?php
$ref = 'PZNG-' . time() . '-' . bin2hex(random_bytes(5));
$payload = [
'amount' => 5000,
'currency' => 'NGN',
'reference' => $ref,
'customer' => ['email' => '[email protected]', 'name' => 'Emeka'],
'callback_url' => 'https://yoursite.com/payment/payza_ngn',
'redirect_url' => 'https://yoursite.com/payment/payza_ngn?reference=' . $ref,
'cancel_url' => 'https://yoursite.com/addfunds',
];
// ... same curl call ...
header('Location: ' . $result['data']['payment_url']);
exit;
Security Best Practices
- Store API keys in environment variables: never hard-code them.
- Make all API calls from your server, never from browser JavaScript.
- Check your database for the reference before crediting: ignore duplicate webhooks.
- Use HTTPS on your callback URL.
- Rotate your secret key immediately if you suspect it is compromised.
- Always use a unique reference per payment: combine order ID with timestamp.
Testing Your Integration
Test mode uses pk_test_… / sk_test_… keys. Payments auto-succeed with no real phone prompt. The full webhook lifecycle works identically to live.
Test checklist
- Initialize a KES STK push: confirm reference stored and webhook fires.
- Initialize a KES hosted checkout (
stk_push: false): confirm redirect topayment_urlworks. - Initialize an NGN payment: confirm redirect to
payment_urland webhook fires. - Call
GET /api/v1/verify/{reference}: confirmstatus: success. - Test duplicate reference: expect HTTP 409.
- Test missing field: expect HTTP 400 with descriptive message.
Once all steps pass in test mode, switch to live keys.
FAQ
How do I accept Airtel, Telkom, or Equitel payments in KES?
Set "stk_push": false in your request. This suppresses the automatic M-Pesa prompt and returns a payment_url: redirect the customer there. Payza's checkout page shows a network selector (Safaricom, Airtel, Telkom, Equitel) and the customer initiates payment themselves. See KES All Networks.
When should I use stk_push: true vs false?
true (default): best for Safaricom-only flows where you want a seamless background STK push without redirecting the customer. false: use when you want to support all Kenyan networks, or when you don't want to fire a prompt automatically.
Does payment_url work for Safaricom too?
Yes. Every successful /api/v1/pay response includes a payment_url regardless of currency or stk_push setting. Redirecting a Safaricom customer to payment_url shows them Payza's checkout with all network options.
Do I need a Multi-Currency subscription for NGN?
NGN worked in our production integration without activating any separate subscription. If you encounter issues, contact [email protected].
How long before a payment expires?
M-Pesa STK push: 5 minutes. Hosted checkout sessions: 30 minutes. After expiry, create a new payment.
Can I re-use a reference?
No. Each reference must be globally unique. Use a combination of your order ID and a timestamp or random bytes.
My webhook isn't arriving: what should I check?
- Is your
callback_urlpublicly reachable from the internet? - Does your server return HTTP 200 within 10 seconds?
- Use
GET /api/v1/verify/{reference}as a fallback to check status.
What Kenyan numbers are accepted?
With stk_push: true (default): Safaricom only: 07xx or 01xx. Format: 0712345678 or 254712345678.
With stk_push: false: Any Kenyan network: Safaricom, Airtel (073x/074x), Telkom (077x), Equitel (076x).