FREE TO START

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

  1. You call POST /v1/initatestk with an amount and a phone number.
  2. The customer gets an M-PESA PIN prompt on their handset.
  3. Safaricom tells us the outcome, we settle it to your paybill/till/bank.
  4. We POST a signed webhook to your server — and you can poll /v1/tsatus any time.
Every response has the same shape { "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..."}'

Authentication

Two credentials, two jobs. Do not mix them up.

CredentialWhere it comes fromWhat it unlocks
Bearer usr_…/v1/register or /v1/loginYour profile and payment accounts — dashboard work. Expires after 30 days.
api_key + api_secret/v1/accounts/createTaking 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_...", ... }
The api_secret is shown exactly once We store only a hash of it, so we cannot email it to you later. Lost it? Rotate it — the old one dies instantly. Keep it server-side; never ship it in a mobile app or browser bundle.

Create an account

POST /v1/register no auth
FieldNotes
namerequiredYour name or business name.
emailrequiredMust be unique. This is your login.
phonerequiredKenyan mobile. 0712345678, 254712… and +254712… are all accepted.
passwordrequiredAt 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

POST /v1/login no auth

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

GET /v1/me Bearer usr_…

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

POST /v1/accounts/create Bearer usr_…

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.

// 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

GET /v1/accounts/list Bearer usr_…

Every payment account you own, with per-account request/success/collected counters. Secrets are never included.

Update an account

POST /v1/accounts/update Bearer usr_…

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

POST /v1/accounts/rotate-secret Bearer usr_…

Send account_id and your login password. Returns a new api_secret; the api_key stays the same.

The old secret stops working immediately Deploy the new value first, then rotate — and remember your webhook signing key changes with it.

Supported banks

GET /v1/banks public

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

POST /v1/initatestk api_key + api_secret

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.

FieldNotes
amountrequiredKES 1–250,000. Commas and decimals are accepted and rounded to whole shillings ("1,500.60"1501).
msisdnrequiredThe customer's phone. Any Kenyan format.
referencerequiredYour order/invoice id. Comes back on every status check and webhook.
account_numberoptionalPaybill accounts only — overrides the stored account reference for this payment.
descriptionoptionalShown on the handset. Safaricom truncates to 13 characters.
callback_urloptionalOverrides 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…"
  }
}
Retries are safe Repeat the same 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

POST /v1/tsatus api_key + api_secret

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"
  }
}
Don't build on polling alone Use the webhook as your source of truth and poll only to reconcile. A tight polling loop will hit the rate limit long before it beats the webhook.

All transactions

GET /v1/transactions api keys or Bearer usr_…

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.

FilterExample
statuscompleted, pending, failed, cancelled, timeout
from / to2026-08-012026-08-31 (a bare date includes the whole day)
msisdn0712345678
referenceINV-001
mpesa_receiptSGH7XY12AB
limit / pageup 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';
Make your handler idempotent A delivery can arrive more than once (retries, reconciliation). Key your bookkeeping on 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.

CodeStatusWhat actually happened
0completedPaid. mpesa_receipt is your proof.
1failedNot enough money in the customer's M-Pesa.
1032cancelledCustomer dismissed the prompt.
1037timeoutPrompt was never answered, or the phone was unreachable.
2001failedWrong M-Pesa PIN.
1001failedAnother transaction is already in flight on that number.
1019failedTransaction expired.
1025 / 9999failedSafaricom could not raise the prompt. Retry.

Error codes

These are ours, in the top-level code field.

codeHTTPMeaning
102422Validation failed — read errors for the field names.
401401Bad or missing credentials.
400403Account inactive, suspended, or missing a destination.
404404No such transaction or account.
405405Wrong HTTP method.
409409Email or phone already registered.
429429Rate limited — see the Retry-After header.
503503Safaricom rejected the push or was unreachable. Safe to retry.
500500Our fault. Retry, then tell us.

Rate limits

EndpointLimitCounted per
/v1/initatestk120 / minuteapi_key
/v1/tsatus240 / minuteapi_key
/v1/register, /v1/login10 / minuteIP address

Over the limit you get 429 with a Retry-After header. Back off for that many seconds — don't hammer.

Go-live checklist

  1. Store api_secret in server-side config or an environment variable — never in client code or version control.
  2. Serve your callback_url over HTTPS and verify the signature on every delivery.
  3. Make the webhook handler idempotent, keyed on transaction_id.
  4. Persist transaction_id against your order the moment the push returns — before the customer even sees the prompt.
  5. Reconcile daily with GET /v1/transactions?status=completed&from=… and match against your own records.
  6. Treat 503 as retryable, 102 as 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.