Kenya Escrow

API Documentation

Kenya Escrow Business API v1 — add M-Pesa escrow to your platform.

Quickstart

1. Register → verify your email → a ke_test_ key is issued instantly
2. Create test transactions and simulate payments — no real money moves
3. Submit business details under Compliance → approved → ke_live_ key
4. Live transactions trigger a real M-Pesa STK push to the buyer's phone

Authentication

Every request needs a Bearer key. Test keys create sandbox transactions only; live keys create real ones.

Authorization: Bearer ke_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create a transaction

POST /api/v1/transactions
Idempotency-Key: order-1001             # recommended — retries never create duplicates

{
  "seller_amount": 5000,               // KSh the seller receives (50 – 240,000)
  "seller_phone":  "2547XXXXXXXX",     // seller's M-Pesa number
  "buyer_phone":   "2547XXXXXXXX",     // buyer's M-Pesa number (gets the STK push)
  "item_name":     "iPhone 14 Pro",
  "description":   "256GB, sealed",
  "duration_hours": 48                 // 0.5 – 720
}
// 200 OK
{
  "ok": true,
  "data": {
    "id": "G79WBTSD",
    "status": "pending",               // pending → held → completed | disputed | refunded | expired
    "seller_amount": 5000,
    "total": 5420,                     // buyer pays this (includes escrow fee)
    "buyer_link":  "https://kenyaescrow.com/TMR-a1b2c3...",  // secret — release/dispute controls
    "seller_link": "https://kenyaescrow.com/G79WBTSD",       // view-only tracking
    "stk_push": "sent",                // live keys only; "skipped_test" in sandbox
    "test": false
  }
}

Send the buyer_link to your buyer (or embed it in your UI). The buyer sees the status page; the seller gets tracking via seller_link plus automatic SMS & WhatsApp alerts. Buyers/sellers never create accounts.

Code examples

$ch = curl_init('https://kenyaescrow.com/api/v1/transactions');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: order-1001',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'seller_amount'  => 5000,
        'seller_phone'   => '2547XXXXXXXX',
        'buyer_phone'    => '2547XXXXXXXX',
        'item_name'      => 'iPhone 14 Pro',
        'duration_hours' => 48,
    ]),
]);
$tx = json_decode(curl_exec($ch), true)['data'];
// $tx['buyer_link'] → send to your buyer
const res = await fetch('https://kenyaescrow.com/api/v1/transactions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-1001',
  },
  body: JSON.stringify({
    seller_amount: 5000,
    seller_phone: '2547XXXXXXXX',
    buyer_phone: '2547XXXXXXXX',
    item_name: 'iPhone 14 Pro',
    duration_hours: 48,
  }),
});
const { data: tx } = await res.json();
// tx.buyer_link → send to your buyer
import requests

res = requests.post(
    'https://kenyaescrow.com/api/v1/transactions',
    headers={
        'Authorization': f'Bearer {api_key}',
        'Idempotency-Key': 'order-1001',
    },
    json={
        'seller_amount': 5000,
        'seller_phone': '2547XXXXXXXX',
        'buyer_phone': '2547XXXXXXXX',
        'item_name': 'iPhone 14 Pro',
        'duration_hours': 48,
    },
)
tx = res.json()['data']
# tx['buyer_link'] → send to your buyer
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://kenyaescrow.com/api/v1/transactions"))
    .header("Authorization", "Bearer " + apiKey)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "order-1001")
    .POST(HttpRequest.BodyPublishers.ofString("""
        {"seller_amount":5000,"seller_phone":"2547XXXXXXXX",
         "buyer_phone":"2547XXXXXXXX","item_name":"iPhone 14 Pro",
         "duration_hours":48}"""))
    .build();
String json = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
// parse json → data.buyer_link → send to your buyer
curl -X POST https://kenyaescrow.com/api/v1/transactions \
  -H "Authorization: Bearer ke_test_xxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{"seller_amount":5000,"seller_phone":"2547XXXXXXXX",
       "buyer_phone":"2547XXXXXXXX","item_name":"iPhone 14 Pro",
       "duration_hours":48}'

Idempotency

Always send a unique Idempotency-Key per order (e.g. your internal order ID). If a retry arrives with the same key, we return the original transaction — a second order is never created. Replays carry the X-Idempotent-Replay: true header. Keys are scoped to your account and never expire. If you don't send one, identical request bodies within a ~5-minute window are still deduplicated automatically.

Read transactions

GET /api/v1/transactions/{id}                          // single
GET /api/v1/transactions?page=1&per_page=20&status=Pending   // your transactions

Status values

pendingCreated, awaiting buyer payment
heldBuyer paid — funds held in escrow
completedBuyer released — seller paid out via B2C
disputedDispute raised — under admin review
refundedReturned to buyer
expiredDeadline reached before release

Test mode

Flip a sandbox transaction to paid without touching M-Pesa — fires a real signed webhook so you can test your handler:

POST /api/v1/test/transactions/{id}/simulate-payment   // ke_test_ keys only

Test transactions show a TEST MODE banner on their escrow page, get TEST- receipts, and skip SMS/WhatsApp/B2C.

Webhooks

Set your endpoint on the dashboard. Every status change POSTs signed JSON:

POST https://yoursite.co.ke/ke-webhook
X-KE-Event: transaction.paid
X-KE-Signature: t=1789846011,v1=a15796da…

{"event":"transaction.paid","transaction_id":"AP9XUEQJ","test":true,
 "timestamp":1789846011,"data":{"status":"Pending","seller_amount":2000,
 "total":2180,"buyer_link":"…","paid_at":"…"}}

Verify the signature

$raw = file_get_contents('php://input');
[$ts, $sig] = explode(',', $_SERVER['HTTP_X_KE_SIGNATURE']);
$sig  = substr($sig, 3);                          // strip "v1="
$ts   = substr($ts, 2);                           // strip "t="
$calc = hash_hmac('sha256', "$ts.$raw", $secret);
if (!hash_equals($calc, $sig)) { http_response_code(401); exit; }
$event = json_decode($raw, true);
import crypto from 'crypto';

// inside your POST handler — `rawBody` = raw request body string
const [tsPart, sigPart] = req.headers['x-ke-signature'].split(',');
const ts  = tsPart.slice(2);
const sig = sigPart.slice(3);
const calc = crypto.createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(calc), Buffer.from(sig))) {
    return res.status(401).end();
}
const event = JSON.parse(rawBody);
import hmac, hashlib

# inside your POST handler — raw_body = raw request body bytes
ts, sig = request.headers['X-KE-Signature'].split(',')
ts, sig = ts[2:], sig[3:]
calc = hmac.new(secret.encode(), f'{ts}.'.encode() + raw_body,
                hashlib.sha256).hexdigest()
if not hmac.compare_digest(calc, sig):
    return '', 401
event = json.loads(raw_body)
// inside your POST handler — rawBody = raw request body string
String[] parts = request.getHeader("X-KE-Signature").split(",");
String ts = parts[0].substring(2), sig = parts[1].substring(3);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
byte[] raw = mac.doFinal((ts + "." + rawBody).getBytes());
String calc = HexFormat.of().formatHex(raw);
if (!MessageDigest.isEqual(calc.getBytes(), sig.getBytes())) {
    response.setStatus(401); return;
}

Events: transaction.paid · transaction.released · transaction.disputed · transaction.refunded · transaction.expired

Errors & limits

401Missing/invalid key
403Wrong key type (e.g. live key on a test-only endpoint)
422Validation — check error.code and error.message
429Rate limit — 30/min test, 60/min live. Respect Retry-After

Limits: seller receives KSh 50 – 240,000 per transaction; buyer total stays within the KSh 250,000 M-Pesa cap. Fees are identical to the public site.