Collect M-Pesa payments in one API call
Sign up, tell us where the money should land — your paybill, till or bank account — and start pushing STK prompts. No Daraja app, no Safaricom paperwork, no go-live wait. We hold the Daraja credentials; Safaricom settles straight into your own shortcode.
# Base URL
https://api.airtimedeal.co.ke/v1
How it works
- You call
POST /v1/initatestkwith an amount and a phone number. - The customer gets an M-PESA PIN prompt on their handset.
- Safaricom tells us the outcome, we settle it to your paybill/till/bank.
- We POST a signed webhook to your server — and you can poll
/v1/tsatusany time.
{ "success": true|false, "code": "0", "message": "...", "data": { ... } } —
check success first, then read data. Errors add an errors object naming the fields at fault.
Quickstart
Four calls from zero to a payment prompt on a real phone.
# 1. create your free account curl -X POST https://api.airtimedeal.co.ke/v1/register \ -H 'Content-Type: application/json' \ -d '{"name":"Jane Doe","email":"jane@shop.co.ke","phone":"0712345678","password":"supersecret"}' # 2. add where the money should land -> returns api_key + api_secret curl -X POST https://api.airtimedeal.co.ke/v1/accounts/create \ -H 'Authorization: Bearer usr_YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{"account_name":"My Shop","type":"till","till_number":"3208403","callback_url":"https://shop.co.ke/mpesa-webhook"}' # 3. charge a customer curl -X POST https://api.airtimedeal.co.ke/v1/initatestk \ -H 'X-API-Key: pk_live_...' -H 'X-API-Secret: sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"amount":100,"msisdn":"0712345678","reference":"INV-001"}' # 4. check on it (webhooks are faster — this is for reconciliation) curl -X POST https://api.airtimedeal.co.ke/v1/tsatus \ -H 'X-API-Key: pk_live_...' -H 'X-API-Secret: sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"transaction_id":"ADX20260812T..."}'
<?php function adx(string $path, array $body, array $headers = []): array { $ch = curl_init('https://api.airtimedeal.co.ke/v1/' . $path); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers), ]); $res = curl_exec($ch); curl_close($ch); return json_decode($res, true) ?: []; } $auth = ['X-API-Key: pk_live_...', 'X-API-Secret: sk_live_...']; $push = adx('initatestk', [ 'amount' => 100, 'msisdn' => '0712345678', 'reference' => 'INV-001', ], $auth); if (!$push['success']) { throw new RuntimeException($push['message']); } $transactionId = $push['data']['transaction_id']; // store this against your order
const BASE = 'https://api.airtimedeal.co.ke/v1'; const auth = { 'Content-Type': 'application/json', 'X-API-Key': process.env.ADX_KEY, 'X-API-Secret': process.env.ADX_SECRET, }; async function charge(amount, msisdn, reference) { const res = await fetch(`${BASE}/initatestk`, { method: 'POST', headers: auth, body: JSON.stringify({ amount, msisdn, reference }), }); const json = await res.json(); if (!json.success) throw new Error(json.message); return json.data.transaction_id; // store against your order } await charge(100, '0712345678', 'INV-001');
import os, requests BASE = 'https://api.airtimedeal.co.ke/v1' AUTH = { 'X-API-Key': os.environ['ADX_KEY'], 'X-API-Secret': os.environ['ADX_SECRET'], } def charge(amount, msisdn, reference): r = requests.post( f'{BASE}/initatestk', json={'amount': amount, 'msisdn': msisdn, 'reference': reference}, headers=AUTH, timeout=30, ) data = r.json() if not data['success']: raise RuntimeError(data['message']) return data['data']['transaction_id'] charge(100, '0712345678', 'INV-001')
Authentication
Two credentials, two jobs. Do not mix them up.
| Credential | Where it comes from | What it unlocks |
|---|---|---|
Bearer usr_… | /v1/register or /v1/login | Your profile and payment accounts — dashboard work. Expires after 30 days. |
api_key + api_secret | /v1/accounts/create | Taking payments: STK push, status, transactions. No expiry. |
Sending API credentials
Any of these three work — pick whichever your stack makes easiest.
# headers (recommended) X-API-Key: pk_live_... X-API-Secret: sk_live_... # HTTP Basic Authorization: Basic base64(api_key:api_secret) # or in the JSON body { "api_key": "pk_live_...", "api_secret": "sk_live_...", ... }
Create an account
| Field | Notes | |
|---|---|---|
| name | required | Your name or business name. |
| required | Must be unique. This is your login. | |
| phone | required | Kenyan mobile. 0712345678, 254712… and +254712… are all accepted. |
| password | required | At least 8 characters. |
// 201 Created { "success": true, "code": "0", "message": "Account created successfully.", "data": { "user": { "id": 1, "name": "Jane Doe", "email": "jane@shop.co.ke", "phone": "254712345678" }, "token": "usr_48bd6f0a99097d29…", "expires_at": "2026-09-11 22:08:11" } }
Log in
Send email and password. Returns the same token shape as register, plus every payment account you own. Sign out with POST /v1/logout (add {"all":true} to end every session).
Profile & totals
Your profile, all payment accounts, and lifetime totals in one round trip — everything a dashboard needs on first paint.
Add a paybill, till or bank account
This is where you say where the money lands. Each account gets its own key pair, so you can run several products or clients from one login. The type can't be changed later — create another account instead.
{
"account_name": "My Shop",
"type": "till",
"till_number": "3208403", // your Buy Goods till
"callback_url": "https://shop.co.ke/mpesa-webhook"
}
Pushes go out as CustomerBuyGoodsOnline. Your reference is used as the account reference.
{
"account_name": "My Shop",
"type": "paybill",
"paybill_number": "4152971",
"account_number": "SHOP01", // optional fixed account ref
"callback_url": "https://shop.co.ke/mpesa-webhook"
}
Pushes go out as CustomerPayBillOnline. The account reference is resolved in this order: the account_number you send on the payment → this stored account_number → the payment's reference.
{
"account_name": "Equity Settlement",
"type": "bank",
"bank_name": "Equity", // or "bank_id": 1 — see GET /v1/banks
"bank_account": "0170123456789",
"callback_url": "https://shop.co.ke/mpesa-webhook"
}
Money goes to the bank's M-Pesa paybill with your account number as the reference, landing directly in your bank account. No till or paybill of your own needed.
// 201 Created — api_secret appears here and nowhere else, ever { "success": true, "data": { "account": { "id": 1, "account_name": "My Shop", "type": "till", "api_key": "pk_live_24caec5a8126d4da0cf4…", "api_secret": "sk_live_867177dc98e980409575…", "destination": { "till_number": "3208403" }, "status": "active" } } }
List your accounts
Every payment account you own, with per-account request/success/collected counters. Secrets are never included.
Update an account
Send account_id plus only the fields you want changed: account_name, callback_url, status (active/inactive), or the destination fields that belong to this account's type.
Rotate the API secret
Send account_id and your login password. Returns a new api_secret; the api_key stays the same.
Supported banks
Bank names, ids and the paybill M-Pesa uses to deposit into each one. Cache it — it rarely changes.
{ "data": { "count": 35, "banks": [
{ "id": 1, "name": "Equity Bank", "short_name": "Equity", "paybill": "247247" },
{ "id": 2, "name": "KCB Bank", "short_name": "KCB", "paybill": "522522" }
] } }
Initiate an STK push
Puts an M-PESA PIN prompt on the customer's phone. Returns as soon as Safaricom accepts the request — the actual payment result arrives later by webhook.
| Field | Notes | |
|---|---|---|
| amount | required | KES 1–250,000. Commas and decimals are accepted and rounded to whole shillings ("1,500.60" → 1501). |
| msisdn | required | The customer's phone. Any Kenyan format. |
| reference | required | Your order/invoice id. Comes back on every status check and webhook. |
| account_number | optional | Paybill accounts only — overrides the stored account reference for this payment. |
| description | optional | Shown on the handset. Safaricom truncates to 13 characters. |
| callback_url | optional | Overrides the account's webhook URL for this payment only. |
// 200 OK { "success": true, "message": "Payment prompt sent. Ask the customer to enter their M-PESA PIN.", "data": { "transaction_id": "ADX202608122208473F2FEF", // store this "checkout_request_id": "ws_CO_12082026220847001", "merchant_request_id": "29115-34620561-1", "status": "pending", "amount": 100, "msisdn": "254712345678", "customer_message": "Enter your M-PESA PIN on your phone…" } }
amount + msisdn + reference within 90 seconds and you get the in-flight transaction back with "duplicate": true instead of a second prompt on the customer's phone.
Check a payment
Send transaction_id (or checkout_request_id). If the payment is still pending and the prompt has expired, we ask Safaricom directly rather than reporting a stale answer.
{
"success": true,
"data": {
"transaction_id": "ADX202608122208473F2FEF",
"status": "completed", // pending | completed | failed | cancelled | timeout
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"amount": 1500,
"paid_amount": 1500,
"mpesa_receipt": "SGH7XY12AB",
"msisdn": "254712345678",
"reference": "INV-2001",
"completed_at": "2026-08-12 22:09:08"
}
}
All transactions
With API credentials you see that one account. With a user token you see every account you own — add account_id to narrow it down.
| Filter | Example |
|---|---|
| status | completed, pending, failed, cancelled, timeout |
| from / to | 2026-08-01 … 2026-08-31 (a bare date includes the whole day) |
| msisdn | 0712345678 |
| reference | INV-001 |
| mpesa_receipt | SGH7XY12AB |
| limit / page | up to 200 per page |
GET /v1/transactions?status=completed&from=2026-08-01&limit=50
{ "data": {
"summary": { "total": 312, "completed": 287, "pending": 2,
"unsuccessful": 23, "collected": 148300, "currency": "KES" },
"pagination": { "page": 1, "limit": 50, "pages": 7, "has_more": true },
"transactions": [ … ]
} }
Webhooks
When a payment settles we POST JSON to your callback_url. Respond 200 quickly — anything else is a failure and we retry with backoff: 1m, 2m, 5m, 15m, 30m, 1h, 3h, 6h (8 attempts, then we stop).
// POST https://shop.co.ke/mpesa-webhook { "event": "payment.completed", // .failed .cancelled .timeout "transaction_id": "ADXTEST0001", "reference": "INV-2001", "status": "completed", "result_code": 0, "result_desc": "The service request is processed successfully.", "amount": 1500, "paid_amount": 1500, "currency": "KES", "msisdn": "254712345678", "mpesa_receipt": "SGH7XY12AB", "checkout_request_id": "ws_CO_191220191020363925", "destination": { "type": "till", "shortcode": "3208403", "account": "INV-2001" }, "completed_at": "2026-08-12 22:09:08" }
Verify the signature
Every delivery carries X-AirtimeDeal-Signature: t=<unix>,v1=<hmac>. The HMAC is SHA-256 over "<t>.<raw body>", keyed with the SHA-256 hex digest of your api_secret. Verify against the raw body — re-encoding decoded JSON can change bytes and break the check.
<?php $raw = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_AIRTIMEDEAL_SIGNATURE'] ?? ''; if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) { http_response_code(400); exit; } $key = hash('sha256', ADX_API_SECRET); // your api_secret $expected = hash_hmac('sha256', $m[1] . '.' . $raw, $key); if (!hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) { http_response_code(401); exit; // forged or replayed } $event = json_decode($raw, true); if ($event['status'] === 'completed') { // mark the order paid — key off transaction_id so replays are harmless mark_order_paid($event['reference'], $event['mpesa_receipt']); } http_response_code(200); echo 'OK';
import crypto from 'crypto'; // mount with a RAW body parser: express.raw({ type: 'application/json' }) app.post('/mpesa-webhook', (req, res) => { const header = req.get('X-AirtimeDeal-Signature') || ''; const m = header.match(/t=(\d+),v1=([a-f0-9]+)/); if (!m) return res.sendStatus(400); const key = crypto.createHash('sha256').update(process.env.ADX_SECRET).digest('hex'); const expected = crypto.createHmac('sha256', key) .update(`${m[1]}.${req.body}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]))) { return res.sendStatus(401); } const event = JSON.parse(req.body); if (event.status === 'completed') markOrderPaid(event.reference, event.mpesa_receipt); res.sendStatus(200); });
import hashlib, hmac, re, os from flask import request @app.post('/mpesa-webhook') def webhook(): raw = request.get_data() m = re.match(r't=(\d+),v1=([a-f0-9]+)', request.headers.get('X-AirtimeDeal-Signature', '')) if not m: return '', 400 key = hashlib.sha256(os.environ['ADX_SECRET'].encode()).hexdigest() expected = hmac.new(key.encode(), f"{m[1]}.".encode() + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, m[2]): return '', 401 event = request.get_json() if event['status'] == 'completed': mark_order_paid(event['reference'], event['mpesa_receipt']) return 'OK', 200
transaction_id and ignore a payment you have already recorded.
M-Pesa result codes
result_code comes straight from Safaricom and maps to our status like this.
| Code | Status | What actually happened |
|---|---|---|
| 0 | completed | Paid. mpesa_receipt is your proof. |
| 1 | failed | Not enough money in the customer's M-Pesa. |
| 1032 | cancelled | Customer dismissed the prompt. |
| 1037 | timeout | Prompt was never answered, or the phone was unreachable. |
| 2001 | failed | Wrong M-Pesa PIN. |
| 1001 | failed | Another transaction is already in flight on that number. |
| 1019 | failed | Transaction expired. |
| 1025 / 9999 | failed | Safaricom could not raise the prompt. Retry. |
Error codes
These are ours, in the top-level code field.
| code | HTTP | Meaning |
|---|---|---|
| 102 | 422 | Validation failed — read errors for the field names. |
| 401 | 401 | Bad or missing credentials. |
| 400 | 403 | Account inactive, suspended, or missing a destination. |
| 404 | 404 | No such transaction or account. |
| 405 | 405 | Wrong HTTP method. |
| 409 | 409 | Email or phone already registered. |
| 429 | 429 | Rate limited — see the Retry-After header. |
| 503 | 503 | Safaricom rejected the push or was unreachable. Safe to retry. |
| 500 | 500 | Our fault. Retry, then tell us. |
Rate limits
| Endpoint | Limit | Counted per |
|---|---|---|
| /v1/initatestk | 120 / minute | api_key |
| /v1/tsatus | 240 / minute | api_key |
| /v1/register, /v1/login | 10 / minute | IP address |
Over the limit you get 429 with a Retry-After header. Back off for that many seconds — don't hammer.
Go-live checklist
- Store
api_secretin server-side config or an environment variable — never in client code or version control. - Serve your
callback_urlover HTTPS and verify the signature on every delivery. - Make the webhook handler idempotent, keyed on
transaction_id. - Persist
transaction_idagainst your order the moment the push returns — before the customer even sees the prompt. - Reconcile daily with
GET /v1/transactions?status=completed&from=…and match against your own records. - Treat
503as retryable,102as a bug in your request.
Something unclear or not behaving? Tell us what call you made and the transaction_id — that's all we need to trace it.