Skip to content

M-Pesa Daraja API Integration Reference

Sources: Safaricom APIs Postman collection (shared-docs/mpesa apis/Safaricom APIs.postman_collection.json — refreshed 2026-08-22 from a live Daraja Developer Portal export, adds "Age On Network"; single canonical copy, was previously duplicated 19x under finance-service/resources/m-pesa-apis/), Safaricom Daraja Developer Portal. Updated: August 2026

Known defect in Safaricom's own collection (confirmed persistent across two exports fetched hours apart 2026-08-22, so not staleness on our end): "Query the Transaction Status of an M-Pesa Transaction"'s documented request body is byte-identical to "Query the status of a Lipa na M-Pesa Online Payment" (STK Push Query) — {BusinessShortCode, Password, Timestamp, CheckoutRequestID}. That's the correct body for STK Push Query; Transaction Status Query's real shape is {Initiator, SecurityCredential, CommandID, TransactionID, PartyA, IdentifierType, Remarks, QueueTimeOutURL, ResultURL, Occasion} — see "RSA Security Credential Generation" below, and don't copy this collection's body for that specific request.


Treasury payment workflow

See payment-workflow.md for the full end-to-end flow (intent creation, shared pay page, initiate_url, gateway redirect). This page covers only the M-Pesa/Daraja API specifics: STK Push triggering, manual "I paid at till/agent" confirmation, and the credential model below.


Two-Tier Configuration Model

Tier Owner Scope Fields
Platform (Tier 1) Codevertex superadmin Shared across all tenants consumer_key, consumer_secret, passkey, environment (sandbox/production)
Tenant (Tier 2) Tenant admin Unique per tenant shortcode (Paybill or Till), initiator_name, initiator_password, account_reference

Storage: Tier 1 credentials stored in encrypted credentials JSON blob in GatewayConfig table (encrypted at rest). Tier 2 fields stored as plain columns (mpesa_shortcode, mpesa_initiator_name, mpesa_initiator_password encrypted, mpesa_account_ref) on the tenant-level GatewayConfig row.


Authentication

All M-Pesa Daraja APIs require an OAuth 2.0 Bearer token obtained from:

GET https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials
Authorization: Basic base64(consumer_key:consumer_secret)

Sandbox: https://sandbox.safaricom.co.ke
Production: https://api.safaricom.co.ke

Token expires in 3600 seconds. Cache and refresh before expiry.


1. STK Push (Lipa Na M-Pesa Online / Express)

Purpose: Initiate customer-to-business (C2B) payment via a push prompt to the customer's phone.
Use case: Online orders, checkout payments.

Endpoint: POST /mpesa/stkpush/v1/processrequest

Request

{
  "BusinessShortCode": "174379",
  "Password": "<base64(shortcode + passkey + timestamp)>",
  "Timestamp": "20250925124519",
  "TransactionType": "CustomerPayBillOnline",
  "Amount": "100",
  "PartyA": "254708374149",
  "PartyB": "174379",
  "PhoneNumber": "254708374149",
  "CallBackURL": "https://booksapi.codevertexafrica.com/api/v1/webhooks/mpesa/callback",
  "AccountReference": "OrderRef123",
  "TransactionDesc": "Payment for Order #123"
}

Password generation:

timestamp := time.Now().Format("20060102150405")
password := base64.StdEncoding.EncodeToString([]byte(shortcode + passkey + timestamp))

TransactionType: CustomerPayBillOnline (Paybill) | CustomerBuyGoodsOnline (Till)

Response

{
  "MerchantRequestID": "29115-34620561-1",
  "CheckoutRequestID": "ws_CO_191220191020363925",
  "ResponseCode": "0",
  "ResponseDescription": "Success. Request accepted for processing",
  "CustomerMessage": "Success. Request accepted for processing"
}

Callback (async, received at CallBackURL):

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          {"Name": "Amount", "Value": 1.00},
          {"Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV"},
          {"Name": "TransactionDate", "Value": 20191219102115},
          {"Name": "PhoneNumber", "Value": 254708374149}
        ]
      }
    }
  }
}

ResultCode: 0 = success. Any other code = failure.


2. STK Push Query

Purpose: Poll the status of an STK Push transaction (use when callback wasn't received).
Endpoint: POST /mpesa/stkpushquery/v1/query

{
  "BusinessShortCode": "174379",
  "Password": "<same as STK push>",
  "Timestamp": "20250925124519",
  "CheckoutRequestID": "ws_CO_191220191020363925"
}

Response: ResultCode: 0 = paid; 1032 = user cancelled; 1037 = timeout.

Live-verified 2026-08-22 against a real completed STK transaction (CheckoutRequestID ws_CO_220820261449537743793901, receipt UHMEQ4C1FI): clean synchronous 200, ResultCode: "0", ResultDesc: "The service request is processed successfully." — and MerchantRequestID in the response matched byte-for-byte what's stored in our own payment_intents.metadata.provider_reference for that intent, cross-confirming data integrity end to end. Implementation: MpesaGateway.VerifyPayment (mpesa.go), called from the reconciliation background job (payments/reconciler.go) and the platform admin "test connection" health check — not currently exposed as an on-demand per-intent HTTP trigger the way Transaction Status Query is (POST .../payments/intents/{id}/check-status); reconciliation only re-checks intents still pending/processing, so an already-succeeded intent has no live manual re-check path today. Not a gap that's been asked for — noting it here as a fact, not a todo.

Do not confuse this with Transaction Status Query (below) — same general purpose ("is this payment done?") but a genuinely different Daraja API, different auth (OAuth-only here vs Initiator+SecurityCredential there), different body shape, and used for a different scenario: this one polls a transaction we ourselves initiated via STK Push (keyed by CheckoutRequestID, something only WE have, generated at STK-push time); Transaction Status Query below can check the status of any M-Pesa transaction by its public receipt/confirmation code (keyed by TransactionID, the code a customer reads off their own SMS) — including ones we never initiated at all, e.g. a C2B till payment. That's the whole reason the C2B manual-code fallback (pos-ui) verifies a cashier-typed code against the C2B inbox rather than this endpoint: the code came from the customer's SMS, not from anything we generated.


3. C2B — Register URLs

Purpose: Register confirmation/validation URLs for over-the-counter C2B payments (paybill, till).
Endpoint: POST /mpesa/c2b/v1/registerurl

{
  "ShortCode": "600000",
  "ResponseType": "Completed",
  "ConfirmationURL": "https://booksapi.codevertexafrica.com/api/v1/webhooks/c2b/confirmation",
  "ValidationURL": "https://booksapi.codevertexafrica.com/api/v1/webhooks/c2b/validation"
}

Must be registered once per production shortcode. Sandbox uses simulate.

Two gotchas confirmed live against the sandbox (2026-08-22), both now fixed in code (urls.MpesaConfirmationURL/MpesaValidationURL) but worth knowing if you're hand-building these URLs elsewhere: - The URL must include the API's real path prefix (/api/v1/webhooks/... here) — a URL missing it 404s on our own server the moment Daraja tries to deliver a confirmation, even though registration itself appears to succeed. - Daraja's RegisterURL rejects any Confirmation/ValidationURL containing the substring "mpesa" outright, with 400.003.02 "Bad Request - Invalid ValidationURL - URL has the word MPESA" — hence /webhooks/c2b/..., not /webhooks/mpesa/..., for these two specific URLs. Every other mpesa/* webhook (STK callback, B2C/B2B results, txn-status, reversal, balance) is unaffected — those are passed to different Daraja APIs (as ResultURL/QueueTimeOutURL) that were not observed to apply the same filter.


4. C2B — Simulate (Sandbox Only)

Endpoint: POST /mpesa/c2b/v1/simulate

{
  "ShortCode": "600000",
  "CommandID": "CustomerPayBillOnline",
  "Amount": 100,
  "Msisdn": "254708374149",
  "BillRefNumber": "account123"
}

5. B2C — Business to Customer (Payouts to Users)

Purpose: Send money from business shortcode to customer M-Pesa account.
Use cases: Rider earnings payouts, refunds, cashback, loyalty redemption.
Endpoint: POST /mpesa/b2c/v1/paymentrequest

{
  "InitiatorName": "YOUR_INITIATOR_NAME",
  "SecurityCredential": "<RSA encrypted initiator password>",
  "CommandID": "BusinessPayment",
  "Amount": "5000",
  "PartyA": "600000",
  "PartyB": "254708374149",
  "Remarks": "Rider payout week 2026-W10",
  "QueueTimeOutURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/b2c-timeout",
  "ResultURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/b2c-result",
  "Occasion": "WeeklyPayout"
}

CommandID options: - BusinessPayment — (no tax) general business payment - SalaryPayment — salary disbursement - PromotionPayment — for cashback/promotions

SecurityCredential: RSA-encrypt initiator password using M-Pesa public key certificate. See "Initiator SecurityCredential" below for where this cert actually comes from in this codebase and its current admin UI.

Result callback:

{
  "Result": {
    "ResultCode": 0,
    "TransactionID": "LGR019G3J2",
    "ResultParameters": {
      "ResultParameter": [
        {"Key": "TransactionAmount", "Value": 5000},
        {"Key": "TransactionReceipt", "Value": "LGR019G3J2"},
        {"Key": "ReceiverPartyPublicName", "Value": "254708374149 - John Doe"}
      ]
    }
  }
}


6. B2B — Business to Business

Purpose: Shortcode-to-shortcode payments (e.g., pay supplier's paybill).
Endpoint: POST /mpesa/b2b/v1/paymentrequest

{
  "Initiator": "YOUR_INITIATOR_NAME",
  "SecurityCredential": "<RSA encrypted>",
  "CommandID": "BusinessPayBill",
  "SenderIdentifierType": "4",
  "RecieverIdentifierType": "4",
  "Amount": "10000",
  "PartyA": "600000",
  "PartyB": "600001",
  "AccountReference": "Invoice001",
  "Remarks": "Supplier payment",
  "QueueTimeOutURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/b2b-timeout",
  "ResultURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/b2b-result"
}

CommandID: BusinessPayBill | MerchantToMerchantTransfer | MerchantTransferFromMerchantToWorking


7. B2Pochi — Business to Pochi (Individual Till)

Endpoint: Same as B2C (/mpesa/b2c/v1/paymentrequest)
CommandID: BusinessPayment with PartyB as the Pochi till number.


7b. B2B Hakikisha — recipient org verification (added 2026-08-22)

Purpose: Verify a B2B recipient organization's identity BEFORE sending money to it — reduces misdirected B2B payments, since §6's B2B payment request itself has no such check.
Endpoint: POST /sfcverify/v1/query/info. OAuth-only (no SecurityCredential needed).

{ "IdentifierType": "4", "Identifier": "<recipient shortcode>" }
No sample response is documented in the collection; MpesaGateway.VerifyB2BOrgInfo (mpesa.go) returns the raw decoded JSON rather than guessing at named fields. Tenant-scoped: POST /{tenant}/gateways/mpesa/verify-b2b-org, body {identifier_type?, identifier}.


7c. Mobile Number / ID Validation (KYC, added 2026-08-22)

Purpose: Verify an MSISDN is registered against a given National ID/passport number.
Endpoint: POST /v1/KYC-validation/validateID. OAuth-only.

{ "requestRefID": "<unique>", "shortCode": "", "msisdn": "", "idType": "", "idNumber": "" }
Same no-documented-response-shape caveat as Hakikisha above; MpesaGateway.ValidateMobileNumberID returns the raw decoded JSON. Tenant-scoped: POST /{tenant}/gateways/mpesa/validate-mobile-id, body {short_code?, msisdn, id_type, id_number}. This is a KYC/identity-verification capability rather than a payment-confirmation one — no frontend surface built for it yet; it's API-only until a concrete product use case (e.g. customer onboarding for credit accounts) defines where it belongs.


8. Transaction Status Query

Purpose: Query the status of ANY M-Pesa transaction by its public receipt code (e.g. what a customer reads off their own SMS) — including ones we never initiated, unlike STK Push Query (§2) above, which only polls a CheckoutRequestID WE generated. See §2's "Do not confuse this with..." note for the full distinction — this is the one important for reconciling a manually-typed M-Pesa code, not §2.
Endpoint: POST /mpesa/transactionstatus/v1/query. Do not copy the request body from this collection's own entry for this request — see the top-of-file note; it's a verified, persistent defect (identical to the unrelated STK Push Query body) present in two exports fetched hours apart. The shape below is the real one, cross-checked against 4 other correctly-filled requests in the same collection (Account Balance, Reversal, both Standing Order variants) and proven via live testing.

{
  "Initiator": "YOUR_INITIATOR_NAME",
  "SecurityCredential": "<RSA encrypted>",
  "CommandID": "TransactionStatusQuery",
  "TransactionID": "LHG31AA5TX",
  "PartyA": "600000",
  "IdentifierType": "4",
  "ResultURL": "https://booksapi.codevertexafrica.com/api/v1/webhooks/mpesa/txn-status-result",
  "QueueTimeOutURL": "https://booksapi.codevertexafrica.com/api/v1/webhooks/mpesa/timeout",
  "Remarks": "Status check",
  "Occasion": ""
}

IdentifierType is "4" for a Paybill shortcode, "2" for Till/Buy-Goods — PartyA here is OUR OWN shortcode, so this follows the same orgIdentifierType() (mpesa.go) as Account Balance; see that function's fix note (2026-08-22) for the Till-tenant bug this codifies.

Live-verified 2026-08-22, three separate real-receipt submissions across this session (all success:true, clean 200): once with a full end-to-end proof — Safaricom actually delivered the async result back through our (at-the-time newly fixed) webhook URL, logged as a real inbound POST /api/v1/webhooks/mpesa/txn-status-result → 200. Daraja's async delivery timing for this specific API is inconsistent in sandbox (landed within ~20s once, didn't land within that same window on two other otherwise-identical attempts) — a Safaricom-side sandbox characteristic, not a defect here; the submission side (the part actually within our control) is solid on every attempt.

Reachable via POST /{tenant}/payments/intents/{intentID}/check-status (CheckIntentStatus) — but only for an intent that has its OWN mpesa_receipt_number/provider_reference in metadata (i.e. a transaction WE initiated via STK and later want to re-verify). A code with no such intent (e.g. one that arrived via a channel we never initiated, or was only ever entered manually) has nothing to attach the query to via this endpoint — that's a data-availability constraint of being keyed by intent, not a code defect.


9. Transaction Reversal

Purpose: Reverse a completed M-Pesa transaction (within 24 hours).
Endpoint: POST /mpesa/reversal/v1/request

{
  "Initiator": "YOUR_INITIATOR_NAME",
  "SecurityCredential": "<RSA encrypted>",
  "CommandID": "TransactionReversal",
  "TransactionID": "OEI2AK4Q16",
  "Amount": "100",
  "ReceiverParty": "600000",
  "RecieverIdentifierType": "4",
  "ResultURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/reversal-result",
  "QueueTimeOutURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/timeout",
  "Remarks": "Duplicate payment reversal",
  "Occasion": ""
}

10. Account Balance Query

Purpose: Query current M-Pesa shortcode balance.
Endpoint: POST /mpesa/accountbalance/v1/query

{
  "Initiator": "YOUR_INITIATOR_NAME",
  "SecurityCredential": "<RSA encrypted>",
  "CommandID": "AccountBalance",
  "PartyA": "600000",
  "IdentifierType": "4",
  "Remarks": "Balance check",
  "QueueTimeOutURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/timeout",
  "ResultURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/balance-result"
}

11. M-Pesa Ratiba / Standing Orders

Purpose: Schedule recurring customer-to-business payments.
Endpoint: POST /standingorder/v1/createStandingOrderExternal

{
  "StandingOrderName": "Monthly Subscription",
  "BusinessShortCode": "174379",
  "TransactionType": "Standing Order Customer Pay Bill",
  "Amount": "500",
  "PartyA": "254708374149",
  "ReceiverPartyIdentifierType": "4",
  "CallBackURL": "https://booksapi.codevertexafrica.com/webhooks/mpesa/ratiba",
  "AccountReference": "SubRef123",
  "TransactionDesc": "Monthly subscription",
  "Frequency": "3",
  "StartDate": "20260310",
  "EndDate": "20271231"
}

Frequency: 1=daily, 2=weekly, 3=monthly, 4=quarterly, 5=half-yearly, 6=yearly.
TransactionType: "Standing Order Customer Pay Bill" (Paybill) | "Standing Order Customer Pay Merchant" (Till/Buy Goods).

Use case: Subscription billing — when a tenant enables M-Pesa, create a standing order to auto-collect monthly subscription fees.


12. QR Code Generation

Purpose: Generate a static QR code for the tenant's shortcode (customers scan with M-Pesa app).
Endpoint: POST /mpesa/qrcode/v1/generate

{
  "MerchantName": "Acme Retail",
  "RefNo": "cafe-checkout-01",
  "Amount": "500",
  "TrxCode": "PB",
  "CPI": "174379",
  "Size": "300"
}

TrxCode: PB = Paybill | BG = Buy Goods | WA = Wallet-to-Account | SB = Subscriber-to-Bank.

Returns a base64-encoded PNG of the QR code.


Webhook Endpoints in treasury-api

All paths below are relative to PublicBaseURL and require the /api/v1 prefix (e.g. https://booksapi.codevertexafrica.com/api/v1/webhooks/mpesa/callback) — every URL builder now goes through internal/pkg/urls's MpesaXxxURL helpers (single source of truth, confirmed live 2026-08-22 after finding several of these were previously missing this prefix entirely and being submitted to Daraja as bare relative paths).

M-Pesa Event Internal Route Built via
STK Push callback POST /api/v1/webhooks/mpesa/callback urls.MpesaCallbackURL
C2B Validation POST /api/v1/webhooks/c2b/validation urls.MpesaValidationURL
C2B Confirmation POST /api/v1/webhooks/c2b/confirmation urls.MpesaConfirmationURL
B2C Result POST /api/v1/webhooks/mpesa/b2c-result urls.MpesaB2CResultURL
B2C Timeout POST /api/v1/webhooks/mpesa/b2c-timeout urls.MpesaB2CTimeoutURL
B2B Result POST /api/v1/webhooks/mpesa/b2b-result urls.MpesaB2BResultURL
B2B Timeout POST /api/v1/webhooks/mpesa/b2b-timeout urls.MpesaB2BTimeoutURL
Transaction Status Result POST /api/v1/webhooks/mpesa/txn-status-result urls.MpesaTxnStatusURL
Transaction Reversal Result POST /api/v1/webhooks/mpesa/reversal-result urls.MpesaReversalURL
Account Balance Result POST /api/v1/webhooks/mpesa/balance-result urls.MpesaBalanceURL
M-Pesa Ratiba POST /api/v1/webhooks/mpesa/ratiba urls.MpesaRatibaURL
Shared QueueTimeOutURL POST /api/v1/webhooks/mpesa/timeout urls.MpesaTimeoutURL

Note on C2B's /webhooks/c2b/... paths (not /webhooks/mpesa/... like everything else): Daraja's RegisterURL API rejects any Confirmation/ValidationURL containing the substring "mpesa" outright, with a hard 400.003.02 "URL has the word MPESA" — confirmed live 2026-08-22. Every other path above is fine to contain "mpesa" since it's passed as ResultURL/QueueTimeOutURL to a different Daraja API (B2C/B2B/balance/etc.) that was not observed to apply the same filter.


Initiator SecurityCredential (RSA-encrypted initiator password)

Required for B2C, B2B, Transaction Status, Reversal, Account Balance — every Daraja command that authenticates as an "Initiator" rather than just the OAuth app. MpesaGateway.generateSecurityCredential (mpesa.go) does this at runtime:

// Encrypt initiator password with M-Pesa public key certificate
func generateSecurityCredential(initiatorPassword, certPath string) (string, error) {
    certPEM, _ := os.ReadFile(certPath)
    block, _ := pem.Decode(certPEM)
    cert, _ := x509.ParseCertificate(block.Bytes)
    rsaKey := cert.PublicKey.(*rsa.PublicKey)
    encrypted, _ := rsa.EncryptPKCS1v15(rand.Reader, rsaKey, []byte(initiatorPassword))
    return base64.StdEncoding.EncodeToString(encrypted), nil
}

Where the certificate actually comes from (checked live 2026-08-22): cert_pem credential (inline PEM, preferred — no file mount needed) → cert_path credential (filesystem path) → MPESA_SANDBOX_CERT_PATH/MPESA_PROD_CERT_PATH env vars → if none of those are set, silently falls back to sending the initiator password UNENCRYPTED (base64 only). Confirmed none are currently configured in the treasury-api deployment (no matching env vars, no volume mounts) — every initiator-authenticated call has been running on the unencrypted fallback, which sandbox tolerates but production would reject.

This is environment-scoped (sandbox vs production), not per-tenant or per-app — it's Safaricom's own infrastructure public key, identical for every developer/tenant in that environment, so it's set once at the platform level (Settings → Platform → Gateways → M-Pesa Paybill/Till → "cert pem" field) and every tenant inherits it via the existing ResolveConfig/MergeCredentials chain — no tenant-level field exists or is needed for this specific credential.

Certificates (current working URLs, found in a saved 2026-06 Daraja portal capture — the URLs previously documented here, developer.safaricom.co.ke/sites/default/files/cert/cert_{sandbox,prod}/cert.cer, now 404): sandbox | production. Both are saved (verified working 2026-08-22) as shared-docs/mpesa apis/{Sandbox,Production}Certificate.cer — the same tracked location as the Postman collection, unlike finance-service/resources/m-pesa-apis/ which is NOT a git repo at all (nothing saved there persists past the local machine). Both certs are technically X.509-expired (2016 and 2018 respectively) but still valid for this use: Daraja's SecurityCredential flow only extracts the RSA public key for encryption, it doesn't check certificate expiry, and Safaricom continues to publish and expect these exact certs.


References

  • Daraja Portal
  • Postman Collection: shared-docs/mpesa apis/Safaricom APIs.postman_collection.json
  • Existing M-Pesa implementation: finance-service/treasury-api/internal/modules/gateways/mpesa.go