Developer documentation

One API for M-Pesa, cards and bank transfers.

Kipa sits on Paystack and Flutterwave rails and normalises them into a single set of objects: charges, refunds, checkout links and settlements. Test keys work immediately — no verification required for the sandbox.

Quickstart

Charge a customer over M-Pesa in three steps: install, authenticate, create a charge.

1. Install
npm install @kipa/node
# or
pip install kipa
2. Authenticate
# Test keys are prefixed kipa_test_, live keys kipa_live_
export KIPA_SECRET_KEY=kipa_test_5f3c...
3. Create an M-Pesa charge (Node)
import Kipa from "@kipa/node";

const kipa = new Kipa(process.env.KIPA_SECRET_KEY);

const charge = await kipa.charges.create({
  amount: 250000,            // minor units — KES 2,500.00
  currency: "KES",
  channel: "mpesa",
  customer: { phone: "+254712345678", email: "amina@example.com" },
  reference: "order_1042",   // your idempotency key
  metadata: { order_id: "1042" },
});

console.log(charge.status); // "pending" → STK push sent to the handset
cURL equivalent
curl https://api.kipa.co.ke/v1/charges \
  -H "Authorization: Bearer $KIPA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":250000,"currency":"KES","channel":"mpesa",
       "customer":{"phone":"+254712345678"},"reference":"order_1042"}'

The charge settles asynchronously. Don't poll — listen for the charge.succeeded webhook below.

Webhook verification

Always verify the signature over the raw request body before parsing JSON, and use a constant-time comparison. Kipa signs with HMAC SHA-256; if you receive directly from the processors, Paystack uses HMAC SHA-512 and Flutterwave a static hash header.

Kipa — Node / TanStack server route
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST({ request }: { request: Request }) {
  const raw = await request.text();
  const signature = request.headers.get("kipa-signature") ?? "";
  const expected = createHmac("sha256", process.env.KIPA_WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(raw);
  if (event.type === "charge.succeeded") {
    await fulfilOrder(event.data.reference);
  }
  return new Response("ok"); // respond 2xx within 5s; we retry 8x otherwise
}
Paystack — HMAC SHA-512
const expected = createHmac("sha512", PAYSTACK_SECRET_KEY)
  .update(rawBody)
  .digest("hex");

if (expected !== request.headers.get("x-paystack-signature")) {
  return new Response("Invalid signature", { status: 401 });
}
Flutterwave — verif-hash
const hash = request.headers.get("verif-hash");
if (!hash || hash !== process.env.FLUTTERWAVE_WEBHOOK_HASH) {
  return new Response("Invalid signature", { status: 401 });
}
Python (Flask)
import hmac, hashlib

@app.post("/webhooks/kipa")
def kipa_webhook():
    raw = request.get_data()
    expected = hmac.new(
        os.environ["KIPA_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("Kipa-Signature", "")):
        return "Invalid signature", 401
    event = request.get_json()
    return "ok", 200
Events are delivered at least once — make handlers idempotent by keying on event.id. Inspect and replay deliveries on the webhooks page.

SDKs

Node.js

@kipa/node

npm install @kipa/node

Python

kipa

pip install kipa

PHP (Laravel)

kipa/kipa-php

composer require kipa/kipa-php

Go

github.com/kipa/kipa-go

go get github.com/kipa/kipa-go

API reference

Base URL https://api.kipa.co.ke · Bearer auth · amounts in minor units · JSON only.

MethodEndpointDescription
POST/v1/chargesCreate a charge. Routes to M-Pesa STK push, card or bank transfer.
GET/v1/charges/:idRetrieve a single charge and its timeline.
GET/v1/chargesList charges, filter by status, channel or date.
POST/v1/refundsRefund a succeeded charge, full or partial.
POST/v1/checkout_linksCreate a hosted, shareable payment page.
GET/v1/settlementsList payouts to your bank or paybill.
GET/v1/eventsReplay webhook events from the last 30 days.
Charge object
{
  "id": "chg_2f81b0",
  "reference": "order_1042",
  "amount": 250000,
  "currency": "KES",
  "channel": "mpesa",
  "status": "succeeded",       // pending | succeeded | failed | refunded
  "processor": "paystack",     // routed rail
  "customer": { "phone": "+254712345678", "email": "amina@example.com" },
  "fees": 4500,
  "created_at": "2026-08-08T09:12:44Z"
}

Errors & testing

insufficient_fundsCustomer's M-Pesa wallet or card was declined for balance.
stk_timeoutCustomer did not enter their PIN within 60 seconds.
invalid_signatureWebhook signature did not match — reject the delivery.
duplicate_referenceA charge with that reference already exists (idempotency).
rate_limitedOver 100 requests/second — retry with exponential backoff.

In test mode, phone numbers ending 0000 always succeed and 1111 always time out.