if you've ever integrated M-Pesa into an app, you know the loop: write some code, trigger a payment, stare at the terminal, wonder if Safaricom ever called you back, add a print statement, repeat. i got tired of that loop, so i built myself a playground: a Django app where every Daraja flow (STK Push, C2B, B2C, B2B) is a form in the browser, and every request, response, and callback is stored and inspectable.
this post walks through how i implemented it, including the part i find most interesting: using the C2B validation callback to run know your customer (KYC) checks against a customer register, so unknown payments get rejected before they ever hit the paybill.

the stack
nothing exotic here. the project is a Django app (Python 3.14, Django 6) with HTMX for the interactive panels, Alpine.js for browser-only behavior, and Tailwind with DaisyUI for styling. locally it runs on SQLite with synchronous Celery, so there is no Docker, Postgres, or Redis to babysit during development. production is a Docker Compose stack with Postgres, Redis, and a real Celery worker.
Daraja is Safaricom's REST API for M-Pesa (https://developer.safaricom.co.ke). the flows i wired up:
- STK Push (Lipa na M-Pesa Online): prompts the customer's phone to authorize a payment, plus a query endpoint to check status.
- C2B (customer to business): register validation and confirmation URLs, then simulate a customer paying your paybill.
- B2C (business to customer): payouts to a phone number.
- B2B (business to business): paybill to paybill transfers.
two models that record everything
the whole playground rests on two models. ApiCall records every outbound request we make to Daraja, and CallbackEvent records every webhook Safaricom sends back. both extend a BaseModel that adds created_at and updated_at.
class ApiCall(BaseModel):
"""An outbound request made to the Daraja API, with the response we got back."""
class Kind(models.TextChoices):
STK_PUSH = "stk_push", "STK Push"
STK_QUERY = "stk_query", "STK Push Query"
C2B_REGISTER = "c2b_register", "C2B Register URLs"
C2B_SIMULATE = "c2b_simulate", "C2B Simulate"
B2C_PAYMENT = "b2c_payment", "B2C Payment"
B2B_PAYMENT = "b2b_payment", "B2B Payment"
kind = models.CharField(max_length=20, choices=Kind.choices)
url = models.URLField()
request_payload = models.JSONField(default=dict)
response_status = models.PositiveIntegerField(null=True, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
error = models.TextField(blank=True, default="")
succeeded = models.BooleanField(default=False)this sounds almost too simple to be worth writing about, but it changed how i debug Daraja completely. when a call fails with a cryptic error, the exact request payload and the exact response are sitting in the database, rendered as pretty JSON in the UI. no more guessing what you actually sent.
a thin service client
services.py is a thin client around the API. one private helper, _post, does the real work: it fetches an OAuth token, makes the request, and persists the whole exchange as an ApiCall whether it succeeded or not.
def _post(kind: str, path: str, payload: dict) -> ApiCall:
"""POST `payload` to the Daraja API and persist the exchange as an `ApiCall`."""
url = f"{_base_url()}{path}"
api_call = ApiCall(kind=kind, url=url, request_payload=_redacted(payload))
try:
token = get_access_token()
response = requests.post(
url,
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=60,
)
api_call.response_status = response.status_code
try:
api_call.response_payload = response.json()
except ValueError:
api_call.response_payload = {"raw": response.text}
api_call.succeeded = response.ok
except (DarajaError, requests.RequestException) as e:
logger.exception("Daraja %s call failed", kind)
api_call.error = str(e)
api_call.save()
return api_calla few details worth calling out:
- redaction: since stored payloads are shown in the UI, the
PasswordandSecurityCredentialfields are replaced with***REDACTED***before saving. the real values still go over the wire, but they never touch the database. - token caching: access tokens last an hour, so they are cached (scoped by environment, so a sandbox token is never reused against production). locally the cache is Django's
DummyCache, which makes this a harmless no-op. - timestamps: Daraja expects timestamps in GMT+3, so the client uses
timezone.localtime()with the project'sTIME_ZONErather than whatever the server thinks the time is. this one bites a lot of people deploying on UTC servers. RecieverIdentifierType: yes, that is misspelled, and yes, the misspelling is required. the B2B endpoint expects it exactly like that. i left a(sic)comment in the code so nobody "fixes" it.
each public function (stk_push, c2b_simulate, b2c_payment, and so on) just builds the payload for its endpoint and hands it to _post.
the playground UI: one dict, one view
every flow in the UI follows the same shape: render a form, submit it over HTMX, run the service call, re-render the panel with the result. instead of writing five nearly identical views, there is one FLOWS dict that maps each flow to its form class and runner function:
FLOWS = {
"stk_push": {"title": "STK Push", "form_class": forms.StkPushForm, "runner": _run_stk_push},
"stk_query": {"title": "STK Push Query", "form_class": forms.StkQueryForm, "runner": _run_stk_query},
"c2b_simulate": {"title": "C2B Simulate", "form_class": forms.C2BSimulateForm, "runner": _run_c2b_simulate},
"b2c": {"title": "B2C Payment", "form_class": forms.B2CForm, "runner": _run_b2c},
"b2b": {"title": "B2B Payment", "form_class": forms.B2BForm, "runner": _run_b2b},
}
@require_POST
def run_flow(request, flow_key):
"""HTMX endpoint: validate the flow's form, hit the Daraja API, re-render the panel with the result."""
if flow_key not in FLOWS:
raise Http404
flow = FLOWS[flow_key]
form = flow["form_class"](request.POST)
result = None
if form.is_valid():
result = flow["runner"](request, form.cleaned_data)
form = flow["form_class"]() # reset to a fresh (pre-filled) form after a successful submit
context = {"panel": _panel_context(flow_key, form=form, result=result)}
return TemplateResponse(request, "daraja/components/flow_panel.html", context)adding a new flow means writing a form, a runner, and one dict entry. the template side is a single flow_panel.html component included per tab.

callbacks: secret URLs, not open doors
Daraja delivers results asynchronously via HTTP callbacks, which means you are exposing public endpoints that accept POSTed JSON and write it to your database. anyone who finds those URLs can spoof "payments". Safaricom does not sign its callbacks, so the defense i went with is an unguessable URL: every callback route contains a secret segment that must match MPESA_CALLBACK_SECRET.
def _secret_required(view):
"""Reject requests whose URL secret doesn't match MPESA_CALLBACK_SECRET with a 404."""
@functools.wraps(view)
def wrapper(request, secret, *args, **kwargs):
if not constant_time_compare(secret, settings.MPESA_CALLBACK_SECRET):
raise Http404
return view(request, *args, **kwargs)
return wrappertwo small things i like here: the comparison uses Django's constant_time_compare (no timing side channel), and a wrong secret raises Http404 instead of a 403, so probing the URL space tells an attacker nothing about whether they are close.
the app builds the full callback URL itself when handing it to Safaricom, so the secret never needs to be typed anywhere. one gotcha: the sandbox rejects localhost callback URLs, so locally you need a tunnel (i use ngrok) and a MPESA_CALLBACK_BASE_URL setting pointing at it.
every callback that arrives is stored as a CallbackEvent and shows up in the activity log next to the API calls, with the payer's phone number (MSISDN, the phone number in international format) masked in the application logs while the database keeps the full value.
KYC validation for C2B payments
this is the feature the rest of the plumbing was built for. when C2B validation is enabled on a shortcode, Safaricom calls your validation URL before completing a payment and waits for your verdict. accept, and the payment goes through; reject, and the customer gets an error on their phone. that hook is where real businesses check that the account number a customer typed actually exists.
the register is a small model:
class KycCustomer(BaseModel):
name = models.CharField(max_length=100)
account_reference = models.CharField(max_length=20, unique=True)
msisdn = models.CharField(
max_length=12, blank=True, default="", help_text="Optional. If set, the payer's phone must also match."
)
is_active = models.BooleanField(default=True)and the validation logic reads almost like the business rule it implements:
def _kyc_validate(payload: dict) -> dict:
bill_ref = str(payload.get("BillRefNumber", "")).strip()
msisdn = str(payload.get("MSISDN", "")).strip()
customer = KycCustomer.objects.filter(account_reference__iexact=bill_ref, is_active=True).first()
if customer is None:
return REJECT_INVALID_ACCOUNT
# Production Daraja may send the MSISDN hashed/masked - only enforce it on an exact-format match.
if customer.msisdn and msisdn.isdigit() and msisdn != customer.msisdn:
return REJECT_INVALID_MSISDN
return {"ResultCode": 0, "ResultDesc": "Accepted", "ThirdPartyTransID": f"KYC-{customer.pk}"}the rejection responses use Daraja's own codes: C2B00012 for an invalid account number and C2B00011 for an invalid MSISDN. one production detail hiding in that comment: on production Daraja the MSISDN can arrive hashed rather than as digits, so the phone check only runs when the value actually looks like a phone number. otherwise every real payment would bounce.
the callback view stores the verdict on the event, in a separate decision field, so payload stays a faithful record of what Safaricom sent:
@csrf_exempt
@require_POST
@_secret_required
def c2b_validation(request):
event = _record(request, CallbackEvent.Kind.C2B_VALIDATION)
verdict = _kyc_validate(event.payload)
event.decision = verdict
event.save(update_fields=["decision"])
return JsonResponse(verdict)managing the register happens in the KYC tab, which is two HTMX endpoints: add a customer, toggle a customer active or inactive.

and here is the payoff in the activity log. i simulated two payments, one with bill reference NOPE and one with INV900 (a registered customer). the first validation callback shows a decision of C2B00012 Invalid Account Number, the second shows ResultCode: 0, Accepted with a ThirdPartyTransID pointing at the matched customer:

small things that earn their keep
a few smaller decisions that made the whole thing nicer to live with:
- phone number normalization: the KYC form accepts
07XXXXXXXX,2547XXXXXXXX, or+2547XXXXXXXXand normalizes everything to the canonical 12-digit form with one regex, so what is stored always matches what Daraja sends.
MSISDN_RE = re.compile(r"^(?:254|0)(7\d{8})$")
def clean_msisdn(self):
msisdn = self.cleaned_data["msisdn"].replace(" ", "").removeprefix("+")
if not msisdn:
return ""
match = MSISDN_RE.match(msisdn)
if not match:
raise forms.ValidationError("Enter a Kenyan mobile number as 07XXXXXXXX or 2547XXXXXXXX.")
return f"254{match.group(1)}"- case-insensitive account references: references are stored uppercase and matched with
__iexact, and a data migration uppercases existing rows, failing loudly if two rows would collapse into the same reference rather than silently merging customers. - log retention: a scheduled Celery task prunes
ApiCallandCallbackEventrecords older than 30 days. playground activity is only useful for recent debugging; past that it is just database growth.
closing thoughts
none of the individual pieces here are clever, and that is kind of the point. two models, a thin client, one generic HTMX view, and a validation function you can read in ten seconds. but together they turn Daraja integration from print-statement archaeology into something you can actually see: every request, every response, every callback, and every accept or reject decision, all in one place.
if you are building on Daraja, steal the ApiCall / CallbackEvent pattern first. future you, debugging a failed payment at 11pm, will be grateful.
resources
- Safaricom Daraja API docs: https://developer.safaricom.co.ke/
- HTMX: https://htmx.org/
- ngrok (for exposing local callbacks): https://ngrok.com/
Written and Authored by Chris, Edited and assisted by Claude
Comments