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/v1Every 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.
- Create an application and a sandbox secret key (
sk_sbx_…) on API keys. Store it on your server only. - Create a payment intent for the amount.
- Confirm it with the customer's phone. They get an M-Pesa prompt and approve with their PIN.
- Listen for
payment.succeededon 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_...| Prefix | Kind | Use |
|---|---|---|
sk_sbx_ | Secret, sandbox | Server-side testing. Simulated payments, no money moves. |
sk_live_ | Secret, live | Server-side production. Real M-Pesa payments. |
pk_… | Publishable | Browser 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.
| Scope | Allows |
|---|---|
payments:read | Read payment intents, transactions and payment links |
payments:write | Create and confirm payment intents; create payment links |
refunds:write | Create refunds |
customers:read | Look up customers |
analytics.<area>:read | One analytics area, for example analytics.revenue:read |
analytics.all:read | Every 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.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body is not valid JSON or not an object. |
| 401 | invalid_api_key | Missing, malformed or wrong key. |
| 401 | api_key_revoked / api_key_expired | The key was revoked or has expired. |
| 403 | insufficient_scope | The key lacks a scope; see details.required. |
| 403 | secret_key_in_browser | A secret key was sent from a browser. |
| 403 | ip_not_allowed / origin_not_allowed | Blocked by the key's restrictions. |
| 403 | entitlement_limit_reached / plan_feature_not_included | Your plan doesn't include this feature. |
| 404 | not_found | No such object in this key's organization and environment. |
| 409 | conflict | The object's state doesn't allow this, e.g. refunding a failed payment. |
| 409 | payment_in_progress | A prompt is already out for this intent. Wait for its result. |
| 409 | idempotency_conflict | Idempotency key reused with a different body. |
| 422 | validation_failed | A field is invalid; see details.field or details.errors. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
| 500 | internal_error | Our fault. Safe to retry with the same idempotency key. |
| 503 | dependency_unavailable | M-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
httpsURLs 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 & path | Scope | Idempotency-Key | Purpose |
|---|---|---|---|
POST /payment_intents | payments:write | Yes | Create a payment for an amount |
GET /payment_intents/{id} | payments:read | Read a payment and its attempts | |
POST /payment_intents/{id}/confirm | payments:write | Send the M-Pesa prompt to a phone | |
GET /payment_links | payments:read | List payment links | |
POST /payment_links | payments:write | Create a payment link | |
POST /refunds | refunds:write | Yes | Refund a payment in full |
GET /transactions | payments:read | List confirmed payments | |
GET /customers?phone= | customers:read | Look up a customer by phone | |
GET /analytics/{report} | analytics scopes | Read-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.
| Status | Meaning |
|---|---|
requires_payment | Created, or the last attempt failed. Confirm it to send a prompt. |
processing | A prompt is on the customer's phone. |
succeeded | Paid. transaction.mpesa_receipt holds the M-Pesa receipt. |
failed | Out of attempts or expired. Create a new intent to try again. |
payments:write| Field | Type | Description |
|---|---|---|
amountMinorrequired | integer | Amount in minor units: 100 to 25,000,000 (KES 250,000, the M-Pesa limit), a multiple of 100. |
currencyrequired | string | KES. |
reference | string | Your reference, up to 100 characters. M-Pesa shows the first 12 on the prompt and SMS. |
description | string | Up to 500 characters. |
metadata | object | Up to 20 keys and 4 KB of your own data. Returned on reads and in webhooks. |
maxAttempts | integer | Prompts allowed before the intent fails. Default 3. |
expiresInMinutes | integer | Minutes 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
}payments:write| Field | Type | Description |
|---|---|---|
phonerequired | string | The 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.
payments:readThe 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."
},
...
}| Field | Meaning |
|---|---|
payment_status | requires_payment (no prompt sent yet), pending, completed, failed, cancelled or expired. Final once it isn't pending. |
confirming | true while M-Pesa's answer is late or unclear. The customer may have paid: don't ask them to pay again. |
can_retry | true when you can confirm the intent again with another prompt. |
last_payment_error.code | Stable code to branch on; see Result codes. |
last_payment_error.result_code | Safaricom's ResultCode, when M-Pesa returned one. |
last_payment_error.title / message | Plain-language reason, safe to show your staff or customer. |
last_payment_error.mpesa_message | Safaricom's own ResultDesc, for your logs. |
Payment links
A payment link is a hosted checkout page you can send by SMS, WhatsApp or email, with no website needed. Links made with a sandbox key are sandbox links.
payments:write| Field | Type | Description |
|---|---|---|
typerequired | string | fixed, service, product, products, event, invoice, customer_amount, donation or recurring. Default fixed. |
amount | integer | Minor units. Required for every type except customer_amount and donation. |
minAmount / maxAmount | integer | customer_amount and donation: the range the payer may choose from. |
amountPresets | integer[] | customer_amount and donation: up to 6 suggested amounts. |
invoiceReference | string | invoice: required. Invoice links can be paid once. |
invoiceDueDate | string | invoice: ISO 8601 date. |
interval | string | recurring: weekly or monthly (default). Business plan and above. |
totalCycles | integer | recurring: stop after 2–120 payments. Unset means until cancelled. |
title | string | Shown on the checkout page. Up to 120 characters. |
description | string | Up to 1,000 characters. |
singleUse / maxUses | boolean / integer | Close the link after one or N payments. |
expiresAt | string | ISO 8601 date after which the link stops taking payments. |
metadata | object | Your own data. |
curl https://kulturepay.co.ke/api/v1/payment_links \
-H "Authorization: Bearer $KULTUREPAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "fixed", "title": "Consultation", "amount": 250000 }'
201 Created
{
"id": "pl_...",
"object": "payment_link",
"type": "fixed",
"url": "https://kulturepay.co.ke/l/k3j9x0a1bq",
"amount": 250000,
"status": "active",
"livemode": false,
...
}payments:readReturns the same object. You can look a link up by its short code instead, with ?code=.
Refunds
refunds:write| Field | Type | Description |
|---|---|---|
transaction_idrequired | string | The txn_… id from a payment intent or the transactions list. |
amount_minorrequired | integer | The full amount paid. M-Pesa reverses the whole receipt, so partial refunds are refused. |
reasonrequired | string | Why, 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
payments:readYour 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
customers:readFinds 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.
| Endpoint | Scope |
|---|---|
GET /analytics/transactions | analytics.transactions:read |
GET /analytics/revenue | analytics.revenue:read |
GET /analytics/refunds | analytics.refunds:read |
GET /analytics/payouts | analytics.payouts:read |
GET /analytics/fraud | analytics.fraud:read |
GET /analytics/subscriptions | analytics.subscriptions:read |
GET /analytics/products | analytics.products:read |
GET /analytics/pos | analytics.pos:read |
GET /analytics/customers | analytics.customers:read |
GET /analytics/orders | analytics:read |
GET /analytics/funnel | analytics:read |
GET /analytics/geography | analytics:read |
GET /analytics/usage | analytics: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
2xxwithin 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
idand skip events you've already handled. - Amounts in event payloads are strings of minor units (
"150000"), so very large values stay exact.
Event types
| Type | When | data.object |
|---|---|---|
payment.created | A payment intent was created. | intent_id, status, amount, currency, reference, description, expires_at |
payment.processing | The M-Pesa prompt reached the customer's phone. | intent_id, attempt_id, amount, currency |
payment.succeeded | The customer paid. Fulfil the order now. | intent_id, transaction_id, amount, currency, late_success |
payment.cancelled | The customer dismissed the prompt. They can try again while attempts remain. | intent_id, attempt_id, intent_status, reason |
payment.failed | The intent ran out of attempts or the prompt timed out. | intent_id, failure_code |
payment.reversed | A refund reversed the payment. | refund_id, transaction_id, amount, currency, status |
payment.requires_review | M-Pesa reported a different amount than requested. Held for review. | intent_id, attempt_id |
refund.created | A refund was requested and the money set aside. | refund_id, transaction_id, amount, currency, status, reason |
refund.succeeded | M-Pesa returned the money to the customer. | refund_id, transaction_id, amount, currency, status |
refund.failed | The reversal failed; the money is back in your balance. | refund_id, transaction_id, amount, currency, status, failure_reason |
order.created | A store, POS or link order was placed (not yet paid). | order_id, number, status, total, currency, store_id, source |
order.paid | The order was paid. | order_id, number, status, previous_status, total, mpesa_receipt |
order.updated | Any status change. Also sent as order.<status>: processing, shipped, delivered, cancelled, refunded. | order_id, status, previous_status, tracking_number |
order.completed | The order was delivered (digital orders complete on payment). | order_id, status, previous_status |
customer.created | A customer was added, or paid you for the first time. | customer_id, name, email, phone (masked), created_at |
customer.updated | A customer's name, email or notes changed. | customer_id, name, email, phone (masked) |
subscription.created | A customer opted into a recurring payment link and paid the first cycle. | subscription_id, status, payment_link, plan, amount, interval, next_renewal_at |
subscription.updated | A renewal was paid, the plan completed, or a renewal was missed (past_due). | subscription_id, status, current_cycle, total_cycles, next_renewal_at |
subscription.cancelled | You cancelled it, or three renewals in a row went unpaid. | subscription_id, status, canceled_at |
dispute.created | A dispute was opened and the amount is held. | dispute_id, transaction_id, amount, currency, status, reason |
dispute.closed | The dispute was resolved or withdrawn. | dispute_id, transaction_id, amount, currency, status, resolution |
payout.created | A withdrawal to your bank or M-Pesa was requested. | withdrawal_id, amount, currency, destination |
payout.pending | The withdrawal is waiting for review. | withdrawal_id, amount, currency, destination |
payout.approved | The withdrawal was approved. | withdrawal_id, amount, currency, status |
payout.completed | The money was sent. | withdrawal_id, amount, currency, status |
payout.failed | Rejected, 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.
- The intent moves to
processing, andpayment.processingfires once the prompt reaches the phone. - Approving gives
payment.succeededand dismissing givespayment.cancelled. A wrong PIN, a low balance or no answer ends the attempt, andlast_payment_errorsays which (see Result codes). - While attempts remain the intent returns to
requires_paymentand you can confirm again. When they run out it isfailedandpayment.failedfires.
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.
POST /payment_intents/{id}/confirmand showcustomer_message("Check your phone and enter your M-Pesa PIN").- Wait for the
payment.succeeded,payment.cancelledorpayment.failedwebhook. This is the reliable path: it arrives even if the customer closes your page. - For a live screen, also poll
GET /payment_intents/{id}every 3 seconds untilpayment_statusis no longerpending. completed: fulfil the order.failedorcancelled: showlast_payment_error.messageand, ifcan_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 webhookcURL
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
donePolls 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.
| ResultCode | code | payment_status | What happened |
|---|---|---|---|
0 | — | completed | Paid. The M-Pesa receipt is on transaction.mpesa_receipt. |
1032 | cancelled_by_customer | cancelled | The customer dismissed the prompt. |
1037 | prompt_timeout | failed | No answer in time, or the phone was off or out of reach. |
2001 | wrong_pin | failed | Wrong M-Pesa PIN. |
1 | insufficient_funds | failed | M-Pesa balance too low for the amount. |
8006 | pin_locked | failed | PIN locked after too many wrong tries; the customer must unlock it with Safaricom. |
17, 2028 | transaction_limit_exceeded | failed | Over the customer's transaction or daily limit, or too many requests too quickly. |
1019 | transaction_expired | failed | M-Pesa expired the request before it was completed. |
1001 | customer_busy | failed | Another M-Pesa request was already open on the customer's phone. |
1025, 9999 | prompt_not_delivered | failed | M-Pesa couldn't send the prompt to the phone. |
26 | system_busy | failed | M-Pesa was too busy. Try again shortly. |
2002 | till_paybill_mismatch | failed | The receiving Till or PayBill doesn't match the shortcode. Check your settlement account. |
none | timed_out | failed | No 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 errorCode | code | Meaning |
|---|---|---|
400.002.02, 400.002.05 | invalid_request | A field was rejected, such as the shortcode, amount or phone. |
404.001.03, 404.001.04 | provider_auth_failed | Safaricom refused the API credentials (live accounts with your own Daraja app). |
404.001.01, 405.001 | provider_misconfigured | Safaricom didn't recognise the request. Contact support. |
— | invalid_phone | Not a Safaricom number that can receive prompts. |
— | provider_unavailable | M-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
referencefor 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.
| Phone | Outcome |
|---|---|
0700000001 | Succeeds |
0700000002 | Wrong PIN; the intent returns to requires_payment |
0700000003 | Insufficient funds |
0700000004 | Customer cancels |
0700000005 | No 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}addspayment_status,last_payment_error(with Safaricom'sresult_code),latest_attempt,confirmingandcan_retry. New failure codespin_locked,prompt_not_delivered,system_busy,provider_auth_failedandprovider_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 eventspayment.created,payment.processing,payment.cancelled,order.*,customer.*andsubscription.*; dispute events now use the standard envelope.