Business to Customer (B2C)
Send payouts (salary, business or promotional payments) from your organization's disbursement account to customers, and handle result/timeout callbacks.
User Stories
- As a fintech product owner, I want to programmatically initiate B2C payouts so that customers receive funds immediately after approval.
- As an integrations developer, I want a simple client and clear webhook callbacks so I can implement reliable end-to-end flows with minimal boilerplate.
- As a billing operations engineer, I want result and timeout notifications with acknowledgements so I can reconcile transactions and trigger retries or alerts when needed.
- As a reseller partner, I want a tested SDK and examples so I can onboard quickly and reduce integration defects.
Parameters Definition
Parameter | Type | Description |
---|---|---|
OriginatorConversationIDrequired str | String | Unique identifier for this request (used for tracing/reconciliation). |
InitiatorNamerequired str | String | API initiator username configured with Safaricom. |
SecurityCredentialrequired str | String | Encrypted security credential for the initiator. |
CommandIDrequired str | Enum | Type of payment. Allowed: SalaryPayment, BusinessPayment, PromotionPayment. |
Amountrequired int | Integer | Amount to disburse to the recipient. |
PartyArequired int | Integer | Shortcode (Bulk Disbursement Account shortcode) sending the funds. |
PartyBrequired int | Integer | Recipient MSISDN. Will be normalized/validated as a Kenyan phone number. |
Remarksrequired str | String | Free text remarks (max 100 chars). |
QueueTimeOutURLrequired str | String | URL to receive timeout notifications if processing exceeds the provider timeout window. |
ResultURLrequired str | String | URL to receive the final result callback for the payment. |
Occasion str | String | Optional occasion (max 100 chars). |
ConversationID str | String | Returned by provider to identify the asynchronous processing conversation. |
ResponseCoderequired str|int | String/Integer | '0' (or all-zero string) indicates success in service responses. |
Result.ResultParameters list | List[Key/Value] | Result callback parameters (TransactionAmount, TransactionReceipt, etc.). |
Overview
Initiate B2C payouts and handle callbacks.
⚠️NOTE
For you to use this API in production you are required to apply for a Bulk Disbursement Account
and obtain a Shortcode
; you cannot perform these payments from a Pay Bill
or Buy Goods
(Till Number). To apply for a Bulk Disbursement Account, visit https://www.safaricom.co.ke/business/sme/m-pesa-payment-solutions
💡Integration Options
- Use the MpesaClient facade (recommended) for a simple, token-managed API to submit B2C payment requests and receive typed response models.
- Use the direct B2C service when you need fine-grained control over requests, headers, or custom behavior (token manager + http client).
💡Why use MpesaClient
The facade handles authentication and returns Pydantic models (responses & helpers) so you can focus on business logic.
Quick Setup
Python
# Example: using the MpesaClient facade to submit a B2C payment and inspect the typed response.from mpesakit.client import MpesaClientfrom mpesakit.b2c import B2CCommandIDType, B2CResponse
# Initialize the client with your credentials and environmentclient = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
# Build and send a B2C payment requestresp: B2CResponse = client.b2c.send_payment( originator_conversation_id="ocid-1234-5678", initiator_name="api_initiator", security_credential="ENCRYPTED_SECURITY_CREDENTIAL", command_id=B2CCommandIDType.BusinessPayment, amount=1500, party_a="600999", # Bulk disbursement shortcode party_b="254712345678", # Recipient MSISDN (normalized by SDK) remarks="Salary payout", queue_timeout_url="https://example.com/b2c/timeout", result_url="https://example.com/b2c/result", occasion="JulySalary",)
# Inspect the typed responseif resp.is_successful(): print("B2C sent:", resp.ResponseDescription)else: print("B2C failed:", resp.ResponseDescription, "code:", resp.ResponseCode)
💡Notes
- Facade calls return typed responses (e.g., B2CResponse).
- The client manages Authorization headers via the TokenManager automatically.
Callbacks (Result & Timeout)
Webhook receivers (FastAPI)
# Minimal FastAPI webhook receivers for B2C Result & Timeout callbacks.# - Validates caller IP using is_mpesa_ip_allowed# - Parses payload into SDK schemas (B2CResultCallback / B2CTimeoutCallback)# - Persists/queues the payload for downstream processing (TODO)# - Returns the acknowledgement JSON expected by M-Pesa Daraja API
from fastapi import FastAPI, Request, HTTPException, statusfrom fastapi.responses import JSONResponsefrom mpesakit.security import is_mpesa_ip_allowedfrom mpesakit.http_client import MpesaHttpClientfrom mpesakit.b2c.schemas import ( B2CResultCallback, B2CTimeoutCallback, B2CResultCallbackResponse, B2CTimeoutCallbackResponse,)import logging
log = logging.getLogger(__name__)app = FastAPI()
# Optional HTTP client for any outgoing calls to MPesa (not required to accept callbacks)mpesa_http = MpesaHttpClient(environment="sandbox") # or "production"
def _get_remote_ip(request: Request) -> str: xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",")[0].strip() return request.client.host
@app.post("/webhooks/b2c/result")async def b2c_result(request: Request): remote_ip = _get_remote_ip(request) if not is_mpesa_ip_allowed(remote_ip): log.warning("Rejected B2C result callback from disallowed IP: %s", remote_ip) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try: payload = await request.json() except Exception as exc: log.exception("Failed reading JSON payload from %s: %s", remote_ip, exc) return JSONResponse( status_code=400, content=B2CResultCallbackResponse(ResultCode=1, ResultDesc="Invalid JSON payload.").model_dump(), )
try: callback = B2CResultCallback.model_validate(payload) except Exception as exc: log.exception("B2C result callback validation error: %s", exc) return JSONResponse( status_code=400, content=B2CResultCallbackResponse(ResultCode=1, ResultDesc=f"Invalid payload: {exc}").model_dump(), )
# TODO: persist callback (DB/queue) for reconciliation and business processing. log.info( "Received B2C result: OriginatorConversationID=%s ConversationID=%s ResultCode=%s", callback.Result.OriginatorConversationID, callback.Result.ConversationID, callback.Result.ResultCode, )
ack = B2CResultCallbackResponse() # default success ack return JSONResponse(status_code=200, content=ack.model_dump())
@app.post("/webhooks/b2c/timeout")async def b2c_timeout(request: Request): remote_ip = _get_remote_ip(request) if not is_mpesa_ip_allowed(remote_ip): log.warning("Rejected B2C timeout callback from disallowed IP: %s", remote_ip) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try: payload = await request.json() except Exception as exc: log.exception("Failed reading JSON payload from %s: %s", remote_ip, exc) return JSONResponse( status_code=400, content=B2CTimeoutCallbackResponse(ResultCode=1, ResultDesc="Invalid JSON payload.").model_dump(), )
try: callback = B2CTimeoutCallback.model_validate(payload) except Exception as exc: log.exception("B2C timeout callback validation error: %s", exc) return JSONResponse( status_code=400, content=B2CTimeoutCallbackResponse(ResultCode=1, ResultDesc=f"Invalid payload: {exc}").model_dump(), )
# TODO: persist/queue timeout notification and trigger compensating workflows. log.info( "Received B2C timeout: OriginatorConversationID=%s ConversationID=%s ResultCode=%s", callback.Result.OriginatorConversationID, callback.Result.ConversationID, callback.Result.ResultCode, )
ack = B2CTimeoutCallbackResponse() # default success ack return JSONResponse(status_code=200, content=ack.model_dump(mode="json"))
💡Best practices for webhook handlers
- Validate incoming payloads against the provided schemas (
B2CResultCallback
/B2CTimeoutCallback
). - Persist notifications for reconciliation before returning a success acknowledgement.
- Use
is_mpesa_ip_allowed
to restrict callers to known Safaricom IP ranges. - Run your app behind a trusted proxy and ensure
X-Forwarded-For
handling is correct for IP validation.
Schemas & Runtime Behavior
💡Request validation
CommandID
must be one of the supported enum values (SalaryPayment
,BusinessPayment
,PromotionPayment
). Invalid CommandID raises a validation error.PartyB
(recipient) is normalized/validated as a Kenyan phone number; invalid numbers raise a validation error.Remarks
andOccasion
are length-restricted (100 characters); exceeding the limit raises a validation error.
💡Response helpers
B2CResponse.is_successful()
treats any all-zero string (e.g., "0" or "00000000") as success. Empty strings or mixed non-zero codes are not considered successful.B2CResultMetadata
exposes convenience properties for common result parameters:transaction_amount
,transaction_receipt
,recipient_is_registered
(returns True/False/None
),receiver_party_public_name
,transaction_completed_datetime
,charges
/utility
/working account balances
.
Error Handling
⚠️Edge cases & normalization
- The SDK normalizes minor upstream inconsistencies so fields remain accessible (tests show the SDK tolerates typical provider quirks).
- Ensure you log and surface provider conversation IDs (
OriginatorConversationID
/ConversationID
) for troubleshooting and reconciliation.
Testing & Expectations
-
Send payment:
- The service posts payment requests to the provider using the configured
HttpClient
and supplies Authorization viaTokenManager
. - Successful responses are returned as
B2CResponse
instances; callis_successful()
to check outcome.
- The service posts payment requests to the provider using the configured
-
Request validation:
- Invalid
CommandID
, malformedPartyB
or overly longRemarks
/Occasion
should raise validation errors during model construction.
- Invalid
-
Result metadata:
- ResultParameters are provided as a list of Key/Value items. The SDK caches these into a dictionary so callers can access values using typed helper properties (
transaction_amount
,transaction_receipt
, etc.). recipient_is_registered
returns True for 'Y', False for 'N', and None for missing/invalid values.
- ResultParameters are provided as a list of Key/Value items. The SDK caches these into a dictionary so callers can access values using typed helper properties (
Next Steps
💡What's Next?
- Implement robust webhook receivers for
ResultURL
andQueueTimeOutURL
; persist notifications and return the acknowledged response model. - Add observability around sends and callbacks, and establish retry/compensation flows for transient failures.
Related Documentation
- 📡 Webhook Setup Guide - Reliable endpoints and security best practices
- 🏗️ Production Setup - Go-live checklist, security and monitoring