Guides / .NET & C#

.NET / C# integration guide

Register a client, store your provider credentials and accept your first M-Pesa payment from a .NET 6+ application.

Prerequisites

This guide assumes:

  • .NET 6.0 or later
  • A working M-Pesa Daraja, KCB Buni or Equity Jenga developer account for your provider credentials
  • An HTTPS endpoint you control, for the C2B confirmation step

Every request and response body on the gateway is JSON — no SDK is required, but the snippets below use System.Net.Http.Json to keep the code short.

1. Register a client

Create an account, then verify the OTP to receive your API key. The account is created inactive; a 6-digit OTP is sent by SMS and email. Verifying it moves the account to pending, awaiting admin approval before it can process live payments.

GatewayClient.cs
using System.Net.Http;
using System.Net.Http.Json;

public class GatewayClient
{
    private readonly HttpClient _http;
    private const string BaseUrl = "https://payments.navipos.co.ke";

    public GatewayClient(string? apiKey = null)
    {
        _http = new HttpClient { BaseAddress = new Uri(BaseUrl) };
        if (apiKey is not null)
        {
            _http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
        }
    }

    public HttpClient Http => _http;
}
Register.cs
public record RegisterRequest(
    string Name, string BusinessType, string Email, string Phone, string Password);

public async Task<JsonDocument> RegisterClientAsync(GatewayClient client, RegisterRequest req)
{
    var response = await client.Http.PostAsJsonAsync("/api/v1/clients", req);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<JsonDocument>()
        ?? throw new InvalidOperationException("Empty response");
}

// Usage
var client = new GatewayClient();
await RegisterClientAsync(client, new RegisterRequest(
    "My Business Ltd", "Retail", "owner@mybusiness.com", "254712345678", "S3cure!pass"));
// Response is {"otp_required":true,"data":{"email":..., "phone":...}} — no key yet.
// The account is created "inactive"; a 6-digit OTP is sent by SMS and email.

Once the OTP arrives, verify it to receive your API key and client ID — this is the only step that returns them:

VerifyOtp.cs
public record VerifyOtpRequest(string Email, string Otp);

public async Task<(string Token, string ApiKey, string ClientId)> VerifyOtpAsync(
    GatewayClient client, VerifyOtpRequest req)
{
    var response = await client.Http.PostAsJsonAsync("/api/v1/auth/verify-otp", req);
    response.EnsureSuccessStatusCode();
    var doc = await response.Content.ReadFromJsonAsync<JsonDocument>();
    var data = doc!.RootElement.GetProperty("data");

    var token    = data.GetProperty("token").GetString()!;
    var apiKey   = data.GetProperty("apiKey").GetString()!;
    var clientId = data.GetProperty("client").GetProperty("clientId").GetString()!;
    return (token, apiKey, clientId);
}

// This is the only place clientId and apiKey are returned — save them now.
// The account moves to "pending" after this and awaits admin approval
// before it can process live payments.

2. Store provider credentials

Attach your Daraja, KCB Buni or Equity Jenga secrets to the client. They're encrypted at rest — the API only ever returns them masked afterwards.

Credentials.cs
public record DarajaCredentials(
    string Provider, string ConsumerKey, string ConsumerSecret,
    string ShortCode, string Passkey, string InitiatorPassword, string Environment);

public async Task StoreCredentialsAsync(GatewayClient client, string clientId, DarajaCredentials creds)
{
    var response = await client.Http.PostAsJsonAsync(
        $"/api/v1/clients/{clientId}/credentials", creds);
    response.EnsureSuccessStatusCode();
}

// Usage — clientId/apiKey came from VerifyOtpAsync above
var authed = new GatewayClient(apiKey);
await StoreCredentialsAsync(authed, clientId, new DarajaCredentials(
    "daraja", "your_consumer_key", "your_consumer_secret",
    "174379", "your_passkey", "your_initiator_password", "sandbox"));
// consumerKey/consumerSecret are always required; shortCode, passkey and
// initiatorPassword are required for daraja/equity (not for kcb).
The same request shape works for all three providers — set provider to daraja, kcb or equity and the gateway routes accordingly.

3. Trigger an STK push

Send a payment prompt straight to the customer's phone:

StkPush.cs
public record StkPushRequest(
    string PhoneNumber, decimal Amount, string? Reference = null, string? Description = null);

public async Task<JsonDocument> InitiateStkPushAsync(GatewayClient client, StkPushRequest req)
{
    var response = await client.Http.PostAsJsonAsync("/api/v1/stk-push", req);
    response.EnsureSuccessStatusCode();
    return (await response.Content.ReadFromJsonAsync<JsonDocument>())!;
}

// Usage — only PhoneNumber and Amount are required; Reference/Description
// get server-generated defaults if you omit them.
var push = await InitiateStkPushAsync(authed, new StkPushRequest(
    "254712345678", 1000m, "ORDER-1042", "Payment for order #1042"));

var data          = push.RootElement.GetProperty("data");
var transactionId = data.GetProperty("transactionId").GetString();

4. Confirm the payment

STK results resolve asynchronously. Poll the transaction until it leaves pending — in production you can space this out with backoff or trigger it from a background job.

PollTransaction.cs
public async Task<string> PollTransactionAsync(
    GatewayClient client, string transactionId, int maxAttempts = 10)
{
    for (var i = 0; i < maxAttempts; i++)
    {
        var response = await client.Http.GetAsync($"/api/v1/transactions/{transactionId}");
        response.EnsureSuccessStatusCode();
        var doc = await response.Content.ReadFromJsonAsync<JsonDocument>();
        var status = doc!.RootElement.GetProperty("data").GetProperty("status").GetString();

        // Status values are lowercase: pending, initiated, completed, failed, cancelled.
        if (status is "completed" or "failed" or "cancelled")
        {
            return status;
        }
        await Task.Delay(2000);
    }
    throw new TimeoutException("Transaction status could not be determined in time");
}

5. Receiving C2B (Pay Bill) payments

For Pay Bill / Till collections, register your own confirmation URL with POST /api/v1/c2b-register. Safaricom's confirmation payload is relayed to that URL as-is:

MpesaConfirmationController.cs
// ASP.NET Core controller — this is YOUR endpoint. Register its URL with
// POST /api/v1/c2b-register so Safaricom's confirmation is relayed to it.
[ApiController]
[Route("payments")]
public class MpesaConfirmationController : ControllerBase
{
    [HttpPost("confirmation")]
    public IActionResult Confirmation([FromBody] JsonElement payload)
    {
        var transId       = payload.GetProperty("TransID").GetString();
        var amount        = payload.GetProperty("TransAmount").GetString();
        var billReference = payload.GetProperty("BillRefNumber").GetString();
        var payerPhone    = payload.GetProperty("MSISDN").GetString();

        // Persist / reconcile the payment against billReference here.

        return Ok(new { ResultCode = 0, ResultDesc = "Accepted" });
    }
}

Response codes

Every response follows the same envelope; errors carry an error object with a message.

CodeMeaningWhat to do
200 OK Request succeeded.
201 Created Resource created.
400 Bad Request Check the request payload.
401 Unauthorized Provide a valid API key or JWT.
404 Not Found The resource does not exist.
429 Too Many Requests Back off and retry — rate limit hit.
500 Server Error Retry, then contact support.
Looking for the full request/response schema for every endpoint? See the API reference.