Developers

KulturePay API

Take M-Pesa payments from your own website, app or back office. Build against the sandbox for free, then switch one key to go live.

Overview

The KulturePay API lets your website, app or back office take M-Pesa payments, create payment links, issue refunds and read your transactions. It is a JSON REST API at:

https://kulturepay.co.ke/api/v1

Every request is authenticated with a secret key and is scoped to one organization and one environment, sandbox or live. Sandbox keys see only sandbox data and never reach Safaricom; live keys move real money.

Amounts are always integers in minor units: KES 1,500 is 150000. M-Pesa only moves whole shillings, so amounts must be multiples of 100. Timestamps are ISO 8601 in UTC unless a field says otherwise.

Quickstart

Take your first sandbox payment in three calls.

  1. Create an application and a sandbox secret key (sk_sbx_…) on API keys. Store it on your server only.
  2. Create a payment intent for the amount.
  3. Confirm it with the customer's phone. They get an M-Pesa prompt and approve with their PIN.
  4. Listen for payment.succeeded on a webhook endpoint and fulfil the order.
# 1. Create the intent
curl https://kulturepay.co.ke/api/v1/payment_intents \
  -H "Authorization: Bearer $KULTUREPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{ "amountMinor": 150000, "currency": "KES", "reference": "ORDER1042" }'

# 2. Send the M-Pesa prompt (0700000001 always succeeds in the sandbox)
curl https://kulturepay.co.ke/api/v1/payment_intents/pay_.../confirm \
  -H "Authorization: Bearer $KULTUREPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "0700000001" }'

# 3. Read the result (or wait for the webhook)
curl https://kulturepay.co.ke/api/v1/payment_intents/pay_... \
  -H "Authorization: Bearer $KULTUREPAY_SECRET_KEY"

Authentication

Send your secret key as a bearer token on every request:

Authorization: Bearer sk_live_...
PrefixKindUse
sk_sbx_Secret, sandboxServer-side testing. Simulated payments, no money moves.
sk_live_Secret, liveServer-side production. Real M-Pesa payments.
pk_…PublishableBrowser or mobile use, limited to the origins you allow.

Secret keys are rejected with 403 secret_key_in_browser when a request carries an Origin header, so a key leaked into front-end code stops working instead of quietly moving money. Keep secret keys in environment variables on your server.

Scopes

Each key carries scopes. A request outside them gets 403 insufficient_scope, with the missing scopes in details.required. Give each integration only what it needs.

ScopeAllows
payments:readRead payment intents, transactions and payment links
payments:writeCreate and confirm payment intents; create payment links
refunds:writeCreate refunds
customers:readLook up customers
analytics.<area>:readOne analytics area, for example analytics.revenue:read
analytics.all:readEvery analytics area except customer PII

Keys & restrictions

Keys belong to an application (one per website, app or system you connect), and each application has a sandbox and a live environment. You can lock a key down further from API keys:

  • IP allowlist: requests from any other address get 403 ip_not_allowed. Use your servers' egress IPs or CIDR ranges.
  • Allowed origins (publishable keys): other origins get 403 origin_not_allowed.
  • Expiry: after the date the key returns 401 api_key_expired.

Rotate a key to get a new secret with the same scopes and restrictions. The old secret keeps working for 24 hours, so you can deploy the new one without downtime. Revoke a key that may have leaked: it stops working at once with 401 api_key_revoked. The full secret is shown only once, when the key is created or rotated.

Applications & sandbox

An application is one website, app or system you connect, such as your online shop or your ERP. Create them on Applications. Each one has two environments with separate keys, webhooks and data:

  • Sandbox (sk_sbx_…): test payments with the test numbers. Nothing reaches Safaricom and no money moves, but webhooks, refunds and analytics behave as in live.
  • Live (sk_live_…): real M-Pesa prompts and real money.

The environment switch in the dashboard's top bar decides which side you are looking at; exports, the wallet and analytics follow it.

Idempotency

Network calls fail and get retried. To make retries safe, send an Idempotency-Key header on POST /payment_intents and POST /refunds. A retry with the same key and the same body returns the first response instead of creating a second payment or refund.

Idempotency-Key: order-1042-attempt-1
  • Use a value that identifies the operation in your system, such as your order id. A random UUID per operation also works.
  • Reusing a key with a different body returns 409 idempotency_conflict.
  • If the first request failed with an error, the key is released and you can retry with it.

Errors

Errors use HTTP status codes and always have the same body, with a stable machine-readable code:

{
  "error": {
    "code": "validation_failed",
    "message": "amount must be a whole-shilling amount in minor units",
    "details": { "field": "amount" },
    "request_id": "req_..."
  }
}

Branch on code, not on message (messages may be reworded). Every response also has an x-request-id header. Quote it when you contact support.

StatusCodeMeaning
400invalid_requestThe body is not valid JSON or not an object.
401invalid_api_keyMissing, malformed or wrong key.
401api_key_revoked / api_key_expiredThe key was revoked or has expired.
403insufficient_scopeThe key lacks a scope; see details.required.
403secret_key_in_browserA secret key was sent from a browser.
403ip_not_allowed / origin_not_allowedBlocked by the key's restrictions.
403entitlement_limit_reached / plan_feature_not_includedYour plan doesn't include this feature.
404not_foundNo such object in this key's organization and environment.
409conflictThe object's state doesn't allow this, e.g. refunding a failed payment.
409payment_in_progressA prompt is already out for this intent. Wait for its result.
409idempotency_conflictIdempotency key reused with a different body.
422validation_failedA field is invalid; see details.field or details.errors.
429rate_limitedToo many requests. Wait for Retry-After seconds.
500internal_errorOur fault. Safe to retry with the same idempotency key.
503dependency_unavailableM-Pesa is unreachable. Retry with backoff.

Pagination

List endpoints are cursor-paginated. Pass limit (1–100, default 100) and, for the next page, the next_cursor from the previous response. Stop when has_more is false.

{ "data": [ ... ], "next_cursor": "txn_...", "has_more": true }

Rate limits

Each API key has its own limit per minute, per hour and per day (by default 100 a minute, 3,600 an hour and 50,000 a day; your key's actual limit is shown on its page under API Keys). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Over the limit you get 429 rate_limited with a Retry-After header in seconds. Back off for that long, then retry. Prefer webhooks to polling: one webhook replaces dozens of status calls.

Unauthenticated requests are also limited per client IP, so a leaked or mistyped key can't be brute-forced.

Security

  • Secret keys are stored only as a hash and shown once. Keep them in a server environment variable, never in a browser, app bundle or repository. Restrict live keys to your servers' IPs and give each key only the scopes it needs.
  • Webhooks are signed with HMAC-SHA256 (verify every one) and only sent to https URLs on public addresses.
  • M-Pesa results are never trusted on the callback alone: we ask Safaricom for the transaction's status before marking anything paid, so a forged callback can't credit you.
  • Customer data: phone numbers are encrypted at rest and masked in the dashboard, exports and events. Data never crosses between organizations or between sandbox and live.
  • Your team: turn on two-factor authentication in Settings → Security, give people the narrowest role that works, and review the audit log. Exports, refunds and settlement changes are limited to owners, administrators and Finance (Managers can also export).
  • Found a vulnerability? Report it through Support with the category Security, and please don't test against other merchants' data.

SDKs & libraries

There is no official SDK yet. The API is plain JSON over HTTPS, so any HTTP client works and the curl examples on this page translate directly. A minimal Node.js helper:

const API = "https://kulturepay.co.ke/api/v1";

async function kulturepay(path, { method = "GET", body, idempotencyKey } = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.KULTUREPAY_SECRET_KEY}`,
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error?.message), { code: data.error?.code, requestId: data.error?.request_id });
  return data;
}

const intent = await kulturepay("/payment_intents", {
  method: "POST",
  idempotencyKey: "order-1042",
  body: { amountMinor: 150000, currency: "KES", reference: "ORDER1042" },
});

Endpoint index

Every endpoint takes a secret key in Authorization: Bearer, returns JSON, and counts towards the key's rate limits. Errors use the standard error shape.

Method & pathScopeIdempotency-KeyPurpose
POST /payment_intentspayments:writeYesCreate a payment for an amount
GET /payment_intents/{id}payments:readRead a payment and its attempts
POST /payment_intents/{id}/confirmpayments:writeSend the M-Pesa prompt to a phone
GET /payment_linkspayments:readList payment links
POST /payment_linkspayments:writeCreate a payment link
POST /refundsrefunds:writeYesRefund a payment in full
GET /transactionspayments:readList confirmed payments
GET /customers?phone=customers:readLook up a customer by phone
GET /analytics/{report}analytics scopesRead-only reports (see Analytics)

Paths are relative to https://kulturepay.co.ke/api/v1. Reads are safe to retry; for a write without an idempotency key, read the resource before retrying.

Payment intents

A payment intent is one amount you want to collect. You create it, then confirm it with a phone number, which sends the M-Pesa prompt. If the customer cancels or enters a wrong PIN, the intent goes back to requires_payment and you can confirm again, up to maxAttempts.

StatusMeaning
requires_paymentCreated, or the last attempt failed. Confirm it to send a prompt.
processingA prompt is on the customer's phone.
succeededPaid. transaction.mpesa_receipt holds the M-Pesa receipt.
failedOut of attempts or expired. Create a new intent to try again.
POST/api/v1/payment_intentsscope payments:write
FieldTypeDescription
amountMinorrequiredintegerAmount in minor units: 100 to 25,000,000 (KES 250,000, the M-Pesa limit), a multiple of 100.
currencyrequiredstringKES.
referencestringYour reference, up to 100 characters. M-Pesa shows the first 12 on the prompt and SMS.
descriptionstringUp to 500 characters.
metadataobjectUp to 20 keys and 4 KB of your own data. Returned on reads and in webhooks.
maxAttemptsintegerPrompts allowed before the intent fails. Default 3.
expiresInMinutesintegerMinutes until the intent expires. Default 30.
201 Created
{
  "id": "pay_...",
  "object": "payment_intent",
  "status": "requires_payment",
  "amount": 150000,
  "currency": "KES",
  "expires_at": "2026-10-21T10:30:00.000Z",
  "livemode": false
}
POST/api/v1/payment_intents/{id}/confirmscope payments:write
FieldTypeDescription
phonerequiredstringThe payer's Safaricom number: 07…, 01…, 2547… or +2547….
202 Accepted
{
  "id": "pay_...",
  "object": "payment",
  "status": "processing",
  "attempt": { "id": "att_...", "status": "sent" },
  "customer_message": "Check your phone and enter your M-Pesa PIN.",
  "livemode": false
}

Show customer_message to the payer. A second confirm while a prompt is out returns 409 payment_in_progress.

GET/api/v1/payment_intents/{id}scope payments:read

The intent and how its latest M-Pesa prompt ended. Act on payment_status; when a prompt didn't go through, last_payment_error says why (cancelled, wrong PIN, low balance, no answer, ...). See Waiting for the result.

200 OK
{
  "id": "pay_...",
  "object": "payment_intent",
  "status": "succeeded",
  "payment_status": "completed",
  "confirming": false,
  "can_retry": false,
  "last_payment_error": null,
  "latest_attempt": {
    "id": "att_...",
    "status": "succeeded",
    "checkout_request_id": "ws_CO_27102026200144931799727778",
    "created_at": "...",
    "completed_at": "..."
  },
  "amount": 150000,
  "currency": "KES",
  "reference": "ORDER1042",
  "description": null,
  "metadata": { "order_id": "1042" },
  "created_at": "...",
  "completed_at": "...",
  "expires_at": "...",
  "transaction": { "id": "txn_...", "mpesa_receipt": "SJK4H7Y2QX", "net": 148500 },
  "livemode": true
}

After a wrong PIN, with attempts left:

{
  "id": "pay_...",
  "status": "requires_payment",
  "payment_status": "failed",
  "can_retry": true,
  "last_payment_error": {
    "code": "wrong_pin",
    "result_code": 2001,
    "title": "Wrong PIN",
    "message": "The customer entered an incorrect M-Pesa PIN. They can pay again with the right PIN.",
    "mpesa_message": "The initiator information is invalid."
  },
  ...
}
FieldMeaning
payment_statusrequires_payment (no prompt sent yet), pending, completed, failed, cancelled or expired. Final once it isn't pending.
confirmingtrue while M-Pesa's answer is late or unclear. The customer may have paid: don't ask them to pay again.
can_retrytrue when you can confirm the intent again with another prompt.
last_payment_error.codeStable code to branch on; see Result codes.
last_payment_error.result_codeSafaricom's ResultCode, when M-Pesa returned one.
last_payment_error.title / messagePlain-language reason, safe to show your staff or customer.
last_payment_error.mpesa_messageSafaricom's own ResultDesc, for your logs.

Refunds

POST/api/v1/refundsscope refunds:write
FieldTypeDescription
transaction_idrequiredstringThe txn_… id from a payment intent or the transactions list.
amount_minorrequiredintegerThe full amount paid. M-Pesa reverses the whole receipt, so partial refunds are refused.
reasonrequiredstringWhy, for your records and the customer's.
curl https://kulturepay.co.ke/api/v1/refunds \
  -H "Authorization: Bearer $KULTUREPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund-1042" \
  -d '{ "transaction_id": "txn_...", "amount_minor": 150000, "reason": "Returned item" }'

201 Created
{ "id": "rfd_...", "object": "refund", "transaction_id": "txn_...", "status": "processing", "amount": 150000, "currency": "KES", "failure_reason": null }

The refund is sent to M-Pesa as a reversal of the original receipt, so the customer gets the money back on the number that paid. Your share comes out of your wallet (on hold or available) and the fee is returned; the status moves from processing to succeeded or failed, and you get the refund.succeeded or refund.failed webhook. A failed refund puts the money back.

Only succeeded transactions can be refunded (409 conflict otherwise), once each, and not while a dispute is open. Payments that went straight to your own till or PayBill are refunded from your M-Pesa account instead. In the sandbox refunds succeed at once.

Transactions

GET/api/v1/transactions?limit=100&cursor=…scope payments:read

Your settled payments, newest first. Paginated. Each one shows where the money went: net = gross − processing_fee − platform_fee − tax.

{
  "data": [{
    "id": "txn_...",
    "status": "succeeded",
    "gross": 150000,
    "processing_fee": 1500,
    "platform_fee": 0,
    "tax": 0,
    "net": 148500,
    "currency": "KES",
    "provider": "daraja",
    "provider_reference": "SJK4H7Y2QX",
    "created_at": "...",
    "timeline": [ ... ]
  }],
  "next_cursor": "...",
  "has_more": false
}

Customers

GET/api/v1/customers?phone=0712345678scope customers:read

Finds a customer who has paid you, by phone number. Phone numbers are stored hashed, so you can look one up but never list them.

{ "customer": { "id": "...", "object": "customer", "name": "Jane W.", "email": null, "created_at": "..." } }

customer is null when nobody with that number has paid you.

Analytics

Read-only reporting endpoints for dashboards and BI tools. Available on the Company and Organization plans (otherwise 403 plan_feature_not_included). Each needs its own scope, or analytics.all:read.

EndpointScope
GET /analytics/transactionsanalytics.transactions:read
GET /analytics/revenueanalytics.revenue:read
GET /analytics/refundsanalytics.refunds:read
GET /analytics/payoutsanalytics.payouts:read
GET /analytics/fraudanalytics.fraud:read
GET /analytics/subscriptionsanalytics.subscriptions:read
GET /analytics/productsanalytics.products:read
GET /analytics/posanalytics.pos:read
GET /analytics/customersanalytics.customers:read
GET /analytics/ordersanalytics:read
GET /analytics/funnelanalytics:read
GET /analytics/geographyanalytics:read
GET /analytics/usageanalytics:read

All take from and to (ISO 8601). /analytics/revenue also takes group_by (hour, day, month, quarter or year), and list-shaped results take limit and cursor.

Webhooks

Webhooks tell your server when something happens, such as a payment succeeding, so you don't have to poll. Add an endpoint URL on Webhooks for each environment. We POST each event as JSON:

POST https://your-server.example/webhooks/kulturepay
Content-Type: application/json
Webhook-Event-Id: evt_...
Webhook-Delivery-Id: ...
Webhook-Attempt: 1
Webhook-Livemode: true
Signature: t=1761040800,v1=5f2b...

{
  "id": "evt_...",
  "object": "event",
  "type": "payment.succeeded",
  "api_version": "v1",
  "created": 1761040800,
  "livemode": true,
  "data": {
    "object": {
      "intent_id": "pay_...",
      "transaction_id": "txn_...",
      "amount": "150000",
      "currency": "KES",
      "late_success": false
    }
  }
}
  • Answer with any 2xx within 30 seconds; slower answers count as failures. Respond first, then do slow work.
  • Anything else is retried with exponential backoff, 5 attempts in all. After that the delivery is marked failed and you can resend it from the dashboard, so reconcile with GET /payment_intents/{id} if your server was down.
  • The same event can arrive more than once, and events can arrive out of order. Store id and skip events you've already handled.
  • Amounts in event payloads are strings of minor units ("150000"), so very large values stay exact.

Event types

TypeWhendata.object
payment.createdA payment intent was created.intent_id, status, amount, currency, reference, description, expires_at
payment.processingThe M-Pesa prompt reached the customer's phone.intent_id, attempt_id, amount, currency
payment.succeededThe customer paid. Fulfil the order now.intent_id, transaction_id, amount, currency, late_success
payment.cancelledThe customer dismissed the prompt. They can try again while attempts remain.intent_id, attempt_id, intent_status, reason
payment.failedThe intent ran out of attempts or the prompt timed out.intent_id, failure_code
payment.reversedA refund reversed the payment.refund_id, transaction_id, amount, currency, status
payment.requires_reviewM-Pesa reported a different amount than requested. Held for review.intent_id, attempt_id
refund.createdA refund was requested and the money set aside.refund_id, transaction_id, amount, currency, status, reason
refund.succeededM-Pesa returned the money to the customer.refund_id, transaction_id, amount, currency, status
refund.failedThe reversal failed; the money is back in your balance.refund_id, transaction_id, amount, currency, status, failure_reason
order.createdA store, POS or link order was placed (not yet paid).order_id, number, status, total, currency, store_id, source
order.paidThe order was paid.order_id, number, status, previous_status, total, mpesa_receipt
order.updatedAny status change. Also sent as order.<status>: processing, shipped, delivered, cancelled, refunded.order_id, status, previous_status, tracking_number
order.completedThe order was delivered (digital orders complete on payment).order_id, status, previous_status
customer.createdA customer was added, or paid you for the first time.customer_id, name, email, phone (masked), created_at
customer.updatedA customer's name, email or notes changed.customer_id, name, email, phone (masked)
subscription.createdA customer opted into a recurring payment link and paid the first cycle.subscription_id, status, payment_link, plan, amount, interval, next_renewal_at
subscription.updatedA renewal was paid, the plan completed, or a renewal was missed (past_due).subscription_id, status, current_cycle, total_cycles, next_renewal_at
subscription.cancelledYou cancelled it, or three renewals in a row went unpaid.subscription_id, status, canceled_at
dispute.createdA dispute was opened and the amount is held.dispute_id, transaction_id, amount, currency, status, reason
dispute.closedThe dispute was resolved or withdrawn.dispute_id, transaction_id, amount, currency, status, resolution
payout.createdA withdrawal to your bank or M-Pesa was requested.withdrawal_id, amount, currency, destination
payout.pendingThe withdrawal is waiting for review.withdrawal_id, amount, currency, destination
payout.approvedThe withdrawal was approved.withdrawal_id, amount, currency, status
payout.completedThe money was sent.withdrawal_id, amount, currency, status
payout.failedRejected, cancelled or failed. See failure_reason.withdrawal_id, amount, currency, status, failure_reason

late_success: true means M-Pesa confirmed a payment after the intent had expired or failed. The money was received and recorded, so fulfil the order or refund it.

Verifying signatures

Anyone can POST to your URL, so check every request. The Signature header holds a Unix timestamp t and v1, the hex HMAC-SHA256 of `${t}.${rawBody}` keyed with your endpoint's signing secret. Use the raw body bytes as received, not re-serialised JSON, and reject timestamps older than 5 minutes to stop replays.

Node.js

import crypto from "node:crypto";

export function verifyKulturePay(secret, signatureHeader, rawBody) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: keep the raw body for this route.
app.post("/webhooks/kulturepay", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyKulturePay(process.env.KULTUREPAY_WEBHOOK_SECRET, req.get("Signature"), req.body.toString())) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(200); // respond first, then process
  handleEvent(event);
});

Python

import hashlib, hmac, time

def verify_kulturepay(secret: str, signature_header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts.get("v1", ""))

PHP

function verify_kulturepay(string $secret, string $header, string $rawBody): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return abs(time() - (int) $parts['t']) < 300 && hash_equals($expected, $parts['v1'] ?? '');
}

$ok = verify_kulturepay(getenv('KULTUREPAY_WEBHOOK_SECRET'), $_SERVER['HTTP_SIGNATURE'], file_get_contents('php://input'));

STK Push

STK Push is how most payments happen: you POST /payment_intents/{id}/confirm with the customer's phone and Safaricom shows them a prompt with the amount and your business name. They approve with their M-Pesa PIN.

  1. The intent moves to processing, and payment.processing fires once the prompt reaches the phone.
  2. Approving gives payment.succeeded and dismissing gives payment.cancelled. A wrong PIN, a low balance or no answer ends the attempt, and last_payment_error says which (see Result codes).
  3. While attempts remain the intent returns to requires_payment and you can confirm again. When they run out it is failed and payment.failed fires.

Only one prompt can be open per intent: a second confirm while one is pending returns 409 payment_in_progress.

Waiting for the result

After a confirm, the prompt is on the customer's phone. Never treat the 202 as paid: wait for M-Pesa's answer, which usually comes within 10–60 seconds.

  1. POST /payment_intents/{id}/confirm and show customer_message ("Check your phone and enter your M-Pesa PIN").
  2. Wait for the payment.succeeded, payment.cancelled or payment.failed webhook. This is the reliable path: it arrives even if the customer closes your page.
  3. For a live screen, also poll GET /payment_intents/{id} every 3 seconds until payment_status is no longer pending.
  4. completed: fulfil the order. failed or cancelled: show last_payment_error.message and, if can_retry, offer to send the prompt again. expired: create a new intent.

Stop polling after about two minutes, but don't mark the payment failed: tell the customer it's still being confirmed. We keep asking Safaricom in the background, and a payment that went through is always recorded, with late_success: true if it arrives after the intent ended.

Node.js

async function waitForPayment(id, { everyMs = 3000, timeoutMs = 120000 } = {}) {
  const until = Date.now() + timeoutMs;
  while (Date.now() < until) {
    const res = await fetch(`https://kulturepay.co.ke/api/v1/payment_intents/${id}`, {
      headers: { Authorization: `Bearer ${process.env.KULTUREPAY_SECRET_KEY}` },
    });
    const intent = await res.json();
    if (intent.payment_status !== "pending") return intent; // completed | failed | cancelled | expired
    await new Promise((r) => setTimeout(r, everyMs));
  }
  return null; // still confirming: rely on the webhook, don't charge again
}

const intent = await waitForPayment("pay_...");
if (intent?.payment_status === "completed") fulfil(intent.transaction.mpesa_receipt);
else if (intent) showError(intent.last_payment_error?.message, intent.can_retry);

Python

import os, time, requests

def wait_for_payment(intent_id, every=3, timeout=120):
    headers = {"Authorization": f"Bearer {os.environ['KULTUREPAY_SECRET_KEY']}"}
    deadline = time.time() + timeout
    while time.time() < deadline:
        intent = requests.get(f"https://kulturepay.co.ke/api/v1/payment_intents/{intent_id}", headers=headers, timeout=10).json()
        if intent["payment_status"] != "pending":
            return intent  # completed | failed | cancelled | expired
        time.sleep(every)
    return None  # still confirming: rely on the webhook

cURL

for i in $(seq 1 40); do
  STATUS=$(curl -s https://kulturepay.co.ke/api/v1/payment_intents/pay_... \
    -H "Authorization: Bearer $KULTUREPAY_SECRET_KEY" | jq -r '.payment_status')
  echo "$STATUS"
  [ "$STATUS" != "pending" ] && break
  sleep 3
done

Polls count toward your key's rate limit, so poll only while a customer is watching, and no faster than every 3 seconds per payment.

Result codes

Safaricom ends every STK prompt with a ResultCode. We map it to a stable last_payment_error.code (also the failure_code on payment.failed) and a plain-language message. Branch on code; the numeric result_code is there for your logs.

ResultCodecodepayment_statusWhat happened
0—completedPaid. The M-Pesa receipt is on transaction.mpesa_receipt.
1032cancelled_by_customercancelledThe customer dismissed the prompt.
1037prompt_timeoutfailedNo answer in time, or the phone was off or out of reach.
2001wrong_pinfailedWrong M-Pesa PIN.
1insufficient_fundsfailedM-Pesa balance too low for the amount.
8006pin_lockedfailedPIN locked after too many wrong tries; the customer must unlock it with Safaricom.
17, 2028transaction_limit_exceededfailedOver the customer's transaction or daily limit, or too many requests too quickly.
1019transaction_expiredfailedM-Pesa expired the request before it was completed.
1001customer_busyfailedAnother M-Pesa request was already open on the customer's phone.
1025, 9999prompt_not_deliveredfailedM-Pesa couldn't send the prompt to the phone.
26system_busyfailedM-Pesa was too busy. Try again shortly.
2002till_paybill_mismatchfailedThe receiving Till or PayBill doesn't match the shortcode. Check your settlement account.
nonetimed_outfailedNo result arrived before the attempt expired.

Other codes come through as daraja_<code> with Safaricom's own description in mpesa_message.

Still processing is not a failure

While the customer is still on the prompt, Safaricom answers status checks with 4999 ("still under processing") or 500.001.1001 ("being processed"). We keep these pending and check again, so a slow customer is never marked failed. Note that 2001 means wrong PIN only for STK Push; Safaricom uses the same number for other things in other APIs.

Refused before the prompt

If Safaricom refuses the request itself, no prompt is sent and the customer is never charged. The confirm call answers 202 with attempt.status: "failed", and last_payment_error.code is one of:

Daraja errorCodecodeMeaning
400.002.02, 400.002.05invalid_requestA field was rejected, such as the shortcode, amount or phone.
404.001.03, 404.001.04provider_auth_failedSafaricom refused the API credentials (live accounts with your own Daraja app).
404.001.01, 405.001provider_misconfiguredSafaricom didn't recognise the request. Contact support.
—invalid_phoneNot a Safaricom number that can receive prompts.
—provider_unavailableM-Pesa didn't respond, so nothing was sent. The confirm call returns 503 dependency_unavailable; try again shortly.

C2B (PayBill)

Customers can also pay without a prompt, from their phone's M-Pesa menu (Lipa na M-Pesa → Pay Bill). To match the money to a payment, they enter as the account number either:

  • the payment's ID (pay_…), on KulturePay's shared PayBill; or
  • your own reference for the payment, on a PayBill you connected yourself.

A matched payment goes through the same checks as STK Push (the amount must match) and fires the same events. Money that matches nothing is never dropped: it is recorded as unallocated and our team follows it up.

Status, balance & reversal

Transaction status

Use GET /payment_intents/{id} or the webhooks. When Safaricom's answer is late or unclear we keep asking them in the background, so a payment that really went through is always recorded, even hours later; it then has late_success: true.

Balance

Money paid to your own Till, PayBill or bank account is in that account, so check it with Safaricom or your bank. Money KulturePay collected for you is in your wallet.

Reversal

Refunds are M-Pesa reversals of the original payment, always for the full amount: see Refunds. A payment that went straight to your own account is reversed by you, from that account.

Callbacks

Safaricom sends its results to KulturePay, not to you, so you never configure or register Daraja callback URLs. We pass each result on as a webhook to your server, signed so you can verify it; that is the only notification you need to handle.

A callback can be forged, so we never take one at its word:

  • Every prompt gets its own secret callback address, so a result can only land on the payment it belongs to.
  • Before anything is marked paid, we ask Safaricom directly for the payment's status. A "paid" callback that Safaricom doesn't confirm is never credited, and is flagged for review.
  • Each result is applied exactly once, even if Safaricom sends it twice or the status check and the callback arrive together.
  • If a callback never comes, we keep checking with Safaricom until the payment is settled, so no payment is lost.

For reference, this is what Safaricom sends us for a successful prompt:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_27102026200144931799727778",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 1500 },
          { "Name": "MpesaReceiptNumber", "Value": "SJK4H7Y2QX" },
          { "Name": "TransactionDate", "Value": 20261027200205 },
          { "Name": "PhoneNumber", "Value": 254712345678 }
        ]
      }
    }
  }
}

A failed or cancelled prompt has a non-zero ResultCode and no CallbackMetadata. You receive the same outcome as payment_status and last_payment_error; the CheckoutRequestID is on latest_attempt.checkout_request_id if Safaricom support asks for it.

Payment pages & links

No code needed. A payment page is a hosted checkout at /p/your-page with your branding, a fixed or customer-chosen amount and an M-Pesa prompt. A payment link (/l/…) is a shareable link for one product, invoice or donation; create them in the dashboard or with POST /payment_links. Both fire the same payment events as the API.

QR codes

A QR code opens a payment page or link on the customer's phone (/q/…). Print it on a counter, receipt or poster.

Store

The store is a hosted shop for your products, with a cart, M-Pesa checkout, stock, discount codes and delivery options, on our domain or your own. Each order fires order.created, then order.paid and a status event as you fulfil it.

POS

The point of sale runs in the browser at your counter. Open a register session, ring up products, and send an M-Pesa prompt to the customer's phone or show a scan-to-pay code; the till shows whether they paid, cancelled or entered a wrong PIN. Sales update stock and appear with your other orders and payments.

Subscriptions

Make a payment link recurring (weekly or monthly) and customers can opt in when they pay. M-Pesa can't be charged silently, so each renewal is a prompt the customer approves. A missed renewal is asked again about once a day; after three misses the subscription is cancelled. Follow them on Subscriptions and with the subscription.* events.

Settlements

Add your own Buy Goods Till, PayBill or bank account on Settlements and make it your default. From then on every payment, from any channel, goes straight to it: there is nothing to verify, nothing is held and nothing needs withdrawing. The page explains how to get a Till, PayBill or bank account if you don't have one.

No account of your own? Join Sharp Deals and collect through the platform's tills and PayBills for a small fee; the money is credited to your wallet. If you also have an account, you choose whether payments go to it first or all through Sharp Deals, in which case your account only receives withdrawals. Your default account is also where withdrawals are paid, and you can remove it at any time.

Wallet & holds

Without an account of your own (or on Sharp Deals), KulturePay collects for you and keeps your share in a wallet. Each payment is pending for a holding period, usually 12–48 hours, because a reversal, refund, dispute or chargeback can still change it, then becomes available. The Wallet page shows the balances, totals and the full ledger. The hold is a risk policy, not a promise of automatic payout.

Withdrawals

Withdraw your available balance on Withdrawals. The amount is reserved at once, our team reviews the request, and the money is sent to your chosen account. Minimums and daily limits are shown when you request. The payout.* events follow each step.

Testing

With a sandbox key the phone number decides the outcome. Nothing reaches Safaricom and no money moves; webhooks fire exactly as in live.

PhoneOutcome
0700000001Succeeds
0700000002Wrong PIN; the intent returns to requires_payment
0700000003Insufficient funds
0700000004Customer cancels
0700000005No answer; times out

The Sandbox page runs each case from the dashboard and shows the webhooks it sent.

Go-live checklist

  • Every path through your integration has been tested with the sandbox numbers, including failures and timeouts.
  • You fulfil orders from the payment.succeeded webhook (or a GET), never from the browser redirect alone.
  • Webhook signatures are verified and duplicate event ids are ignored.
  • POSTs that move money send an Idempotency-Key.
  • The live secret key is in a server environment variable, restricted to your servers' IPs, and not committed to source control.
  • You log x-request-id on errors so support can trace them.
  • A live webhook endpoint is configured (sandbox endpoints don't receive live events).

Changelog

The API is v1. A change that could break an integration gets a new version; everything below is additive.

  • 27 October 2026: GET /payment_intents/{id} adds payment_status, last_payment_error (with Safaricom's result_code), latest_attempt, confirming and can_retry. New failure codes pin_locked, prompt_not_delivered, system_busy, provider_auth_failed and provider_misconfigured. A "still processing" answer from Safaricom (4999) now stays pending instead of failing.
  • October 2026: full refunds by M-Pesa reversal; per-key rate limits with X-RateLimit-* headers; new events payment.created, payment.processing, payment.cancelled, order.*, customer.* and subscription.*; dispute events now use the standard envelope.