- Create an account — sign up to get access to your dashboard. It's free.
- Generate an API key — from Settings → API Keys. You'll get a key starting with
sp_. Store it securely; it's only shown once. - Make your first charge — POST to
/api/v1/payments/stk-pushwith the customer's phone, amount, and (optionally) your order reference.
https://api.sunpay.co.ke/api/v1Every request needs your API key in the Authorization header:
Keep your API key on your server. Never expose it in mobile apps or browser code — anyone with the key can charge customers on your behalf.
SunPay supports two ways to collect from customers. Pick whichever fits your flow — both produce the same webhook payload.
STK Push (prompt the phone)
/api/v1/payments/stk-pushSends an M-Pesa prompt to the customer's phone. They tap PIN to pay.
| Field | Type | Required | Description |
|---|---|---|---|
phoneNumber | string | Yes | Customer phone, format 254XXXXXXXXX |
amount | number | Yes | Amount in KES (minimum 1) |
externalRef | string | No | Your order or invoice ID |
callbackUrl | string | No | URL to notify when payment finishes (one-off) |
settleTo | object | No | Auto-forward net amount to a till/paybill (see Inline Settlement below) |
Example request
curl -X POST https://api.sunpay.co.ke/api/v1/payments/stk-push \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "254712345678",
"amount": 100,
"externalRef": "ORDER-123",
"callbackUrl": "https://your-site.com/webhook"
}'Response
{
"success": true,
"message": "Success. Request accepted for processing",
"transactionId": "uuid-transaction-id",
"checkoutRequestId": "ws_CO_16042026..."
}C2B / Paybill (customer initiates from M-Pesa menu)
Ideal for POS systems. You create a pending payment, display the account number to the customer, and they pay directly via M-Pesa > Paybill — no STK prompt required.
/api/v1/payments/expectCreate a pending payment that will be matched when the customer pays via Paybill.
Example request
curl -X POST https://api.sunpay.co.ke/api/v1/payments/expect \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"amount": 2500,
"externalRef": "INV-2024-001",
"callbackUrl": "https://your-pos.com/webhook"
}'Response
{
"success": true,
"transactionId": "uuid",
"externalRef": "INV-2024-001",
"amount": "2500",
"instructions": {
"paybill": "123456",
"accountNumber": "INV-2024-001",
"amount": 2500
}
}Full POS flow
# C2B Payment Flow for POS
1. POS calls POST /api/v1/payments/expect with amount and externalRef
2. Display the externalRef as "Account Number" on POS screen
3. Customer pays via M-Pesa Paybill:
- Lipa na M-Pesa > Paybill
- Enter Paybill: [Your Shortcode]
- Account Number: [Same externalRef from step 1]
- Amount: [Bill amount]
4. SunPay matches payment by Account Number
5. POS receives webhook (or polls /api/v1/payments/:id)How payment matching works (3-step lookup)
When a customer pays via Paybill, SunPay runs this lookup before money moves. Unknown account numbers are rejected and the payment bounces back automatically.
| Priority | Match | Result |
|---|---|---|
| 1st | Pending tx with exact account number | Complete that tx, fire callbacks & webhooks |
| 2nd | Exact Merchant Reference (e.g. SP1234) | Create a new tx, fire webhooks |
| 3rd | Registered Paybill/Till as account (e.g. 998877) — see Sub-Merchants | Route to that sub-merchant, fire webhooks |
| 4th | Separator match (e.g. 5T0042) | Create tx with subReference for tenant routing |
| None | No match found | Reject — payment bounces back |
Direct payment by merchant reference
Every account has a unique merchant ref (e.g. SP1234) shown in Settings → Profile. Customers can pay directly to your Paybill using your ref as the account number — no API call needed first. SunPay auto-creates a completed transaction and fires your webhooks.
Multi-till routing via C2B Separator
Set a separator in Settings → Profile to split account numbers like 5T0042 into a merchant ref + sub-reference (returned as subReference in the webhook).
# Multi-tenant / multi-till routing
#
# In Settings → Profile, set your C2B Separator to e.g. "5T"
# SunPay splits incoming account numbers:
# Account Number: "5T0042"
# → Merchant ref: "0042" (matched to your account)
# → Sub-reference: "5T" (returned in webhook so you know which till)POS tip: use a unique invoice/receipt number as externalRef and display the Paybill + account number on the POS screen.
Add a settleTo object to any payment request and SunPay will automatically forward the net amount (after fee) to the destination as soon as the customer pays. Perfect for POS SaaS partners routing money to each shop.
| Field | Type | Required | Description |
|---|---|---|---|
settleTo.type | "till" | "paybill" | Yes | Kind of destination |
settleTo.shortcode | string | Yes | Till or Paybill number (4–9 digits) |
settleTo.accountReference | string | No | Required for paybill. Shows on the receiver's statement. |
STK Push with inline settlement (paybill)
curl -X POST https://api.sunpay.co.ke/api/v1/payments/stk-push \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "254712345678",
"amount": 1000,
"externalRef": "ORDER-123",
"settleTo": {
"type": "paybill",
"shortcode": "400200",
"accountReference": "SHOP-42"
}
}'Settling to a Till
# Forwarding to a Till instead of a Paybill
{
"amount": 1000,
"phoneNumber": "254712345678",
"settleTo": {
"type": "till",
"shortcode": "555888"
}
}- Customer pays the full amount (e.g. KES 1000) into your SunPay shortcode.
- SunPay deducts the platform fee (default 1.5%).
- SunPay B2B-forwards the net (e.g. KES 985) to the till/paybill in
settleTo. - You receive a
settlement.completed(or.failed) webhook.
Settlement webhook payload
{
"event": "settlement.completed",
"timestamp": "2026-05-26T10:30:00.000Z",
"data": {
"transactionId": "uuid-transaction-id",
"settlementRef": "QJG7A5BKLN",
"amount": "985",
"settleToShortcode": "400200",
"settleToType": "paybill",
"settleToAccountReference": "SHOP-42",
"resultCode": 0,
"resultDesc": "The service request is processed successfully."
}
}Omit settleTo and SunPay falls back to your account's default settlement destination (Settings → Profile). Both /api/v1/payments/stk-push and /api/v1/payments/expect accept it.
Business → Business (B2B)
Transfer funds from your business to another business's Paybill or Till. Use for supplier payments and inter-company transfers.
/api/v1/payments/b2b| Field | Type | Required | Description |
|---|---|---|---|
receiverShortcode | string | Yes | Receiver's Paybill or Till number |
amount | number | Yes | Amount in KES |
accountReference | string | Yes | Account number for the receiver |
remarks | string | No | Description of the payment |
commandId | string | No | BusinessPayBill, BusinessBuyGoods, or MerchantToMerchantTransfer |
receiverIdentifierType | string | No | "4" for Paybill, "2" for Till |
Example request
curl -X POST https://api.sunpay.co.ke/api/v1/payments/b2b \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"receiverShortcode": "600000",
"amount": 5000,
"accountReference": "SUPPLIER-PAY-001",
"remarks": "Payment for goods",
"commandId": "BusinessPayBill"
}'Response
{
"success": true,
"message": "B2B payment initiated",
"conversationId": "AG_20260125_...",
"originatorConversationId": "uuid..."
}Business → Customer (B2C)
Send money from your business to a customer's M-Pesa wallet. Ideal for salaries, refunds, cashback, and disbursements.
/api/v1/payments/b2c| Field | Type | Required | Description |
|---|---|---|---|
phoneNumber | string | Yes | Recipient phone, 254XXXXXXXXX |
amount | number | Yes | Amount in KES |
remarks | string | No | Description of the payment |
occasion | string | No | Occasion or reason |
Example request
curl -X POST https://api.sunpay.co.ke/api/v1/payments/b2c \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "254712345678",
"amount": 1000,
"remarks": "Salary payment",
"occasion": "January Salary"
}'Response
{
"success": true,
"message": "B2C payment initiated",
"conversationId": "AG_20260125_...",
"originatorConversationId": "uuid..."
}B2C requires float in your B2C wallet. Ask Safaricom to enable B2C on your shortcode if it isn't already.
Reversal (refund)
Reverse a completed M-Pesa transaction. Use for refunds or to correct erroneous payments. Only transactions where you are the credit party can be reversed.
/api/v1/payments/reversal| Field | Type | Required | Description |
|---|---|---|---|
transactionId | string | Yes | Original M-Pesa transaction ID (e.g. QJG7A5BKLN) |
amount | number | Yes | Amount to reverse in KES |
receiverParty | string | Yes | Your shortcode (where funds return) |
remarks | string | No | Reason for reversal |
occasion | string | No | Additional context |
Example request
curl -X POST https://api.sunpay.co.ke/api/v1/payments/reversal \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"transactionId": "QJG7A5BKLN",
"amount": 100,
"receiverParty": "839377",
"remarks": "Refund for order cancellation"
}'Response
{
"success": true,
"message": "Reversal initiated successfully",
"conversationId": "AG_20260125_..."
}Reversals may require manual authorization from Safaricom. Customer-initiated reversals must be requested within 24 hours.
/api/v1/payments/:idLook up the current status of a payment by transaction ID.
Example request
curl https://api.sunpay.co.ke/api/v1/payments/{transaction_id} \
-H "Authorization: Bearer sp_your_api_key_here"Response
{
"id": "uuid-transaction-id",
"status": "completed",
"amount": "100.00",
"phoneNumber": "254712345678",
"mpesaRef": "QJG7A5BKLN",
"createdAt": "2024-01-22T10:30:00Z"
}Possible status values
SunPay notifies you of payment results two ways. They use the same payload data, but the wrapping and signing differ.
Registered webhooks
Set up in Settings → Webhooks. Fire for every payment, signed with HMAC-SHA256 in the X-Webhook-Signature header. Wrapped in a {event, timestamp, data} envelope.
Per-transaction callbackUrl
Pass callbackUrl in your request. Fires only for that transaction. Plain POST with no signature — the payload is the data object directly.
Registered webhook payload
// Registered webhooks (Settings → Webhooks)
// Signed with HMAC-SHA256 in X-Webhook-Signature header
{
"event": "payment.completed",
"timestamp": "2026-03-16T14:22:05.123Z",
"data": {
"transactionId": "uuid-transaction-id",
"externalRef": "ORDER-123",
"subReference": null,
"status": "completed",
"paymentType": "stk_push",
"mpesaRef": "QJG7A5BKLN",
"amount": "100.00",
"phoneNumber": "254712345678",
"payerName": "JOHN DOE",
"resultCode": 0,
"resultDesc": "The service request is processed successfully."
}
}Per-transaction callbackUrl payload
// Per-transaction callbackUrl (passed in your API request)
// Plain POST, no signature — just the data object
{
"transactionId": "uuid-transaction-id",
"externalRef": "ORDER-123",
"subReference": null,
"status": "completed",
"paymentType": "stk_push",
"mpesaRef": "QJG7A5BKLN",
"amount": "100.00",
"phoneNumber": "254712345678",
"payerName": "JOHN DOE",
"resultCode": 0,
"resultDesc": "The service request is processed successfully."
}Payload fields
| Field | Type | Required | Description |
|---|---|---|---|
transactionId | string | No | SunPay internal transaction ID |
externalRef | string | No | Your reference, or the M-Pesa account number for direct C2B |
subReference | string | No | Tenant ID from separator routing (null if not used) |
status | string | No | completed or failed |
paymentType | string | No | stk_push for STK Push, c2b for Paybill, b2c for payouts |
mpesaRef | string | No | M-Pesa transaction code (e.g. QJG7A5BKLN) |
amount | string | No | Amount paid in KES |
phoneNumber | string | No | Payer's phone (254XXXXXXXXX) |
payerName | string | No | Payer's full name from M-Pesa |
resultCode | number | No | M-Pesa result code (0 = success) |
resultDesc | string | No | Human-readable result description |
Verifying signatures (Node.js)
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, webhookSecret) {
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-webhook-signature'];
if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(req.body);
console.log('Event:', event, 'Ref:', data.externalRef);
res.sendStatus(200);
});Events you can subscribe to
Tip: always confirm payments by polling GET /api/v1/payments/:id in addition to webhooks, in case your server was unreachable when the webhook fired.
If you operate a POS SaaS, you can register each shop you onboard as a sub-merchant under your SunPay account. The shop's own Till/Paybill number becomes the Account Number customers type when paying SunPay's main Paybill — SunPay then routes the funds to that shop automatically.
- Your shops keep using the till/paybill number they already advertise.
- No STK push, no API call before the payment — customer pays from the M-Pesa menu.
- Each sub-merchant has its own wallet, dashboard, webhooks, and settlement.
- You manage every shop through one API key — list, fetch, and onboard via
/api/v1/merchants.
/api/v1/merchantsRegister a sub-merchant under the authenticated parent merchant.
| Field | Type | Required | Description |
|---|---|---|---|
businessName | string | Yes | Shop's display name (2–255 chars). |
phone | string | No | Shop owner's phone in 254XXXXXXXXX format (12 digits total). |
email | string | No | Optional. Auto-generated as submerchant+{shortcode}@sunpay.local if omitted. |
password | string | No | Optional (min 8 chars). If omitted, the shop can't log in directly — you manage them via the API. |
paybill.type | "till" | "paybill" | Yes | Kind of shortcode the shop owns. |
paybill.shortcode | string | Yes | Shop's till or paybill number (4–9 digits). Becomes the Account Number customers type for incoming payments. |
webhookUrl | string | No | Optional. Public HTTPS URL to receive this sub-merchant's payment events. If omitted, the sub-merchant inherits the parent's webhook (URL + events) automatically. Non-public URLs (localhost / private IPs) are silently skipped — the sub-merchant is still created but with webhook: null. Each sub-merchant gets its own signing secret (returned in the response). |
Create a sub-merchant
curl -X POST https://api.sunpay.co.ke/api/v1/merchants \
-H "Authorization: Bearer sp_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"businessName": "Mama Njeri Groceries",
"phone": "254712345678",
"email": "mama@njeri.co.ke",
"paybill": {
"type": "till",
"shortcode": "998877"
},
"webhookUrl": "https://your-pos.example.com/sunpay/webhook"
}'Response
{
"id": "4323eb4f-71dc-423d-96dc-7ddced027162",
"businessName": "Mama Njeri Groceries",
"merchantRef": "SP3",
"phone": "254712345678",
"email": "mama@njeri.co.ke",
"settlementShortcode": "998877",
"settlementType": "till",
"parentMerchantId": "73ee0900-f90b-415a-8d85-7d81fb21b9a3",
"isActive": true,
"createdAt": "2026-05-27T06:49:11.092Z",
"webhook": {
"url": "https://your-pos.example.com/sunpay/webhook",
"secret": "whsec_7f3a...e21c",
"events": ["payment.completed", "payment.failed"],
"inheritedFromParent": false
}
}
// Notes:
// - The password field is omitted from every response.
// - "webhookUrl" is OPTIONAL. If you omit it, the sub-merchant inherits the
// parent merchant's webhook (URL + events) automatically; "inheritedFromParent"
// will then be true. If the parent has no webhook and you pass none, "webhook"
// is null and no payment notifications will fire for this sub-merchant.
// - Webhook provisioning is best-effort: if the URL is non-public (localhost /
// private IP) or registration fails, "webhook" is null and the sub-merchant
// is STILL created (HTTP 201). Always check the "webhook" field in the response.
// - Each sub-merchant gets its OWN signing secret — verify the X-Webhook-Signature
// header against the secret returned here, not the parent's./api/v1/merchantsList every sub-merchant you've created.
List sub-merchants
curl https://api.sunpay.co.ke/api/v1/merchants \
-H "Authorization: Bearer sp_your_api_key_here"/api/v1/merchants/:idFetch a single sub-merchant by id. Only returns sub-merchants you created.
Get one sub-merchant
curl https://api.sunpay.co.ke/api/v1/merchants/{merchant_id} \
-H "Authorization: Bearer sp_your_api_key_here"/api/v1/merchants/:idPermanently delete a sub-merchant you created, along with all of its related records.
Delete a sub-merchant
curl -X DELETE https://api.sunpay.co.ke/api/v1/merchants/{merchant_id} \
-H "Authorization: Bearer sp_your_api_key_here"Response
{
"deleted": true,
"id": "4323eb4f-71dc-423d-96dc-7ddced027162",
"merchantRef": "SP3",
"businessName": "Mama Njeri Groceries"
}
// WARNING: This is permanent. Deleting a sub-merchant also removes ALL of its
// related records — transactions, withdrawals, webhooks, delivery logs, and
// API keys (cascade delete). The financial history cannot be recovered.
// You can only delete sub-merchants you created.How a payment lands on a sub-merchant
- Customer opens M-Pesa → Lipa na M-Pesa → Pay Bill.
- Business no.: SunPay's Paybill (e.g.
600000). - Account no.: the sub-merchant's own till/paybill (e.g.
998877). - SunPay matches the account number against your registered sub-merchants.
- The transaction is credited to that sub-merchant's wallet.
- The sub-merchant's webhook fires with the
payment.completedevent — this is the URL you passed aswebhookUrl, or the parent's webhook if you inherited it. Verify theX-Webhook-Signatureagainst the sub-merchant's own secret. - SunPay then B2B-forwards the net amount (after fee) to the sub-merchant's till/paybill.
Uniqueness & collision rules
- A given till/paybill number can be registered to only one SunPay merchant. Duplicates return
409 Conflict. - You can't register a paybill that equals any existing merchant reference (e.g.
SP1234) — that would hijack routing. - Each sub-merchant has its own
feeRate(falls back to the SunPay default) and its own settlement destination — they aren't inherited from the parent. - Today, only the sub-merchant receives
payment.completedwebhooks. Parent-fanout webhooks are on the roadmap.
The Authorization header on these endpoints uses your normal SunPay API key — the same one you use for /api/v1/payments/stk-push. Every sub-merchant you create is linked to that key's merchant via parentMerchantId.
Complete copy-paste modules for a POS backend. Each one wraps STK Push, Paybill/C2B, status polling, and a signed webhook receiver. Set SUNPAY_API_KEY and SUNPAY_WEBHOOK_SECRET in your environment, then call the four functions from your POS server.
- Cashier rings up a sale.
- POS calls
chargeStkPush(push to phone) orexpectPaybillPayment(customer pays via Paybill menu). - POS shows a "Waiting for payment" screen.
- The signed webhook fires → POS marks the receipt paid, prints, opens the drawer.
- As a fallback,
waitForPaymentpolls the status if the webhook is delayed.
Node.js / Express — sunpay.js
// sunpay.js — drop-in SunPay client for a Node/Express POS backend
// npm install axios express
const axios = require('axios');
const crypto = require('crypto');
const API_KEY = process.env.SUNPAY_API_KEY; // sp_xxx
const WEBHOOK_SECRET = process.env.SUNPAY_WEBHOOK_SECRET; // from Settings → Webhooks
const BASE_URL = 'https://api.sunpay.co.ke/api/v1';
const sp = axios.create({
baseURL: BASE_URL,
headers: { Authorization: `Bearer ${API_KEY}` },
timeout: 15000,
});
// --- 1. STK Push (cashier enters customer phone) ---
async function chargeStkPush({ phone, amount, receiptNo, shop }) {
const { data } = await sp.post('/payments/stk-push', {
phoneNumber: phone, // "254712345678"
amount, // 1200
externalRef: receiptNo, // "RCPT-2026-0001"
callbackUrl: 'https://your-pos.com/sunpay/webhook',
settleTo: shop && { // optional — route to a specific till/paybill
type: shop.type, // "till" | "paybill"
shortcode: shop.shortcode,
accountReference: shop.accountReference, // required for paybill
},
});
return data; // { transactionId, checkoutRequestId, ... }
}
// --- 2. Paybill / C2B (customer pays from M-Pesa menu) ---
async function expectPaybillPayment({ amount, receiptNo, shop }) {
const { data } = await sp.post('/payments/expect', {
amount,
externalRef: receiptNo,
callbackUrl: 'https://your-pos.com/sunpay/webhook',
settleTo: shop && {
type: shop.type, shortcode: shop.shortcode, accountReference: shop.accountReference,
},
});
// data.instructions = { paybill, accountNumber, amount } — show this on the POS screen
return data;
}
// --- 3. Poll for status (fallback when webhook is slow/blocked) ---
async function getPayment(transactionId) {
const { data } = await sp.get(`/payments/${transactionId}`);
return data; // { id, status: "pending"|"completed"|"failed", mpesaRef, ... }
}
async function waitForPayment(transactionId, { timeoutMs = 60_000, intervalMs = 2000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const tx = await getPayment(transactionId);
if (tx.status === 'completed' || tx.status === 'failed') return tx;
await new Promise(r => setTimeout(r, intervalMs));
}
throw new Error('Payment timeout');
}
// --- 4. Signed webhook receiver (mount on your Express app) ---
function verifySignature(rawBody, signatureHeader) {
if (!signatureHeader) return false;
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function mountWebhook(app, handler) {
app.post('/sunpay/webhook',
require('express').raw({ type: 'application/json' }),
(req, res) => {
if (!verifySignature(req.body, req.headers['x-webhook-signature'])) {
return res.status(401).send('bad signature');
}
const { event, data } = JSON.parse(req.body.toString('utf8'));
// Reply 200 immediately, process async — never block the webhook
res.sendStatus(200);
Promise.resolve(handler(event, data)).catch(err =>
console.error('webhook handler error', err)
);
}
);
}
module.exports = {
chargeStkPush, expectPaybillPayment, getPayment, waitForPayment, mountWebhook,
};
/* ---------- Example usage in your POS server ---------- */
// const express = require('express');
// const sunpay = require('./sunpay');
// const app = express();
//
// app.post('/api/sales/:id/charge', express.json(), async (req, res) => {
// const sale = await db.sales.find(req.params.id);
// const tx = await sunpay.chargeStkPush({
// phone: req.body.phone,
// amount: sale.total,
// receiptNo: sale.receiptNo,
// shop: { type: 'paybill', shortcode: sale.shop.paybill, accountReference: sale.shop.code },
// });
// res.json({ transactionId: tx.transactionId });
// });
//
// sunpay.mountWebhook(app, async (event, data) => {
// if (event === 'payment.completed') {
// await db.sales.markPaid(data.externalRef, { mpesaRef: data.mpesaRef });
// printer.printReceipt(data.externalRef);
// }
// });
//
// app.listen(3000);Python / Flask — sunpay.py
# sunpay.py — drop-in SunPay client for a Python/Flask POS backend
# pip install requests flask
import os, hmac, hashlib, time, json
import requests
from flask import request, abort
API_KEY = os.environ['SUNPAY_API_KEY']
WEBHOOK_SECRET = os.environ['SUNPAY_WEBHOOK_SECRET']
BASE_URL = 'https://api.sunpay.co.ke/api/v1'
_session = requests.Session()
_session.headers.update({'Authorization': f'Bearer {API_KEY}'})
def _settle_to(shop):
if not shop: return None
return {
'type': shop['type'], # "till" | "paybill"
'shortcode': shop['shortcode'],
'accountReference': shop.get('accountReference'),
}
# --- 1. STK Push ---
def charge_stk_push(phone, amount, receipt_no, shop=None):
payload = {
'phoneNumber': phone, 'amount': amount, 'externalRef': receipt_no,
'callbackUrl': 'https://your-pos.com/sunpay/webhook',
}
if shop: payload['settleTo'] = _settle_to(shop)
r = _session.post(f'{BASE_URL}/payments/stk-push', json=payload, timeout=15)
r.raise_for_status()
return r.json()
# --- 2. Paybill / C2B ---
def expect_paybill_payment(amount, receipt_no, shop=None):
payload = {
'amount': amount, 'externalRef': receipt_no,
'callbackUrl': 'https://your-pos.com/sunpay/webhook',
}
if shop: payload['settleTo'] = _settle_to(shop)
r = _session.post(f'{BASE_URL}/payments/expect', json=payload, timeout=15)
r.raise_for_status()
return r.json() # ['instructions'] = { paybill, accountNumber, amount }
# --- 3. Poll ---
def get_payment(transaction_id):
r = _session.get(f'{BASE_URL}/payments/{transaction_id}', timeout=15)
r.raise_for_status()
return r.json()
def wait_for_payment(transaction_id, timeout_s=60, interval_s=2):
deadline = time.time() + timeout_s
while time.time() < deadline:
tx = get_payment(transaction_id)
if tx['status'] in ('completed', 'failed'):
return tx
time.sleep(interval_s)
raise TimeoutError('payment timeout')
# --- 4. Signed webhook (Flask) ---
def _verify(raw_body: bytes, signature: str) -> bool:
if not signature: return False
expected = hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def webhook_view(handler):
"""Wire as: app.add_url_rule('/sunpay/webhook', view_func=webhook_view(my_handler), methods=['POST'])"""
def view():
raw = request.get_data()
if not _verify(raw, request.headers.get('X-Webhook-Signature', '')):
abort(401)
body = json.loads(raw)
try:
handler(body['event'], body['data'])
except Exception as e:
# Log it, but still 200 — never make Safaricom/SunPay retry forever
print('webhook handler error', e)
return '', 200
view.__name__ = 'sunpay_webhook'
return view
# ---------- Example usage ----------
# from flask import Flask
# import sunpay, db, printer
#
# app = Flask(__name__)
#
# def on_event(event, data):
# if event == 'payment.completed':
# db.mark_paid(data['externalRef'], data['mpesaRef'])
# printer.print_receipt(data['externalRef'])
#
# app.add_url_rule('/sunpay/webhook', view_func=sunpay.webhook_view(on_event), methods=['POST'])
#
# @app.post('/sales/<sale_id>/charge')
# def charge(sale_id):
# sale = db.find(sale_id)
# tx = sunpay.charge_stk_push(
# phone=request.json['phone'], amount=sale.total, receipt_no=sale.receipt_no,
# shop={'type': 'paybill', 'shortcode': sale.shop.paybill, 'accountReference': sale.shop.code},
# )
# return txPHP — sunpay.php
<?php
// sunpay.php — drop-in SunPay client for a PHP POS backend
// Requires PHP 7.4+ with curl extension
class SunPay {
private string $apiKey;
private string $webhookSecret;
private string $baseUrl = 'https://api.sunpay.co.ke/api/v1';
public function __construct(string $apiKey, string $webhookSecret) {
$this->apiKey = $apiKey;
$this->webhookSecret = $webhookSecret;
}
// ---- 1. STK Push ----
public function chargeStkPush(string $phone, int $amount, string $receiptNo, ?array $shop = null): array {
$body = [
'phoneNumber' => $phone, 'amount' => $amount, 'externalRef' => $receiptNo,
'callbackUrl' => 'https://your-pos.com/sunpay/webhook',
];
if ($shop) $body['settleTo'] = $shop; // ['type'=>'till|paybill','shortcode'=>'..','accountReference'=>'..']
return $this->post('/payments/stk-push', $body);
}
// ---- 2. Paybill / C2B ----
public function expectPaybillPayment(int $amount, string $receiptNo, ?array $shop = null): array {
$body = [
'amount' => $amount, 'externalRef' => $receiptNo,
'callbackUrl' => 'https://your-pos.com/sunpay/webhook',
];
if ($shop) $body['settleTo'] = $shop;
return $this->post('/payments/expect', $body); // ['instructions'] = paybill+accountNumber+amount
}
// ---- 3. Poll ----
public function getPayment(string $transactionId): array {
return $this->get("/payments/{$transactionId}");
}
public function waitForPayment(string $transactionId, int $timeoutS = 60, int $intervalS = 2): array {
$deadline = time() + $timeoutS;
while (time() < $deadline) {
$tx = $this->getPayment($transactionId);
if (in_array($tx['status'], ['completed', 'failed'], true)) return $tx;
sleep($intervalS);
}
throw new RuntimeException('payment timeout');
}
// ---- 4. Webhook verification ----
public function verifyWebhook(string $rawBody, string $signature): bool {
$expected = hash_hmac('sha256', $rawBody, $this->webhookSecret);
return hash_equals($expected, $signature);
}
// ---- HTTP helpers ----
private function post(string $path, array $body): array {
return $this->request('POST', $path, $body);
}
private function get(string $path): array {
return $this->request('GET', $path, null);
}
private function request(string $method, string $path, ?array $body): array {
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) throw new RuntimeException("HTTP error: $err");
if ($code >= 400) throw new RuntimeException("SunPay $code: $resp");
return json_decode($resp, true);
}
}
/* ---------- Example usage: webhook receiver (webhook.php) ---------- */
// $sp = new SunPay(getenv('SUNPAY_API_KEY'), getenv('SUNPAY_WEBHOOK_SECRET'));
// $raw = file_get_contents('php://input');
// $sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
// if (!$sp->verifyWebhook($raw, $sig)) { http_response_code(401); exit('bad signature'); }
// $body = json_decode($raw, true);
// if ($body['event'] === 'payment.completed') {
// $d = $body['data'];
// mark_sale_paid($d['externalRef'], $d['mpesaRef'], $d['amount']);
// print_receipt($d['externalRef']);
// }
// http_response_code(200);
/* ---------- Example usage: charge endpoint (charge.php) ---------- */
// $sp = new SunPay(getenv('SUNPAY_API_KEY'), getenv('SUNPAY_WEBHOOK_SECRET'));
// $input = json_decode(file_get_contents('php://input'), true);
// $sale = find_sale($input['saleId']);
// $tx = $sp->chargeStkPush($input['phone'], $sale['total'], $sale['receiptNo'], [
// 'type' => 'paybill', 'shortcode' => $sale['shop']['paybill'], 'accountReference' => $sale['shop']['code'],
// ]);
// header('Content-Type: application/json');
// echo json_encode($tx);Multi-shop: pass settleTo on every call to route the net amount straight into each shop's own till/paybill. Skip it to let the money sit in your SunPay wallet (withdraw later).
Node.js / JavaScript
const axios = require('axios');
const API_KEY = 'sp_your_api_key_here';
const BASE_URL = 'https://api.sunpay.co.ke/api/v1';
async function initiatePayment(phone, amount, orderId) {
const { data } = await axios.post(`${BASE_URL}/payments/stk-push`, {
phoneNumber: phone,
amount,
externalRef: orderId,
callbackUrl: 'https://your-site.com/webhook',
}, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
});
return data;
}
initiatePayment('254712345678', 100, 'ORDER-456')
.then(r => console.log('Initiated:', r))
.catch(e => console.error('Error:', e.response?.data));Python
import requests
API_KEY = 'sp_your_api_key_here'
BASE_URL = 'https://api.sunpay.co.ke/api/v1'
def initiate_payment(phone, amount, order_id):
r = requests.post(
f'{BASE_URL}/payments/stk-push',
json={
'phoneNumber': phone,
'amount': amount,
'externalRef': order_id,
'callbackUrl': 'https://your-site.com/webhook',
},
headers={
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json',
},
)
return r.json()
print(initiate_payment('254712345678', 100, 'ORDER-789'))Simple, transparent pricing. No setup or monthly fees.
per successful transaction
- No setup fees
- No monthly fees
- Pay only for successful transactions
- Withdraw earnings anytime