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.

FityPay Developer API dashboard preview

Authentication & Setup

Authentication uses two token types: a Management Token for product administration, and a Product Token for initiating payments.

  1. Navigate to the Developer Dashboard.
  2. Create one or more products (each product stores a Destination Account, Account Type, and product token).
  3. Use a Product Token in the Authorization header of your requests when calling /v1/start.
  4. 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.

Why these endpoints exist:
  • 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.
Base URL: https://api.kinaracloud.online/v1/products
Bearer Auth (Management Token)
Developer accounts only: This endpoint is available to developer accounts. A business account calling it receives 403 DEVELOPER_ACCOUNT_REQUIRED — business accounts manage products from the dashboard instead. Switch the account type in Account Settings to enable API access.
Management Token rule: This token belongs to your developer account and is used only for managing products. It can create, list, update, disable, rotate, and delete product routing records. It should not be used for /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 rules. 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_typedestination_accountaccount_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.
Account reference rules. For 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, so Brandname, Brand-Name and Br4ndn4me all fail.
  • Letters, numbers, spaces and . _ - / only, up to 50 characters.
Use something your own customers recognise as yours - your business name, an invoice number, or a customer account number.

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."
}
Validation notes: 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.

Running a multi-tenant product? If your own users each get a subdomain (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.
Migration note: existing integrations must verify their webhook URL in the dashboard. Unverified, pending, disabled, or unregistered URLs are rejected before STK initiation.

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.

  1. You add the URL in the dashboard and click Verify.
  2. FityPay requests <your-url>?fitypay_challenge=<random>.
  3. Your endpoint replies 200 with that exact value as the body.
  4. 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
JSON works too. If your framework forces a JSON response, return the value under 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 URLPlain HTTP and private, loopback, or link-local addresses are rejected.
Valid TLS certificateCertificates are verified. Self-signed certificates fail.
No redirectsRedirects are not followed. Register the final URL directly.
Responds within 10 secondsThe verifier gives up after 10 seconds, 5 to connect.
Body under 8 KBOnly the challenge value is expected; longer responses are truncated.
Verification is limited to 6 attempts per webhook every 10 minutes. Fix the endpoint before retrying rather than clicking Verify repeatedly.

Troubleshooting

Dashboard error Fix
endpoint did not echo the challenge valueYour endpoint replied 200 but with different content. Return the challenge only — no HTML wrapper, no extra JSON fields.
endpoint returned HTTP 405Your route only accepts POST. Allow GET on the same path.
endpoint returned HTTP 401/403Auth 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 bodyYou 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 addressThe domain does not resolve, or it points at a private IP.
Already using the old POST echo? Nothing breaks. If the GET challenge does not succeed, FityPay automatically retries with the original POST {"event":"webhook.verification","verification_token":"..."} request and accepts the token echoed in the response body. New integrations should use the GET challenge.
Only webhooks showing Verified and enabled in the dashboard can be used. A developer account can register multiple webhooks, and any product under that account can use any active verified webhook.

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.

Base URL: https://api.kinaracloud.online/v1/webhooks
Bearer Auth (Management Token)
Developer accounts only: same gate as the Products API. A business account calling this receives 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

LimitValue
URLs per POST or DELETE call25
Live webhooks per account (pending + verified)300
Bulk calls10 every 10 minutes per account
Per-webhook verification retries6 every 10 minutes (same as the dashboard)
Provisioning hundreds of tenants at once? Split them across multiple calls of up to 25 — ten calls a minute apart covers 250 webhooks well inside the limits above.

Initiate Payment Endpoint

Trigger an STK Push securely to a customer's phone.

Token rule: /v1/start accepts product tokens only. Management tokens are rejected.
Mandatory: Every request must include a webhook_url that matches an active, verified webhook registered by this developer account.
URL: {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');
}
What the payer actually sees. The two fields M-Pesa shows on the customer's phone are chosen by FityPay, not by the request:
  • Account reference — for paybill and bank products this is the account_number_name you configured on the product, which is validated for uniqueness and impersonation when you save it. For till products 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.
Reconcile on 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."
}
CRITICAL: You must immediately save the 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.
API Reference

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.

Endpoint: GET https://api.kinaracloud.online/v1/status
No 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

ValueHTTPMeaning
operational200Everything is working normally.
degraded200The API is usable but something is slower or failing more than usual - typically M-Pesa upstream. Keep your retry logic in place.
maintenance503STK Push is deliberately paused. components.stk_push.message explains why. Other endpoints still work.
outage503A core dependency is unreachable. Requests will fail.
Polling: The response is cached for about 20 seconds and rate limited to 120 requests per minute per IP. Polling every 30-60 seconds is plenty; a HEAD request works if you only need the HTTP code. Never gate a payment on this endpoint - handle errors from /v1/start directly.
API Reference

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_SWITCHEmergency pause is active; STK traffic is temporarily disabled.
AUTH_HEADER_INVALIDAuthorization header is missing or malformed.
TOKEN_INVALIDProduct token is invalid.
ACCOUNT_SUSPENDEDOwner account is suspended.
PRODUCT_DISABLEDProduct exists but is disabled.
USER_STK_PAUSEDPer-user STK kill-switch is active for the account.
WEBHOOK_REQUIREDwebhook_url was not provided.
WEBHOOK_NOT_REGISTEREDwebhook_url is not an active verified webhook registered by this developer account.
PHONE_INVALIDPhone number format is invalid.
AMOUNT_INVALIDAmount is missing or not greater than zero.
AMOUNT_LIMIT_EXCEEDEDAmount exceeds allowed per-transaction threshold.
RATE_LIMIT_IPPer-IP request rate limit exceeded.
RATE_LIMIT_CLIENTPer-client/product request rate limit exceeded.
RATE_LIMIT_PHONESame phone number exceeded the allowed STK burst window.
RATE_LIMIT_UNIQUE_PHONESToo many different phone numbers were submitted for the same product in a short window.
DUPLICATE_SUBMISSIONSame phone+amount+product request replayed within the protection window.
DAILY_LIMITFree 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_REQUIREDThe product management API was called by a business account.
ACCOUNT_REFERENCE_REJECTEDaccount_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_TAKENReturned with 409. Another FityPay account already uses that account reference. References are unique platform-wide.
REQUEST_BALANCE_EXHAUSTEDActive Pro cycle requests are exhausted and wallet balance is empty, or Pay As You Go wallet has no request tokens.
PRO_REQUEST_CAP_REACHEDLegacy code for exhausted Pro cycle requests.
PRODUCT_ROUTING_INCOMPLETEProduct routing config (for example party_b) is incomplete.
ROUTING_DOWNCentral routing config is unavailable.
CENTRAL_CONFIG_INCOMPLETECentral M-Pesa credentials are incomplete.
GATEWAY_REJECTEDDaraja/M-Pesa rejected the STK initiation request.
SERVER_EXCEPTIONUnexpected server exception occurred during initiation.
Guides

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');
}
Guides

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.

Attention: Your webhook_url MUST be registered and verified in the dashboard before calling /v1/start.
Callback signing is a required follow-up for any future FityPay-delivered webhooks. For current STK results, Safaricom posts directly to your verified callback URL, so always save and match the checkout_request_id from the initiation response.
Check our official repository for the file sample_developer_callback.php. It is a fully operational example demonstrating:
  • Capturing the payload via php://input.
  • Asserting ResultCode === 0 for M-PESA success.
  • Extracting Amount, MpesaReceiptNumber, and matching against the CheckoutRequestID you saved earlier!
Download Sample Callback Template