Daraja API (Safaricom M-Pesa) Integration Guide

Technology: daraja-api · Category: payments · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/daraja-api

Insight:

Daraja is M-Pesa — codeAmani's payment rail for Kenya-targeted projects (Stripe stays the default elsewhere), and there it's the primary rail, not an afterthought. The non-negotiables: phone as 254… (no +), integer KES, 1-hour token refresh, HTTPS callbacks, and idempotency on CheckoutRequestID. Offload slow post-payment work to a queue (Upstash QStash) so the callback returns fast. Since ~Mar 2026, payer numbers are masked — don't build identity on the callback phone.

██████╗  █████╗ ██████╗  █████╗      ██╗ █████╗
██╔══██╗██╔══██╗██╔══██╗██╔══██╗     ██║██╔══██╗
██║  ██║███████║██████╔╝███████║     ██║███████║
██║  ██║██╔══██║██╔══██╗██╔══██║██   ██║██╔══██║
██████╔╝██║  ██║██║  ██║██║  ██║╚█████╔╝██║  ██║
╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚════╝ ╚═╝  ╚═╝

Daraja API (Safaricom M-Pesa) 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), recurring payments / standing orders (the newer Ratiba API), 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.

Portal note (Daraja 3.0, launched Nov 2025). Safaricom rebuilt the developer portal as Daraja 3.0 — fully self-service registration, a redesigned dashboard, and new lowercase URLs. The old capitalised deep links (/APIs, /Documentation, /test_credentials, /c2b/apis/post/registerurl) now 404. The API endpoints themselves are unchanged (STK Push is still …/mpesa/stkpush/v1/processrequest); only the portal navigation moved. Create apps and read sandbox credentials under Dashboard, and browse per-API docs under APIs.

Here is the big picture at a glance — you have got this once you see how the pieces connect:

flowchart TD
  A["Your app"] --> B["Get OAuth token"]
  B --> C["STK Push request"]
  C --> D["Daraja API"]
  D --> E["Customer phone prompt"]
  E --> F["Callback to your webhook"]
  F --> G["Save payment to database"]

Official Documentation

Resource URL
Daraja Developer Portal (Daraja 3.0) https://developer.safaricom.co.ke
API catalogue & per-API docs https://developer.safaricom.co.ke/apis
Dashboard (apps, keys, sandbox credentials) https://developer.safaricom.co.ke/dashboard
FAQs https://developer.safaricom.co.ke/faqs

Individual API pages (M-Pesa Express / STK Push, C2B, B2C, Authorization, Ratiba) live as client-routed pages under /apis in the Daraja 3.0 SPA — reach them from the API catalogue rather than deep-linking, since the old fixed doc URLs were retired in the portal rebuild.


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:

sequenceDiagram
  participant App as "Your server"
  participant Auth as "Daraja OAuth"
  participant Daraja as "Daraja API"
  participant Phone as "Customer phone"
  App->>Auth: Request Bearer token
  Auth-->>App: access_token
  App->>Daraja: STK Push processrequest
  Daraja-->>App: CheckoutRequestID
  Daraja->>Phone: Payment prompt
  Phone-->>Daraja: Enter M-Pesa PIN
  Daraja->>App: Callback with ResultCode
  App->>App: Reconcile and save payment
// 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:

sequenceDiagram
  participant App as "Your server"
  participant Daraja as "Daraja API"
  participant Customer as "Customer"
  App->>Daraja: RegisterURL · ValidationURL + ConfirmationURL
  Daraja-->>App: ResponseDescription success
  Customer->>Daraja: Pays Paybill or Till
  Daraja->>App: Validation request
  App-->>Daraja: ResultCode 0 accept · or reject
  Daraja->>App: Confirmation payload
  App->>App: Record payment and reconcile

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 ResponseType is your safety net. External (non-STK) validation is not on by default: Safaricom must enable "External Validation" for your shortcode before your ValidationURL is ever called — until then only the Confirmation fires. The ResponseType you 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.

Phone-number masking (live since ~24 Mar 2026 — plan around it). Following CBK approval, Safaricom now masks the customer's phone number in merchant-facing M-Pesa notifications (shown like 0722**000*). Confirmed for the SMS/notification channel; whether the Daraja callback MSISDN / PhoneNumber field is also masked is not officially documented — do not assume it stays in the clear. Practical rules: for STK Push you already supplied PhoneNumber in the request, so persist it then and never depend on the callback echoing it back; for C2B (customer-initiated) you have historically relied on the confirmation MSISDN to know who paid — treat that as at-risk and lean on BillRefNumber (the account the customer types) as your primary identity key, plus TransID for idempotency. A recipient can request full sender details within a 24-hour window (via Safaricom's lookup, shortcode 334); that is a manual consumer path, not an API, so design so a masked number never blocks reconciliation.


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 password

Sandbox 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:mpesa

Common Use Cases

Use Case Approach
Customer payment STK Push → await callback
Recurring / subscription billing Ratiba API (standing orders, Daraja 3.0) — preferred over the old "scheduled STK Push via cron" hack, since Ratiba gets the customer's up-front consent for repeat debits
Refund / payout B2C PaymentRequest
Merchant collection C2B Register URL + simulate
Payment status STK Push Query API
Account balance AccountBalance API

Ratiba is Daraja 3.0's standing-order API (announced alongside the portal rebuild) for repeat/subscription debits the customer authorises once. Its docs are still rolling out on the portal — verify the exact endpoint and request shape on the /apis Ratiba page before building. For one-off charges, STK Push remains the right tool.


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
Customer phone shows masked (0722**000*) Expected since ~Mar 2026 masking rollout — key reconciliation off BillRefNumber + TransID, and for STK Push store the number you sent
Old doc link 404s (/APIs, /Documentation) Portal moved to Daraja 3.0 (lowercase /apis, /dashboard); browse APIs from the catalogue

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

Official docs: