Daraja API (Safaricom M-Pesa) + Claude Code Integration Guide
How M-Pesa STK Push works
Async STK Push → idempotent callback. Get the non-negotiables right and the rest is plumbing.
The non-negotiables: phone format is `254XXXXXXXXX` (no `+`, no leading `0`); amount is an integer in KES (no decimals — `100` not `100.00`); the access token expires every hour, so refresh-on-failure or refresh-on-startup; the callback URL must be HTTPS and reachable from Safaricom's egress (ngrok or a deployed preview for local dev); ACK callbacks fast — if you don't, Safaricom retries after ~10s, and you'll process the same payment twice unless you dedupe on `CheckoutRequestID`. Store the `CheckoutRequestID` immediately when STK Push returns, before the callback can land. Sandbox shortcode is 174379, test phone 254708374149. Offload anything slow (SMS receipts, downstream API calls) to a queue (Upstash QStash, etc.) so the callback handler returns in <1s.
The five-step lifecycle
Each call has its own failure mode. Tap a card for the gotchas that show up in production.
Walk the happy and unhappy paths
Press ▶ Start payment and the simulator walks through the same lifecycle a real STK Push takes. When the customer is deciding, branch the flow — confirm, cancel, or simply walk away — and the terminal note shows what your server should do for that exact ResultCode.
- Fetch tokenPOST /oauth/v1/generate
- STK PushPOST /mpesa/stkpush/v1/processrequest
- Customer decidesPhone prompt → PIN
- CallbackPOST → your /api/mpesa/callback
- ReconcileDedupe + DB write + ACK 200
The biggest production trap isn't “cancelled” — it's the third button. When no callback arrives, you cannot assume failure: the customer may have paid. Always reconcile with stkpushquery before refunding.
██████╗ █████╗ ██████╗ █████╗ ██╗ █████╗
██╔══██╗██╔══██╗██╔══██╗██╔══██╗ ██║██╔══██╗
██║ ██║███████║██████╔╝███████║ ██║███████║
██║ ██║██╔══██║██╔══██╗██╔══██║██ ██║██╔══██║
██████╔╝██║ ██║██║ ██║██║ ██║╚█████╔╝██║ ██║
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚════╝ ╚═╝ ╚═╝Daraja API (Safaricom M-Pesa) + Claude Code Integration Guide
Focus: Integrating M-Pesa mobile money payments into projects from Claude Code — STK Push, C2B, B2C, and webhook handling — using the Safaricom Daraja API.
Overview
Daraja is Safaricom's developer platform for M-Pesa, Kenya's leading mobile money network. It provides REST APIs for sending payment prompts (STK Push / Lipa na M-Pesa Online), business-to-customer transfers (B2C), customer-to-business collection (C2B), account balance queries, and transaction status checks. Claude Code can scaffold, test, and automate M-Pesa payment integrations — including sandbox testing, OAuth token management, and webhook verification.
Here is the big picture at a glance — you have got this once you see how the pieces connect:
Official Documentation
| Resource | URL |
|---|---|
| Daraja Developer Portal | https://developer.safaricom.co.ke |
| API Documentation | https://developer.safaricom.co.ke/APIs |
| Sandbox Environment | https://developer.safaricom.co.ke/test_credentials |
| STK Push Guide | https://developer.safaricom.co.ke/Documentation |
| C2B Register URL API | https://developer.safaricom.co.ke/c2b/apis/post/registerurl |
| Community Docs | https://daraja-docs.vercel.app |
Authentication
Daraja uses OAuth2 client credentials flow. Every API call requires a Bearer token obtained by encoding your Consumer Key and Secret as Base64.
Get a Token
// lib/mpesa-auth.ts
export async function getMpesaToken(): Promise<string> {
const credentials = Buffer.from(
`${process.env.MPESA_CONSUMER_KEY}:${process.env.MPESA_CONSUMER_SECRET}`
).toString("base64");
const url =
process.env.MPESA_ENV === "production"
? "https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials"
: "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials";
const res = await fetch(url, {
headers: { Authorization: `Basic ${credentials}` },
});
const data = await res.json();
if (!data.access_token) throw new Error("Failed to get M-Pesa token");
return data.access_token;
}cURL Token Request
# Sandbox token
TOKEN=$(curl -s "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials" \
-u "$MPESA_CONSUMER_KEY:$MPESA_CONSUMER_SECRET" | jq -r '.access_token')
echo "Token: $TOKEN"Core API Integration
STK Push (Lipa na M-Pesa Online / Customer-Initiated Payment)
STK Push sends a payment prompt directly to the customer's phone.
Follow the full lifecycle below — each step is straightforward once you trace the order:
// lib/mpesa-stk.ts
import { getMpesaToken } from "./mpesa-auth";
function generatePassword(shortcode: string, passkey: string, timestamp: string): string {
return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString("base64");
}
export async function stkPush({
phone,
amount,
accountReference,
transactionDesc,
}: {
phone: string; // Format: 254XXXXXXXXX
amount: number; // Amount in KES (integer)
accountReference: string;
transactionDesc: string;
}) {
const token = await getMpesaToken();
const timestamp = new Date()
.toISOString()
.replace(/[^0-9]/g, "")
.slice(0, 14); // YYYYMMDDHHmmss
const shortcode = process.env.MPESA_SHORTCODE!;
const passkey = process.env.MPESA_PASSKEY!;
const password = generatePassword(shortcode, passkey, timestamp);
const baseUrl =
process.env.MPESA_ENV === "production"
? "https://api.safaricom.co.ke"
: "https://sandbox.safaricom.co.ke";
const res = await fetch(`${baseUrl}/mpesa/stkpush/v1/processrequest`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
BusinessShortCode: shortcode,
Password: password,
Timestamp: timestamp,
TransactionType: "CustomerPayBillOnline",
Amount: amount,
PartyA: phone,
PartyB: shortcode,
PhoneNumber: phone,
CallBackURL: `${process.env.APP_URL}/api/mpesa/callback`,
AccountReference: accountReference,
TransactionDesc: transactionDesc,
}),
});
return res.json();
}STK Push Status Query
export async function checkStkStatus(checkoutRequestId: string) {
const token = await getMpesaToken();
const timestamp = new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14);
const password = generatePassword(
process.env.MPESA_SHORTCODE!,
process.env.MPESA_PASSKEY!,
timestamp
);
const baseUrl =
process.env.MPESA_ENV === "production"
? "https://api.safaricom.co.ke"
: "https://sandbox.safaricom.co.ke";
const res = await fetch(`${baseUrl}/mpesa/stkpushquery/v1/query`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
BusinessShortCode: process.env.MPESA_SHORTCODE,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestId,
}),
});
return res.json();
}Webhook Handler (Callback URL)
// app/api/mpesa/callback/route.ts (Next.js App Router)
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const body = await req.json();
const result = body.Body?.stkCallback;
if (!result) return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
const { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc, CallbackMetadata } = result;
if (ResultCode === 0) {
// Payment successful
const items = CallbackMetadata?.Item ?? [];
const amount = items.find((i: any) => i.Name === "Amount")?.Value;
const mpesaCode = items.find((i: any) => i.Name === "MpesaReceiptNumber")?.Value;
const phone = items.find((i: any) => i.Name === "PhoneNumber")?.Value;
// Save to database
await db.payments.create({
data: {
checkoutRequestId: CheckoutRequestID,
merchantRequestId: MerchantRequestID,
mpesaCode,
phone: String(phone),
amount: Number(amount),
status: "success",
},
});
console.log(`Payment success: ${mpesaCode} — KES ${amount} from ${phone}`);
} else {
// Payment failed or cancelled
console.log(`Payment failed: ${ResultDesc} (code: ${ResultCode})`);
await db.payments.updateMany({
where: { checkoutRequestId: CheckoutRequestID },
data: { status: "failed", failureReason: ResultDesc },
});
}
return NextResponse.json({ ResultCode: 0, ResultDesc: "Success" });
}B2C (Business to Customer Payment)
export async function b2cPayment({
phone,
amount,
remarks,
}: {
phone: string;
amount: number;
remarks: string;
}) {
const token = await getMpesaToken();
const res = await fetch("https://sandbox.safaricom.co.ke/mpesa/b2c/v3/paymentrequest", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
OriginatorConversationID: `B2C-${Date.now()}`,
InitiatorName: process.env.MPESA_INITIATOR_NAME,
SecurityCredential: process.env.MPESA_SECURITY_CREDENTIAL,
CommandID: "BusinessPayment",
Amount: amount,
PartyA: process.env.MPESA_SHORTCODE,
PartyB: phone,
Remarks: remarks,
QueueTimeOutURL: `${process.env.APP_URL}/api/mpesa/b2c/timeout`,
ResultURL: `${process.env.APP_URL}/api/mpesa/b2c/result`,
}),
});
return res.json();
}C2B — Register URL, validation & confirmation
C2B (Customer to Business) is for payments the customer initiates themselves — paying your Paybill or Till from the M-Pesa menu or SIM toolkit, without you triggering an STK Push. Before M-Pesa will forward those payments to you, you must register two callback URLs for your shortcode: a Validation URL (called before the money moves — you can accept or reject) and a Confirmation URL (called after the money has moved — record-keeping only).
Trace the flow once and it clicks — registration is a one-time setup, the callbacks fire on every payment:
Register the URLs (one-time per shortcode)
// lib/mpesa-c2b.ts
import { getMpesaToken } from "./mpesa-auth";
export async function registerC2BUrls() {
const token = await getMpesaToken();
// NOTE: sandbox uses v1; production C2B Register URL should use v2.
const baseUrl =
process.env.MPESA_ENV === "production"
? "https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl"
: "https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl";
const res = await fetch(baseUrl, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
ShortCode: process.env.MPESA_SHORTCODE,
ResponseType: "Completed", // "Completed" | "Cancelled" — fallback if ValidationURL is unreachable
ConfirmationURL: `${process.env.APP_URL}/api/mpesa/c2b/confirmation`,
ValidationURL: `${process.env.APP_URL}/api/mpesa/c2b/validation`,
}),
});
return res.json(); // { OriginatorCoversationID, ConversationID, ResponseDescription }
}Confirmation callback handler
Confirmation fires after the payment has cleared — you cannot reject here. Record it idempotently (deduplicate on TransID, the M-Pesa receipt) and always return a success ack so Daraja stops retrying.
// app/api/mpesa/c2b/confirmation/route.ts (Next.js App Router)
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const body = await req.json();
// Example payload:
// {
// TransactionType: "Pay Bill",
// TransID: "UCB030CBG1", // M-Pesa receipt — use as idempotency key
// TransTime: "20260311161727", // YYYYMMDDHHmmss
// TransAmount: "1.00",
// BusinessShortCode: "600991",
// BillRefNumber: "account001", // account number the customer typed
// InvoiceNumber: "",
// OrgAccountBalance: "4635316.60",
// ThirdPartyTransID: "",
// MSISDN: "2547...", // payer phone (masked in sandbox)
// FirstName: "John",
// MiddleName: "",
// LastName: ""
// }
await db.payments.upsert({
where: { mpesaCode: body.TransID }, // idempotent on the receipt number
update: {},
create: {
mpesaCode: body.TransID,
phone: String(body.MSISDN),
amount: Number(body.TransAmount),
accountRef: body.BillRefNumber,
status: "success",
source: "c2b",
},
});
// Always ack — non-2xx makes Daraja retry the confirmation.
return NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
}The Validation URL (if you accept it) receives the same payload shape before the debit; reply { "ResultCode": 0, "ResultDesc": "Accepted" } to allow, or a rejection code (e.g. { "ResultCode": "C2B00012", "ResultDesc": "Rejected" }) to block.
Gotcha — validation requires opt-in, and
ResponseTypeis your safety net. External (non-STK) validation is not on by default: Safaricom must enable "External Validation" for your shortcode before yourValidationURLis ever called — until then only the Confirmation fires. TheResponseTypeyou register decides what happens when validation is enabled but your endpoint is unreachable or times out:"Completed"tells M-Pesa to auto-complete the payment (safest for collections — you never lose money to a flaky webhook), while"Cancelled"tells it to auto-reject. Start with"Completed"unless you genuinely need to refuse payments in-flight.
Production endpoint note: the C2B Register URL is v2 in production (
/mpesa/c2b/v2/registerurl); the v1 path that appears in some Safaricom go-live emails will not work live. Sandbox still uses v1.
Environment Variables
# Required
MPESA_CONSUMER_KEY=... # From Daraja app → Consumer Key
MPESA_CONSUMER_SECRET=... # From Daraja app → Consumer Secret
MPESA_SHORTCODE=174379 # Paybill or Till number (174379 for sandbox)
MPESA_PASSKEY=... # From Daraja app → Lipa na Mpesa → Passkey
# Your app URL (for callbacks — must be HTTPS in production)
APP_URL=https://yourapp.com
# Environment toggle
MPESA_ENV=sandbox # or "production"
# B2C (if using business payments)
MPESA_INITIATOR_NAME=... # API operator username
MPESA_SECURITY_CREDENTIAL=... # Encrypted passwordSandbox Test Credentials
| Field | Value |
|---|---|
| Shortcode | 174379 |
| Test Phone | 254708374149 |
| Passkey | Available in Daraja sandbox dashboard |
Automation Workflows
Claude Code Slash Command: Test STK Push
.claude/commands/mpesa-test.md:
Test an M-Pesa STK Push payment to the sandbox phone number.
Use Bash to run the test:
```bash
curl -s -X POST https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest \
-H "Authorization: Bearer $(node scripts/get-mpesa-token.js)" \
-H "Content-Type: application/json" \
-d @scripts/stk-test-payload.json | jq .Report the CheckoutRequestID and whether the request was accepted. Then check if the callback was received at /api/mpesa/callback by checking application logs.
Usage: `/project:mpesa-test`
### GitHub Actions: Sandbox Integration Tests
```yaml
# .github/workflows/mpesa-tests.yml
name: M-Pesa Sandbox Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: npm ci
- name: Run M-Pesa integration tests
env:
MPESA_CONSUMER_KEY: ${{ secrets.MPESA_SANDBOX_CONSUMER_KEY }}
MPESA_CONSUMER_SECRET: ${{ secrets.MPESA_SANDBOX_CONSUMER_SECRET }}
MPESA_SHORTCODE: "174379"
MPESA_ENV: sandbox
APP_URL: https://webhook.site/unique-id # test webhook receiver
run: npm run test:mpesaCommon Use Cases
| Use Case | Approach |
|---|---|
| Customer payment | STK Push → await callback |
| Subscription renewal | Scheduled STK Push via cron |
| Refund / payout | B2C PaymentRequest |
| Merchant collection | C2B Register URL + simulate |
| Payment status | STK Push Query API |
| Account balance | AccountBalance API |
Troubleshooting
| Issue | Fix |
|---|---|
Invalid Access Token | Token expires after 1 hour — regenerate before each request |
CallbackURL unreachable | Must be HTTPS; use ngrok for local development: ngrok http 3000 |
Invalid PhoneNumber | Must be format 254XXXXXXXXX (no leading 0 or +) |
ResultCode: 1 in callback | Customer cancelled or insufficient funds |
The initiator information is invalid | B2C initiator name/credential mismatch |
| Sandbox STK not received | Use the sandbox test phone 254708374149 |
Local Webhook Testing with ngrok
# Install ngrok: https://ngrok.com
ngrok http 3000
# Copy the HTTPS URL and set it as your callback:
# APP_URL=https://xxxx.ngrok.io
# Then: CALLBACK_URL=$APP_URL/api/mpesa/callback