MPesa Daraja 2.0 + TRPC

tRPC ?? an alternative way to integrate with Daraja 2.0 beyond its REST API.Its a type-safe alternative that simplifies STK push, reduced bugs, and 10X developer experience. Here’s what I learned.

March 10, 202515 min read

When a Distributed Systems Class Becomes a Career Catalyst

Picture this: I'm sitting in my Distributed Systems exam, heart racing as I face a ten-mark question about Remote Procedure Calls. The question demands a diagram, and my mind scrambles to recall some bit of knownledge I heard about gRPC, Protocol Buffers, and bidirectional communication patterns for me to put something down. Despite my best efforts to sketch something coherent, I know my answer barely scratches the surface of what's required.

You know that feeling when you've missed the target even before seeing the results? Yeah, that was me in that moment.

Curiosity And Exploration

Rather than brushing it off and moving on, the experience lit a fire under me. What exactly was an RPC? Why was it important in distributed systems? How does it differ from the REST APIs I'm comfortable with?

The more I researched, the more I discovered about software architectures I hadn't fully understood:Here are some of the findings:

Layered Architectures

Description: Software organized in horizontal layers, with each layer communicating only with adjacent ones.

Examples:

  1. Web Applications: UI, business logic, data access layers
  2. Operating Systems: Kernel, drivers, system calls, applications

Object-Based Architectures (RMI and RPC)

Description: Enables programs to call procedures on remote systems as if they were local.

Examples:

  1. tRPC/gRPC: Google's high-performance RPC framework
  2. Java RMI: Remote method calls between Java objects

Service-Oriented Architectures

Description: Applications structured as loosely coupled services with standardized interfaces.

Examples:

  1. Banking Systems: Separate services for accounts, loans, investments
  2. SAP: Business modules integrated through service interfaces

Microservices

Description: Small, independent services developed and deployed individually.

Examples:

  1. Netflix: Hundreds of services for recommendations, authentication, content
  2. Amazon: Thousands of services for distinct business capabilities

Event-Driven Architectures

Description: Systems where components communicate through events from producers to consumers.

Examples:

  1. Trading Platforms: Market data events triggering analysis and trades
  2. IoT Systems: Sensor events activating connected devices

Try Kodaschool for free

Click below to sign up and get access to free web, android and iOs challenges.

Sign Up

No Theory Goes without a Practical Application Behind It :The Challenge

Learning without application is like reading about swimming without ever getting wet. I needed a real project to cement my understanding.

That's when it hit me – why not convert one of my most familiar integrations, the M-Pesa Daraja API, from REST to RPC?

I had two main options:

  1. gRPC: Google's high-performance RPC framework using Protocol Buffers
  2. tRPC: End-to-end typesafe APIs made easy, built for TypeScript developers

After weighing the options, I chose tRPC. Why? Because who doesn't love the sweet, sweet TypeScript type system? The promise of type safety across frontend and backend was too good to pass up.

Move Fast and Break Nothing. End-to-end typesafe APIs ... : his is waht trpc means to every eveloper that adopts it....

What is tRPC and Why Choose It?

For those not familiar, tRPC (TypeScript Remote Procedure Call) creates end-to-end typesafe APIs without schemas or code generation. Unlike traditional REST APIs where you need to manually maintain type definitions, tRPC leverages TypeScript's type inference to ensure full type safety across your client and server.

The key advantages that led me to choose tRPC for this integration:

  • Type Safety: Full end-to-end type safety without any code generation
  • Developer Experience: Autocompletion and compiler checks catch errors before runtime
  • Simplified Architecture: No need for separate API documentation or validation libraries

What We'll Learn

  • Understand tRPC: Learn about Remote Procedure Calls
  • Set Up tRPC: Configure tRPC for your project.
  • Integrate M-Pesa Daraja: Handle authentication and token management with tRPC middleware.
  • Create Type-Safe Procedures: Build secure and efficient API calls using TypeScript.
  • Simplify Client-Side Code: Implement clean and straightforward client-side logic.
  • Explore Real-World Benefits: Discover the advantages of tRPC in reducing bugs and improving documentation.

Prerequisites

  • Basic Reactjs knowledge
  • Node.js runtime installed
  • Basic Typescript Knowledge
  • Zod Schema Validation Library
  • Basic understanding of Mpesa-Daraja Api (Reference this article && great should out to the author Mary Maina Mpesa intergration)

Understanding the Problem:

The M-Pesa Daraja API requires a specific authentication flow:

It uses the username and password to obtain an accesstoken that u can progressively use in the other subsequent invocations.....

  1. Obtain an access token from their authorization API
  2. Use this token in subsequent API calls (STK Push, B2C, C2B, etc.)

In my original REST implementation, I used Express middleware to handle this authentication flow:

For reference i will be using my own implementaion that i did back then check it out here

const router =require("express").Router()
const {getAccessToken} = require("../middleware/generatetoken")
const transacControllers = require("../controllers/trxncontrollers")
// STEP 2: STK push
router.post("/stk",getAccessToken,transacControllers.payAmount)

The middleware would fetch the token and attach it to the request object, making it available to downstream handlers. But how would this work in the tRPC paradigm?

How th Intergration Works

  1. The frontend makes a request to initiate payment with a phone number and amount
  2. The backend authenticates with the M-Pesa API
  3. The server sends an STK Push request to M-Pesa
  4. M-Pesa sends a payment prompt to the user's phone
  5. The user confirms payment by entering their PIN
  6. M-Pesa sends the result back to our callback URL

tRPC vs REST: Fundamental Differences

Before diving into implementation, it's important to understand the key differences:

REST API:

  • HTTP-based with explicit routes and HTTP methods
  • Authentication typically handled through middleware
  • Request and response formats vary (JSON, form data, etc.)

Below is an illustration of the Rest Architecture pattern

Image

tRPC:

  • Typed RPC (Remote Procedure Call) system
  • Procedures (functions) instead of routes
  • Type safety across client and server
  • Middleware works differently than in Express

Lets Findout More About tRPC below.

Vocabulary:

Procedure ↗: API endpoint — can be a query, mutation, or subscription.
Query: A procedure that gets some data.
Mutation: A procedure that creates, updates, or deletes some data.
Subscription: A procedure that creates a persistent connection and listens to changes.
Router ↗: A collection of procedures (and/or other routers) under a shared namespace.
Context ↗: Stuff that every procedure can access. Commonly used for things like session state and database connections.
Middleware ↗: A function that can run code before and after a procedure. Can modify context.
Validation ↗:”Does this input data contain the right stuff? Is it in the correct Format as specified?”

Image

tRPC Authentication Flow

In tRPC, here's how we handle authentication:

  1. Context: The initial data available to all procedures
  2. Middleware: Functions that run before procedures, can modify context
  3. Procedures: The actual functions that clients call

How the M-Pesa Integration Works with tRPC

Let's break down the flow of an STK Push request:

  1. Client-Side: The client calls trpc.mpesa.stkPush.mutate({ phone, amount })
  2. Server Middleware: The mpesaAuth middleware runs, obtaining an M-Pesa token
  3. Input Validation: Zod validates that phone is a string and amount is a number
  4. Procedure Execution: The STK Push procedure formats the data and makes the M-Pesa API call
  5. Response: The typed response is returned to the client

Let's implement this step by step:

Environment Setup and Configuration

Folder Structure:

Image

In your root-project folder run this command in aterminal of your choice:

npm init  -y

This create a package.json file

Why a package json file outside our client/server folder?

  • Workspace Configuration:sets up our project as a mono repo
  • Project Orchestration: The root package.json allows you to define scripts that can control both the client and server simultaneously. For example:

Lets now Initialize our Backend :

mkdir backend     #create afolder named backend 
cd backend        # navigate inside the folder
npm init -y    

Install dependencies

npm install @trpc/server axios cors dotenv express nodemon superjson zod
npm install --save-dev @types/axios @types/cors @types/express @types/node ts-node typescript

@trpc/server: Type-safe API without schemas

axios: Promise-based HTTP client

cors: Enable cross-origin resource sharing

dotenv: Load environment variables from files

express: Web framework for Node.js

nodemon: Auto-restart server during development

superjson: Serializes dates and other JavaScript objects

zod: TypeScript-first schema validation library

Configure Typescript

npx tsc --init

Modify tsconfig.json to include appropriate settings for a Node.js environment.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}

Create these folder structure in your backend:

backend/
│
├── routes/
│   ├── mpesarouter.ts
│   └── routers.ts
│
├── .dockerignore
├── .env
├── .gitignore
├── Dockerfile
├── index.ts
├── package-lock.json
├── package.json
├── trpc.ts
└── tsconfig.json

Lets add this in our package.json scripts:

  {"scripts": {
    "start": "nodemon --exec ts-node index.ts",
    "build": "tsc",
    "dev": "ts-node index.ts"
  },
  }

Lets Initialize our Frontend

In the root of our project lets run this command:

npm init vite @latest 

Procee to set our frontend as Follows up

Image

Install dependencies

npm install @trpc/client @trpc/server react react-dom superjson zod

Now we are up and ready To write Code

Set up TRPC

On our Backend we have four main important files trpc.ts, mpesarouter.ts, routers.ts, index.ts

Note Example

Basic trpc setup for TRPC

import { TRPCError, initTRPC } from '@trpc/server';
import superjson from 'superjson';

// Start with an empty context
type Context = {};

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return shape;
  },
});

export const middleware = t.middleware
export const router = t.router;
export const publicProcedure = t.procedure;

initTRPC.context<Context>(): Sets up tRPC with a context type

superjson: A transformer that handles serializing complex JavaScript objects like Date

t.middleware: For creating middleware functions

t.router: For creating routers that group procedures

t.procedure: The base procedure that all your endpoints will extend from

In REST APIs, you'd need to create an Express middleware that modifies the request object. In tRPC, middleware enhances the context that gets passed to your procedures.

M-Pesa Basic Setup && authentication middleware:

trpc.ts

import { TRPCError, initTRPC } from '@trpc/server'
// import { type Context } from "./middleware/context"
import superjson from 'superjson'
import axios from "axios"

type MpesaResponse = {
    data:{
    access_token: string;
    expires_in: number;
} }

type Context = {};

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return shape
  },
})

export const middleware = t.middleware
export const router = t.router;
export const publicProcedure = t.procedure;


export const mpesaAuth = middleware(async ({ ctx, next }) => {
  try {
    const key = process.env.MPESA_CONSUMER_KEY || "DV.....X5"
    const secret = process.env.MPESA_CONSUMER_SECRET || "54.....aS"
    const auth = Buffer.from(`${key}:${secret}`).toString("base64");

    const response:MpesaResponse = await axios.get(
      "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials",
      {
        headers: {
          Authorization: `Basic ${auth}`,
        },
      }
    );

    const mpesaToken = response.data.access_token;

  // add the token to the context
    return next({
      ctx: {
        ...ctx,
        mpesaToken,
      },
    });
  } catch (err) {
    console.error(err);
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Failed to get access token' });
  }
});

export const mpesaProcedure = publicProcedure.use(mpesaAuth);

This middleware:

  1. Uses M-Pesa credentials to obtain an access token
  2. Adds the token to the context object
  3. Passes the enhanced context to the next procedure
  4. Creates a reusable mpesaProcedure that automatically handles M-Pesa authentication

In REST APIs, you'd need to create an Express middleware that modifies the request object. In tRPC, middleware enhances the context that gets passed to your procedures.

Setting Up the Express Server

Index.ts

import { createExpressMiddleware } from "@trpc/server/adapters/express";
import express from "express";
import cors from "cors";
import { appRouter } from "./routes/routers";
const app = express();
import * as dotenv from "dotenv";
dotenv.config();

app.use(cors({ origin: "*" }));
const createContext = async() => {
  return {};
};

app.use(
  "/trpc",
  createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

app.listen(3000, () => {
  console.log("Server started on http://localhost:3080");
});

export type AppRouter = typeof appRouter;

Here we :

  1. Sets up an Express server
  2. Creates a context function that initializes an empty context object
  3. Uses the createExpressMiddleware adapter to make our tRPC router available at /trpc
  4. Exports the AppRouter type for client-side consumption

This line is so significant since it is what exposes our server to the client

export type AppRouter = typeof appRouter

Note

Adapters are of various types depending on the framework you are working on:

Image

Defining Our M-Pesa Procedures

Basic Procedure Setup

import { z } from 'zod';
import { TRPCError } from '@trpc/server';

export const mpesaRouter = router({
  stkPush: mpesaProcedure
    .input(z.object({
      phone: z.string(),
      amount: z.number()
    }))
    .mutation(async ({ input, ctx }) => {
      // Implementation details...
    }),
});

.input(): Uses Zod to validate incoming data

.mutation(): Defines this as a data-modifying operation

({ input, ctx }): Receives the validated input and context with our M-Pesa token

mpesarouter.ts

import { z } from 'zod';
import { router, mpesaProcedure } from '../trpc';
import axios from 'axios';
import { TRPCError } from '@trpc/server';

type MpesaREquestBody = {
  BusinessShortCode: string;
  Password: string;
  Timestamp: string;
  TransactionType: "CustomerBuyGoodsOnline" | "CustomerPayBillOnline";
  Amount: number;
  PartyA: unknown;
  PartyB: unknown;
  PhoneNumber: string;
  CallBackURL: string;
  AccountReference: string;
  TransactionDesc: string;
}

type MpesaResponse = {
    data: {
        ResponseCode: string;
        ResponseDescription: string;
        MerchantRequestID: string;
        CheckoutRequestID: string;
        CustomerMessage: string;
    }
}

export const mpesaRouter = router({
  stkPush: mpesaProcedure
    .input(z.object({
      phone: z.string(),
      amount: z.number()
    }))
    .mutation(async ({ input, ctx }) => {
      const phone = input.phone.substring(1);
      const amount = input.amount;

      const date = new Date();
      const timestamp =
        date.getFullYear() +
        ("0" + (date.getMonth() + 1)).slice(-2) +
        ("0" + date.getDate()).slice(-2) +
        ("0" + date.getHours()).slice(-2) +
        ("0" + date.getMinutes()).slice(-2) +
        ("0" + date.getSeconds()).slice(-2);

      const shortCode = process.env.MPESA_PAYBILL || "174379";
      const passkey = process.env.MPESA_PASSKEY || "bf.......19";

      if (!shortCode || !passkey) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'M-Pesa short code or passkey is not defined'
        });
      }

      const password = Buffer.from(shortCode + passkey + timestamp).toString("base64");
      const payload: MpesaREquestBody = {
            BusinessShortCode: shortCode,
            Password: password,
            Timestamp: timestamp,
            TransactionType: "CustomerPayBillOnline",
            Amount: amount,
            PartyA: `254${phone}`,
            PartyB: shortCode,
            PhoneNumber: `254${phone}`,
            CallBackURL: process.env.MPESA_CALLBACK_URL || "https://.....com/callback",
            AccountReference: `${phone}`,
            TransactionDesc: "TEST",
          }
      try {
        const response:MpesaResponse = await axios.post(
            "https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest",
            payload,
          {
            headers: {
              Authorization: `Bearer ${ctx.mpesaToken}`
            },
          }
        );

        if (response.data && response.data.ResponseCode === "0") {
          return response.data;
        } else {
          throw new TRPCError({
            code: 'INTERNAL_SERVER_ERROR',
            message: 'Failed to make payment'
          });
        }
      } catch (err) {
        console.error(err);
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Failed to make payment'
        });
      }
    }),
});

The implementation then:

  • Formats the phone number and timestamp
  • Creates the required M-Pesa payload
  • Uses the token from the context to make the API call
  • Returns the response or throws a typed error

Combinig Multiple routers

routers.ts

import { router } from "../trpc"
import { mpesaRouter } from "./mpesarouter"

export const appRouter = router({
  mpesa :mpesaRouter
})

export type AppRouter = typeof appRouter

This above code :

  1. Imports the M-Pesa router
  2. Creates an application router that combines all our API routers
  3. Exports the router type as AppRouter for client-side type inference

The Client Side

At this point we see the benefits of ts fully

imports

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../backend/index';
import superjson from 'superjson';

createTRPCProxyClient: Creates your tRPC client instance

httpBatchLink: Handles HTTP communication with your server

AppRouter: Your backend's router type for type safety

configurations for trpc client

const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'https://mpesa-daraja-with-trpc.onrender.com/trpc',
    }),
  ],
  transformer: superjson,
});

<AppRouter>: By passing your AppRouter type to createTRPCProxyClient, you're telling TypeScript what procedures are available and what their input/output types are.

links: This configures how your client communicates with the server.

transformer: Configures superjson as the data transformer to handle complex JavaScript types.

Intiate payment function

  const result = await trpc.mpesa.stkPush.mutate({
      amount,
      phone,
    });

trpc.mpesa.stkPush.mutate(): This is where the tRPC magic happens:

  • trpc: Your client instance
  • mpesa: The router namespace defined in your backend (router({ mpesa: mpesaRouter }))
  • stkPush: The procedure name defined in your mpesaRouter
  • mutate(): Indicates this is a mutation (a procedure that modifies data)

Full complete code

App.tsx

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../backend/index';
import superjson from 'superjson';
import { useState } from 'react';

function App() {
  const [amount, setAmount] = useState<number>(0);
  const [phone, setPhone] = useState<string>('');

  // Create tRPC client
  const trpc = createTRPCProxyClient<AppRouter>({
    links: [
      httpBatchLink({
        url: 'https://mpesa-daraja-with-trpc.onrender.com/trpc',
      }),
    ],
    transformer: superjson,
  });

  async function initiatePayment(event: React.FormEvent) {
    event.preventDefault();
    try {
      const result = await trpc.mpesa.stkPush.mutate({
        amount,
        phone,
      });
      console.log('Payment initiated:', result);
      // Clear the form
      setAmount(0);
      setPhone('');
    } catch (error) {
      console.error('Payment failed:', error);
    }
  }

  return (
    <div>
      <h1>M-Pesa Payment</h1>
      <form onSubmit={initiatePayment}>
        <input
          type="number"
          value={amount}
          onChange={(e) => setAmount(Number(e.target.value))}
          placeholder="Amount"
        />
        <input
          type="text"
          value={phone}
          onChange={(e) => setPhone(e.target.value)}
          placeholder="Phone Number"
        />
        <button type="submit">Pay with M-Pesa</button>
      </form>
    </div>
  );
}

export default App;

All the rest of the codeis handling a form event and hits our server and thus user will receive a prompt o the assigned phone number.

Note

When you export type AppRouter = typeof appRouter on the server, you're creating a type that represents your entire API structure.

When you import that type on the client and pass it to createTRPCProxyClient<AppRouter>, you're telling TypeScript what procedures and types are available.

The createTRPCProxyClient function creates a JavaScript Proxy object that intercepts calls like trpc.mpesa.stkPush.mutate() and turns them into HTTP requests to your server.

When the server responds, the data is passed through the transformer (superjson) and returned to your code with the correct types.

Lets now run our app

Frontend :

Image

Backend :

Image

Here is our final app

Image

Lessons Learned

This journey from exam failure to practical implementation taught me several valuable lessons:

  1. Failures are opportunities: That missed exam question led me to a deeper understanding than I might have gained otherwise.
  2. Theory needs practice: Reading about RPCs wasn't enough. I needed to implement one to truly understand.
  3. Modern tooling is transformative: tRPC reduced boilerplate, improved type safety, and made the entire development experience more enjoyable.
  4. Middleware patterns translate across paradigms: The concept of middleware is powerful whether you're building REST APIs or RPC procedures.

Attatched is a link to the repo.

Hurray We made it happy coding.....

Feel free to reach out. I'm happy to share and discuss technical approaches with fellow developers.

Barack Ouma

About Barack Ouma

Software Engineer | AWS Enthusiast | Tech Writer
Building and scaling cloud infrastructures.
Expert in AWS architecture, cost optimization, and security.
Passionate about simplifying tech through writing and hands-on solutions.
Driven to solve real-world problems with innovative and Quality Software.