FityPay API Documentation
Welcome to the FityPay Developer API! Our platform provides seamless integration for initiating custom M-PESA STK Push requests. Route payments dynamically to your own Tills, Paybills, or Bank Accounts through simple RESTful endpoints.
Authentication & Setup
Authentication uses two token types: a Management Token for product administration, and a Product Token for initiating payments.
- Navigate to the Developer Dashboard.
- Create one or more products (each product stores a Destination Account, Account Type, and product token).
- Use a Product Token in the Authorization header of your requests when calling
/v1/start. - Use your Management Token from the Dashboard API Credentials section for product CRUD operations.
Security note: management token plaintext is not stored in the database. After generation/regeneration, copy it immediately from your active dashboard session.
Example Environment Structure
{
"FITYPAY_BASE_URL": "https://api.kinaracloud.online/v1/start",
"FITYPAY_MANAGEMENT_TOKEN": "YOUR_MANAGEMENT_TOKEN",
"FITYPAY_PRODUCT_TOKEN": "YOUR_PRODUCT_TOKEN" // associated with a specific product (till/paybill/account) in your dashboard
}
Manage Products
Use the Management API to let your own backend create, update, and delete products automatically, instead of doing this manually in the dashboard.
- Onboard new tills/paybills/bank accounts from your ERP, POS, or SaaS system in real-time.
- Store one product token per destination account for safer payment routing.
- Keep product details synced when account names or destination numbers change.
https://api.kinaracloud.online/v1/productsBearer Auth (Management Token)
403 DEVELOPER_ACCOUNT_REQUIRED — business accounts manage products from the dashboard instead. Switch the account type in Account Settings to enable API access.
/v1/start. For payment initiation, use the product_token returned by this Management API.
Create / Update Request Body Model
{
"product_id": 12, // Required only for PATCH/PUT/DELETE
"product_name": "Main Shop Till", // Required for create. Max 15 characters
"account_type": "till", // "till", "paybill" or "bank"
"destination_account": "123456", // Till number, paybill number, or the bank's paybill number
"account_number_name": "Main Counter", // See the rules below
"status": "active", // "active" or "disabled"
"rotate_token": false // Optional on PATCH/PUT; true generates a new product token
}
product_name is capped at 15 characters and is rejected with 400 PRODUCT_NAME_REJECTED if it is longer, empty, or contains line breaks or control characters. It is your own label for the channel — it is never shown to the payer and never sent to M-Pesa, so keep it short enough to read in a list: Main Till, School Fees, Branch 02.
The limit applies only when you send the field. Products created before this rule keep their longer names and can still be updated — omit
product_name from the request and the existing name is left alone.
Account Types
account_type | destination_account | account_number_name |
|---|---|---|
till |
Your till number | Not used. M-Pesa shows the till name, so anything sent here is ignored. |
paybill |
Your paybill number | Required. Free text, shown to the payer in the M-Pesa prompt. Subject to the rules below. |
bank |
Your bank's paybill number | Required. The bank account number. Digits only, 4-34 of them; spaces and dashes are allowed as separators. |
paybill products account_number_name is what your customer reads on their phone before they enter their PIN, so it is validated:
- It must be unique across FityPay. If another account already uses it you get
409 ACCOUNT_REFERENCE_TAKEN. Safaricom issues a distinct account number to every merchant, and so do we. - It cannot borrow another brand's identity. References matching a bank, payment provider or well-known brand are refused with
400 ACCOUNT_REFERENCE_REJECTED. Near-misses and character substitutions are caught too, soBrandname,Brand-NameandBr4ndn4meall fail. - Letters, numbers, spaces and
. _ - /only, up to 50 characters.
Returned Product Object
{
"product_id": 12,
"product_name": "Main Shop Till",
"account_type": "till",
"account_type_label": "Till Number",
"destination_account": "123456",
"account_number_name": "Main Counter",
"product_token": "YOUR_PRODUCT_TOKEN",
"status": "active",
"created_at": "2026-04-19 12:30:00",
"updated_at": "2026-04-19 12:30:00"
}
List Products (GET)
curl -X GET https://api.kinaracloud.online/v1/products \
-H "Authorization: Bearer YOUR_MANAGEMENT_TOKEN"
{
"success": true,
"products": [
{
"product_id": 12,
"product_name": "Main Shop Till",
"account_type": "till",
"account_type_label": "Till Number",
"destination_account": "123456",
"account_number_name": "Main Counter",
"product_token": "YOUR_PRODUCT_TOKEN",
"status": "active",
"created_at": "2026-04-19 12:30:00",
"updated_at": "2026-04-19 12:30:00"
},
{
"product_id": 13,
"product_name": "Branch Paybill",
"account_type": "paybill",
"account_type_label": "Paybill Number",
"destination_account": "174379",
"account_number_name": "INV-BRANCH-001",
"product_token": "YOUR_SECOND_PRODUCT_TOKEN",
"status": "active",
"created_at": "2026-04-19 12:40:00",
"updated_at": "2026-04-19 12:40:00"
}
]
}
Create Product (POST)
curl -X POST https://api.kinaracloud.online/v1/products \
-H "Authorization: Bearer YOUR_MANAGEMENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_name":"Main Shop Till",
"account_type":"till",
"destination_account":"123456",
"account_number_name":"Main Counter"
}'
{
"success": true,
"message": "Product created successfully.",
"product": {
"product_id": 12,
"product_name": "Main Shop Till",
"account_type": "till",
"account_type_label": "Till Number",
"destination_account": "123456",
"account_number_name": "Main Counter",
"product_token": "YOUR_PRODUCT_TOKEN", // save this token securely! It is used to route payments to the correct destination account.
"status": "active",
"created_at": "2026-04-19 12:30:00",
"updated_at": "2026-04-19 12:30:00"
}
}
Edit Product (PATCH or PUT)
{
"product_id": 12,
"product_name": "Downtown Till",
"account_type": "paybill",
"destination_account": "174379",
"account_number_name": "INV-STORE-001",
"status": "active",
"rotate_token": false
}
{
"success": true,
"message": "Product updated successfully.",
"product": {
"product_id": 12,
"product_name": "Downtown Till",
"account_type": "paybill",
"account_type_label": "Paybill Number",
"destination_account": "174379",
"account_number_name": "INV-STORE-001",
"product_token": "YOUR_PRODUCT_TOKEN",
"status": "active",
"created_at": "2026-04-19 12:30:00",
"updated_at": "2026-04-19 12:45:00"
}
}
Create a Bank Product (POST)
Use destination_account for your bank's paybill number and account_number_name for the bank account number itself.
curl -X POST https://api.kinaracloud.online/v1/products \
-H "Authorization: Bearer YOUR_MANAGEMENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_name": "Settlement",
"account_type": "bank",
"destination_account": "247247",
"account_number_name": "0114558829"
}'
{
"success": true,
"message": "Product created successfully.",
"product": {
"product_id": 14,
"product_name": "Settlement",
"account_type": "bank",
"account_type_label": "Bank Account",
"destination_account": "247247",
"account_number_name": "0114558829",
"product_token": "YOUR_BANK_PRODUCT_TOKEN",
"status": "active",
"created_at": "2026-04-19 12:50:00",
"updated_at": "2026-04-19 12:50:00"
}
}
Delete Product (DELETE)
{
"product_id": 12
}
{
"success": true,
"message": "Product deleted successfully."
}
account_type supports till, bank paybill/account and paybill. account_number_name is mandatory for paybill and bankpaybill products.
Management Token Code Examples
These examples use the Management Token to create/list products. The returned product_token is what you later use on /v1/start.
Node.js (Using Axios)
const axios = require('axios');
const url = 'https://api.kinaracloud.online/v1/products';
const managementToken = 'YOUR_MANAGEMENT_TOKEN';
const payload = {
product_name: 'Main Shop Till',
account_type: 'till',
destination_account: '123456',
account_number_name: 'Main Counter',
status: 'active'
};
const response = await axios.post(url, payload, {
headers: {
Authorization: `Bearer ${managementToken}`,
'Content-Type': 'application/json'
}
});
const productToken = response.data.product.product_token;
console.log('Use this product token for /v1/start:', productToken);
const response = await axios.get('https://api.kinaracloud.online/v1/products', {
headers: {
Authorization: `Bearer ${managementToken}`
}
});
console.log(response.data.products);
Python (Using Requests)
import requests
url = "https://api.kinaracloud.online/v1/products"
headers = {
"Authorization": "Bearer YOUR_MANAGEMENT_TOKEN",
"Content-Type": "application/json"
}
payload = {
"product_name": "Branch Paybill",
"account_type": "paybill",
"destination_account": "174379",
"account_number_name": "INV-BRANCH-001",
"status": "active"
}
response = requests.post(url, json=payload, headers=headers)
data = response.json()
if response.status_code == 201 and data.get("success"):
print("Product token:", data["product"]["product_token"])
else:
print("Error:", data.get("message"))
payload = {
"product_id": 12,
"product_name": "Branch 02",
"account_type": "paybill",
"destination_account": "174379",
"account_number_name": "INV-BRANCH-002",
"status": "active",
"rotate_token": False
}
response = requests.patch(url, json=payload, headers=headers)
print(response.json())
PHP (Using cURL)
<?php
$url = "https://api.kinaracloud.online/v1/products";
$managementToken = "YOUR_MANAGEMENT_TOKEN";
$payload = json_encode([
"product_name" => "Main Shop Till",
"account_type" => "till",
"destination_account" => "123456",
"account_number_name" => "Main Counter",
"status" => "active"
]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $managementToken,
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($httpCode === 201 && !empty($data["success"])) {
echo "Product token: " . $data["product"]["product_token"];
}
<?php
$payload = json_encode(["product_id" => 12]);
$ch = curl_init("https://api.kinaracloud.online/v1/products");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $managementToken,
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
print_r(json_decode($response, true));
Webhook Registration
Before using STK Push callbacks, register your callback URL from the developer dashboard under Webhooks, or programmatically via the Webhook Management API. FityPay verifies that you control the HTTPS endpoint before it can be used by /v1/start.
tenant1.yourapp.com, tenant2.yourapp.com, …), each one still needs its own verification — see why and use the bulk endpoint to register many at once instead of one at a time in the dashboard.
How verification works
Verification is a challenge echo. FityPay sends your URL a one-time random value and checks that your endpoint sends the same value back. Echoing a value that only we generated proves you control that exact URL.
The challenge is sent as a GET request. Real M-PESA callbacks arrive as POST, so verification never touches your payment-handling code.
- You add the URL in the dashboard and click Verify.
- FityPay requests
<your-url>?fitypay_challenge=<random>. - Your endpoint replies
200with that exact value as the body. - The values are compared. On a match the webhook becomes Verified.
The exchange
/api/fitypay/webhook below is only an example path. FityPay calls whatever URL you registered as webhook_url — there is no fixed or reserved route it must live at.
GET /api/fitypay/webhook?fitypay_challenge=7f3c9a01b28e4d6f HTTP/1.1
Host: yourdomain.com
User-Agent: FityPay-Webhook-Verifier/1.0
X-FityPay-Verification: 7f3c9a01b28e4d6f
Respond with the challenge and nothing else:
HTTP/1.1 200 OK
Content-Type: text/plain
7f3c9a01b28e4d6f
fitypay_challenge, verification_token, or challenge — for example {"fitypay_challenge":"7f3c9a01b28e4d6f"}. Plain text is simplest.
Implementation
Add this above your existing callback logic, at whatever URL you already registered as your webhook_url — the /api/fitypay/webhook path below is just an example, not a required route. It answers the challenge and returns; every other request falls through to your normal handler untouched.
<?php
// FityPay webhook verification - answer the challenge and stop.
if (isset($_GET['fitypay_challenge'])) {
header('Content-Type: text/plain');
echo $_GET['fitypay_challenge'];
exit;
}
// Your existing M-PESA callback handling continues below, unchanged.
$payload = json_decode(file_get_contents('php://input'), true);
// ...
// Express
// Use your existing callback route here - '/api/fitypay/webhook' is just an
// example. It must match the webhook_url you registered, nothing more.
app.get('/your/registered/webhook/path', (req, res) => {
const challenge = req.query.fitypay_challenge;
if (challenge) {
return res.type('text/plain').send(challenge);
}
res.sendStatus(400);
});
// Your existing callback route stays as it is.
app.post('/your/registered/webhook/path', (req, res) => {
// ...
res.sendStatus(200);
});
# Flask
# Use your existing callback route here - '/api/fitypay/webhook' is just an
# example. It must match the webhook_url you registered, nothing more.
@app.route("/your/registered/webhook/path", methods=["GET", "POST"])
def fitypay_webhook():
if request.method == "GET":
challenge = request.args.get("fitypay_challenge")
if challenge:
return Response(challenge, mimetype="text/plain")
return "", 400
# Your existing callback handling continues here.
payload = request.get_json(silent=True) or {}
return "", 200
Requirements
| Requirement | Why |
|---|---|
| Public HTTPS URL | Plain HTTP and private, loopback, or link-local addresses are rejected. |
| Valid TLS certificate | Certificates are verified. Self-signed certificates fail. |
| No redirects | Redirects are not followed. Register the final URL directly. |
| Responds within 10 seconds | The verifier gives up after 10 seconds, 5 to connect. |
| Body under 8 KB | Only the challenge value is expected; longer responses are truncated. |
Troubleshooting
| Dashboard error | Fix |
|---|---|
endpoint did not echo the challenge value | Your endpoint replied 200 but with different content. Return the challenge only — no HTML wrapper, no extra JSON fields. |
endpoint returned HTTP 405 | Your route only accepts POST. Allow GET on the same path. |
endpoint returned HTTP 401/403 | Auth middleware or a WAF is blocking the verifier. Allow the challenge request through. |
endpoint redirected (HTTP 301) | Register the URL you were redirected to, usually the https:// or www form. |
with an empty body | You returned 200 but printed nothing. Make sure you echo the value and exit. |
could not reach the endpoint (timed out) | The URL is not publicly reachable, or the handler is too slow. Answer the challenge before any slow work. |
host does not resolve to a public address | The domain does not resolve, or it points at a private IP. |
POST {"event":"webhook.verification","verification_token":"..."} request and accepts the token echoed in the response body. New integrations should use the GET challenge.
Webhook Management API
Register, list, and remove webhooks from your own backend instead of the dashboard — built for platforms that provision a subdomain per end-user and need to register each one the moment it goes live.
https://api.kinaracloud.online/v1/webhooksBearer Auth (Management Token)
403 DEVELOPER_ACCOUNT_REQUIRED.
Why subdomains still verify individually
This endpoint makes registering many webhooks fast; it does not change what is being proven. Each URL still gets its own real ownership check — the same GET challenge described above, sent to that exact host. We looked at verifying only your main domain and trusting every subdomain under it automatically, and decided against it: an HTTP challenge answered on yourapp.com cannot prove you control anything.yourapp.com, including a subdomain your own DNS still points at a decommissioned service. That gap is exactly the kind of borrowed identity webhook verification exists to catch. "Bulk" here means many independent proofs in one call, not one proof covering many hosts.
Register webhooks (POST)
Send webhook_url for one, or webhook_urls for many (up to 25 per call). Both shapes return the same per-URL result format, so your integration does not need two code paths as you grow from one tenant to many.
curl -X POST https://api.kinaracloud.online/v1/webhooks \
-H "Authorization: Bearer YOUR_MANAGEMENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook_urls": [
"https://tenant1.yourapp.com/api/fitypay/webhook",
"https://tenant2.yourapp.com/api/fitypay/webhook",
"https://tenant3.yourapp.com/api/fitypay/webhook"
]
}'
const axios = require('axios');
const response = await axios.post('https://api.kinaracloud.online/v1/webhooks', {
webhook_urls: [
'https://tenant1.yourapp.com/api/fitypay/webhook',
'https://tenant2.yourapp.com/api/fitypay/webhook',
'https://tenant3.yourapp.com/api/fitypay/webhook'
]
}, {
headers: {
'Authorization': 'Bearer YOUR_MANAGEMENT_TOKEN',
'Content-Type': 'application/json'
}
});
// response.data.results holds the per-URL outcome - check it even
// when response.data.success is false, since some URLs may still
// have verified.
console.log(response.data.summary, response.data.results);
import requests
response = requests.post(
"https://api.kinaracloud.online/v1/webhooks",
headers={"Authorization": "Bearer YOUR_MANAGEMENT_TOKEN"},
json={
"webhook_urls": [
"https://tenant1.yourapp.com/api/fitypay/webhook",
"https://tenant2.yourapp.com/api/fitypay/webhook",
"https://tenant3.yourapp.com/api/fitypay/webhook"
]
}
)
data = response.json()
# data["results"] holds the per-URL outcome - check it even when
# data["success"] is False, since some URLs may still have verified.
print(data["summary"], data["results"])
<?php
$ch = curl_init("https://api.kinaracloud.online/v1/webhooks");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer YOUR_MANAGEMENT_TOKEN",
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"webhook_urls" => [
"https://tenant1.yourapp.com/api/fitypay/webhook",
"https://tenant2.yourapp.com/api/fitypay/webhook",
"https://tenant3.yourapp.com/api/fitypay/webhook"
]
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
// $result['results'] holds the per-URL outcome - check it even when
// $result['success'] is false, since some URLs may still have verified.
foreach ($result['results'] as $row) {
echo $row['url'] . ': ' . $row['message'] . PHP_EOL;
}
{
"success": false,
"message": "2 verified, 1 failed, 0 duplicate.",
"summary": { "verified": 2, "failed": 1, "duplicate_in_request": 0 },
"results": [
{
"url": "https://tenant1.yourapp.com/api/fitypay/webhook",
"success": true,
"verified": true,
"webhook_id": 481,
"message": "Webhook added and verified."
},
{
"url": "https://tenant2.yourapp.com/api/fitypay/webhook",
"success": true,
"verified": true,
"webhook_id": 482,
"message": "Webhook added and verified."
},
{
"url": "https://tenant3.yourapp.com/api/fitypay/webhook",
"success": false,
"verified": false,
"webhook_id": 483,
"message": "Webhook saved but verification failed: endpoint did not echo the challenge value"
}
]
}
success at the top level is true only when every URL in the call verified. Always read results for the per-URL outcome — one failing tenant should not stop you from provisioning the rest, and the response tells you exactly which one to retry.
List webhooks (GET)
curl -X GET https://api.kinaracloud.online/v1/webhooks \
-H "Authorization: Bearer YOUR_MANAGEMENT_TOKEN"
const axios = require('axios');
const response = await axios.get('https://api.kinaracloud.online/v1/webhooks', {
headers: { 'Authorization': 'Bearer YOUR_MANAGEMENT_TOKEN' }
});
console.log(response.data.webhooks);
import requests
response = requests.get(
"https://api.kinaracloud.online/v1/webhooks",
headers={"Authorization": "Bearer YOUR_MANAGEMENT_TOKEN"}
)
print(response.json()["webhooks"])
<?php
$ch = curl_init("https://api.kinaracloud.online/v1/webhooks");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer YOUR_MANAGEMENT_TOKEN"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($result['webhooks'] as $webhook) {
echo $webhook['url'] . ': ' . $webhook['status'] . PHP_EOL;
}
{
"success": true,
"webhooks": [
{
"webhook_id": 481,
"url": "https://tenant1.yourapp.com/api/fitypay/webhook",
"status": "verified",
"active": true,
"last_verification_error": null,
"verified_at": "2026-06-01 09:12:03",
"created_at": "2026-06-01 09:11:58",
"updated_at": "2026-06-01 09:12:03"
}
]
}
Remove webhooks (DELETE)
Send webhook_id for one, or webhook_ids for many (same 25 cap) — the natural pair to bulk registration when a tenant is deprovisioned.
curl -X DELETE https://api.kinaracloud.online/v1/webhooks \
-H "Authorization: Bearer YOUR_MANAGEMENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "webhook_ids": [481, 482] }'
const axios = require('axios');
const response = await axios.delete('https://api.kinaracloud.online/v1/webhooks', {
headers: {
'Authorization': 'Bearer YOUR_MANAGEMENT_TOKEN',
'Content-Type': 'application/json'
},
data: { webhook_ids: [481, 482] }
});
console.log(response.data.results);
import requests
response = requests.delete(
"https://api.kinaracloud.online/v1/webhooks",
headers={"Authorization": "Bearer YOUR_MANAGEMENT_TOKEN"},
json={"webhook_ids": [481, 482]}
)
print(response.json()["results"])
<?php
$ch = curl_init("https://api.kinaracloud.online/v1/webhooks");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer YOUR_MANAGEMENT_TOKEN",
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["webhook_ids" => [481, 482]]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $result['message'];
Limits
| Limit | Value |
|---|---|
URLs per POST or DELETE call | 25 |
| Live webhooks per account (pending + verified) | 300 |
| Bulk calls | 10 every 10 minutes per account |
| Per-webhook verification retries | 6 every 10 minutes (same as the dashboard) |
Initiate Payment Endpoint
Trigger an STK Push securely to a customer's phone.
/v1/start accepts product tokens only. Management tokens are rejected.
webhook_url that matches an active, verified webhook registered by this developer account.
{FITYPAY_BASE_URL}POST Bearer Auth
Request Body JSON Model
{
"phone": "0712345678", // Or "254712345678"
"amount": 1, // Must be greater than 0
"webhook_url": "https://payments.merchant.co.ke/callback.php", // Required. Must already be verified via the dashboard or /v1/webhooks
"service": "ecommerce", // Optional: tag callbacks for nested services
"reference": "ORDER-4471", // Optional: your own reconciliation reference
"description": "Payment for software"
}
Example Request
curl -X POST https://api.kinaracloud.online/v1/start \
-H "Authorization: Bearer YOUR_PRODUCT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone": "0712345678",
"amount": 1,
"webhook_url": "https://payments.merchant.co.ke/callback.php",
"reference": "ORDER-4471",
"description": "Payment for software"
}'
const axios = require('axios');
const response = await axios.post('https://api.kinaracloud.online/v1/start', {
phone: '0712345678',
amount: 1,
webhook_url: 'https://payments.merchant.co.ke/callback.php', // must be registered and verified
reference: 'ORDER-4471', // optional
description: 'Payment for software'
}, {
headers: {
'Authorization': 'Bearer YOUR_PRODUCT_TOKEN',
'Content-Type': 'application/json'
}
});
if (response.data.success) {
console.log('Checkout ID:', response.data.checkout_request_id);
} else {
console.error('Error:', response.data.message);
}
import requests
response = requests.post(
"https://api.kinaracloud.online/v1/start",
headers={
"Authorization": "Bearer YOUR_PRODUCT_TOKEN",
"Content-Type": "application/json"
},
json={
"phone": "0712345678",
"amount": 1,
"webhook_url": "https://payments.merchant.co.ke/callback.php", # must be registered and verified
"reference": "ORDER-4471", # optional
"description": "Payment for software"
}
)
data = response.json()
if data.get("success"):
print("Checkout ID:", data["checkout_request_id"])
else:
print("Error:", data.get("message"))
<?php
$ch = curl_init("https://api.kinaracloud.online/v1/start");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer YOUR_PRODUCT_TOKEN",
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"phone" => "0712345678",
"amount" => 1,
"webhook_url" => "https://payments.merchant.co.ke/callback.php", // must be registered and verified
"reference" => "ORDER-4471", // optional
"description" => "Payment for software"
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!empty($result['success'])) {
echo "Checkout ID: " . $result['checkout_request_id'];
} else {
echo "Error: " . ($result['message'] ?? 'Request failed');
}
- Account reference — for
paybillandbankproducts this is theaccount_number_nameyou configured on the product, which is validated for uniqueness and impersonation when you save it. Fortillproducts M-Pesa displays the till name, and FityPay sends a generated reference. - Transaction description — generated by FityPay.
reference and description are still accepted and are stored against the transaction for your own audit trail — you will find them in Dashboard › Payments & Logs — and reference is echoed back in the response so you can reconcile against it. Neither is forwarded to M-Pesa. This is deliberate: it means the identity shown to a payer is always the one your product was verified with, and cannot be changed per request.
checkout_request_id. It is the only value M-Pesa returns in the callback, and it is the value the webhook carries back to you.
Success Response JSON
{
"success": true,
"checkout_request_id": "ws_CO_21032026123456789",
"merchant_request_id": "12345-67890-1",
"reference": "AUTOGEN-REF-12345",
"message": "Payment initiated successfully."
}
checkout_request_id into your local database! When Safaricom finalizes the payment, they will send a webhook containing this exact ID. You use it to securely map the notification back to the customer's order.
Service Status Endpoint
A public, unauthenticated endpoint you can poll from your own monitoring, or check by hand when a request behaves unexpectedly. It tells you whether a failure is on your side, ours, or M-Pesa's.
GET https://api.kinaracloud.online/v1/statusNo authentication required
{
"status": "operational",
"message": "All systems operational.",
"components": {
"api": { "status": "operational", "description": "REST API request handling" },
"database": { "status": "operational", "description": "Account, product and transaction storage" },
"stk_push": { "status": "operational", "description": "M-Pesa STK Push initiation" },
"callbacks": { "status": "operational", "description": "Payment result delivery to webhooks" }
},
"metrics": {
"stk_success_rate_1h": 98.4,
"window": "60m"
},
"checked_at": "2026-08-13T09:41:07Z",
"cache_seconds": 20
}
Status Values
| Value | HTTP | Meaning |
|---|---|---|
operational | 200 | Everything is working normally. |
degraded | 200 | The API is usable but something is slower or failing more than usual - typically M-Pesa upstream. Keep your retry logic in place. |
maintenance | 503 | STK Push is deliberately paused. components.stk_push.message explains why. Other endpoints still work. |
outage | 503 | A core dependency is unreachable. Requests will fail. |
HEAD request works if you only need the HTTP code. Never gate a payment on this endpoint - handle errors from /v1/start directly.
HTTP Status Codes
Our API relies on standard HTTP concepts to indicate success or failure. Error responses include a human-readable message and a machine-readable code for programmatic handling.
| Code | Status | Description |
|---|---|---|
| 200 OK | Success | The STK push prompt has been successfully triggered on the customer's device. |
| 400 Bad Request | Validation Error | Missing required payload fields, invalid phone number logic, incomplete dashboard setup, or upstream Daraja rejection. Check the `message` field. |
| 403 Forbidden | Account Suspended | Your account has been suspended. |
| 401 Unauthorized | Auth Failed | Missing, improperly formatted, or invalid Authorization Bearer token header. |
| 405 Method Not Allowed | Invalid Method | You attempted to route traffic using a method other than POST. Update your request method. |
| 422 Unprocessable Entity | Validation Guard | Request format is valid JSON but violates strict rules, such as an unregistered webhook or amount limit. |
| 402 Payment Required | Billing Block | Account has consumed all included Pro requests for the current cycle. |
| 429 Too Many Requests | Rate / Quota Limit | Traffic was throttled (per-IP, per-client, per-phone, or unique-phone burst guard) or your plan quota was exceeded. |
| 500 Internal Error | Server Issue | Our upstream Daraja connection has failed, or central application is down. |
Error Response Shape
{
"success": false,
"message": "Too many requests from this IP. Please retry later.",
"code": "RATE_LIMIT_IP",
"retry_after_seconds": 74
}
Unregistered Webhook Example
{
"success": false,
"message": "The supplied webhook_url is not registered and verified for this account.",
"code": "WEBHOOK_NOT_REGISTERED",
"error": {
"code": "WEBHOOK_NOT_REGISTERED",
"message": "The supplied webhook_url is not registered and verified for this account."
}
}
Machine Error Codes
| Code | Meaning |
|---|---|
STK_KILL_SWITCH | Emergency pause is active; STK traffic is temporarily disabled. |
AUTH_HEADER_INVALID | Authorization header is missing or malformed. |
TOKEN_INVALID | Product token is invalid. |
ACCOUNT_SUSPENDED | Owner account is suspended. |
PRODUCT_DISABLED | Product exists but is disabled. |
USER_STK_PAUSED | Per-user STK kill-switch is active for the account. |
WEBHOOK_REQUIRED | webhook_url was not provided. |
WEBHOOK_NOT_REGISTERED | webhook_url is not an active verified webhook registered by this developer account. |
PHONE_INVALID | Phone number format is invalid. |
AMOUNT_INVALID | Amount is missing or not greater than zero. |
AMOUNT_LIMIT_EXCEEDED | Amount exceeds allowed per-transaction threshold. |
RATE_LIMIT_IP | Per-IP request rate limit exceeded. |
RATE_LIMIT_CLIENT | Per-client/product request rate limit exceeded. |
RATE_LIMIT_PHONE | Same phone number exceeded the allowed STK burst window. |
RATE_LIMIT_UNIQUE_PHONES | Too many different phone numbers were submitted for the same product in a short window. |
DUPLICATE_SUBMISSION | Same phone+amount+product request replayed within the protection window. |
DAILY_LIMIT | Free trial daily quota reached before any paid activity. The quota depends on account type: developer accounts get 10 calls per day, business accounts 20. The response echoes account_type, limit, used, remaining and reset_at. |
DEVELOPER_ACCOUNT_REQUIRED | The product management API was called by a business account. |
ACCOUNT_REFERENCE_REJECTED | account_number_name is missing, too long, contains disallowed characters, matches a brand or payment-provider name, or (for bank products) is not a valid account number. It is shown to the payer in the M-Pesa prompt, so it must identify you. |
ACCOUNT_REFERENCE_TAKEN | Returned with 409. Another FityPay account already uses that account reference. References are unique platform-wide. |
REQUEST_BALANCE_EXHAUSTED | Active Pro cycle requests are exhausted and wallet balance is empty, or Pay As You Go wallet has no request tokens. |
PRO_REQUEST_CAP_REACHED | Legacy code for exhausted Pro cycle requests. |
PRODUCT_ROUTING_INCOMPLETE | Product routing config (for example party_b) is incomplete. |
ROUTING_DOWN | Central routing config is unavailable. |
CENTRAL_CONFIG_INCOMPLETE | Central M-Pesa credentials are incomplete. |
GATEWAY_REJECTED | Daraja/M-Pesa rejected the STK initiation request. |
SERVER_EXCEPTION | Unexpected server exception occurred during initiation. |
Code Integration Examples
Copy-paste starting points for a single STK Push request in Node.js, Python, and PHP. Each tab shows the request and how to read the response.
Node.js (Using Axios)
const axios = require('axios');
const url = 'https://api.kinaracloud.online/v1/start';
const payload = {
phone: '0712345678',
amount: 1,
webhook_url: 'https://payments.merchant.co.ke/callback.php', // must be registered and verified
service: 'order_12345', // optional
description: 'Test Payment Integration'
};
const response = await axios.post(url, payload, {
headers: {
'Authorization': 'Bearer YOUR_PRODUCT_TOKEN',
'Content-Type': 'application/json'
}
});
if (response.status === 200 && response.data.success) {
const { checkout_request_id, merchant_request_id, reference } = response.data;
console.log('Checkout ID:', checkout_request_id);
console.log('Reference:', reference);
} else {
console.error('Error:', response.data.message);
}
Python (Using Requests)
import requests
url = "https://api.kinaracloud.online/v1/start"
headers = {
"Authorization": "Bearer YOUR_PRODUCT_TOKEN",
"Content-Type": "application/json"
}
payload = {
"phone": "0712345678",
"amount": 1,
"webhook_url": "https://payments.merchant.co.ke/callback.php", # must be registered and verified
"service": "order_12345", # optional
"description": "Test Payment Integration"
}
response = requests.post(url, json=payload, headers=headers)
data = response.json()
if response.status_code == 200 and data.get("success"):
print("Checkout ID:", data["checkout_request_id"])
print("Reference:", data["reference"])
else:
print("Failed to initiate:", data.get("message"))
PHP (Using cURL)
<?php
$url = "https://api.kinaracloud.online/v1/start";
$token = "YOUR_PRODUCT_TOKEN";
$data = json_encode([
"phone" => "0712345678",
"amount" => 1,
"webhook_url" => "https://payments.merchant.co.ke/callback.php", // must be registered and verified
"service" => "order_12345", // optional
"description" => "Test Payment Integration"
]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $token,
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($httpCode === 200 && !empty($result['success'])) {
echo "Checkout ID: " . $result['checkout_request_id'];
echo "Reference: " . $result['reference'];
} else {
echo "Error: " . ($result['message'] ?? 'Request failed');
}
Handling Webhooks
Our platform enforces Direct-to-Developer Webhook Routing. This dramatically increases security and eliminates bottleneck dependencies.
Safaricom will POST the transaction result directly to the verified webhook_url you include in each payment request, so it is vital you script it correctly.
webhook_url MUST be registered and verified in the dashboard before calling /v1/start.
checkout_request_id from the initiation response.
sample_developer_callback.php. It is a fully operational example demonstrating:
- Capturing the payload via
php://input. - Asserting
ResultCode === 0for M-PESA success. - Extracting
Amount,MpesaReceiptNumber, and matching against theCheckoutRequestIDyou saved earlier!