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 underfinance-service/resources/m-pesa-apis/), Safaricom Daraja Developer Portal. Updated: August 2026Known 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>" }
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": "" }
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