← Back to dashboard
daraja-apipaymentsfresh

Daraja API (Safaricom M-Pesa) + Claude Code Integration Guide

How M-Pesa STK Push works

The real model

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.

STK Push simulatorphase: idle
  1. Fetch token
    POST /oauth/v1/generate
  2. STK Push
    POST /mpesa/stkpush/v1/processrequest
  3. Customer decides
    Phone prompt → PIN
  4. Callback
    POST → your /api/mpesa/callback
  5. Reconcile
    Dedupe + 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.

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

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


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

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

Bash
# 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:

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

TypeScript
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)

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

TypeScript
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)

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

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


Environment Variables

Bash
# 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

FieldValue
Shortcode174379
Test Phone254708374149
PasskeyAvailable in Daraja sandbox dashboard

Automation Workflows

Claude Code Slash Command: Test STK Push

.claude/commands/mpesa-test.md:

Markdown
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.

Text

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 CaseApproach
Customer paymentSTK Push → await callback
Subscription renewalScheduled STK Push via cron
Refund / payoutB2C PaymentRequest
Merchant collectionC2B Register URL + simulate
Payment statusSTK Push Query API
Account balanceAccountBalance API

Troubleshooting

IssueFix
Invalid Access TokenToken expires after 1 hour — regenerate before each request
CallbackURL unreachableMust be HTTPS; use ngrok for local development: ngrok http 3000
Invalid PhoneNumberMust be format 254XXXXXXXXX (no leading 0 or +)
ResultCode: 1 in callbackCustomer cancelled or insufficient funds
The initiator information is invalidB2C initiator name/credential mismatch
Sandbox STK not receivedUse the sandbox test phone 254708374149

Local Webhook Testing with ngrok

Bash
# 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