API Documentation

    Accept M-Pesa payments, route money to anywhere, and get notified — in minutes.

    Quick Start
    1. Create an account — sign up to get access to your dashboard. It's free.
    2. Generate an API key — from Settings → API Keys. You'll get a key starting with sp_. Store it securely; it's only shown once.
    3. Make your first charge — POST to /api/v1/payments/stk-push with the customer's phone, amount, and (optionally) your order reference.
    Base URL
    https://api.sunpay.co.ke/api/v1
    Authentication

    Every request needs your API key in the Authorization header:

    Authorization: Bearer sp_your_api_key_here

    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.

    Accept Payments

    SunPay supports two ways to collect from customers. Pick whichever fits your flow — both produce the same webhook payload.

    STK Push (prompt the phone)

    POST
    /api/v1/payments/stk-push

    Sends an M-Pesa prompt to the customer's phone. They tap PIN to pay.

    FieldTypeRequiredDescription
    phoneNumberstringYesCustomer phone, format 254XXXXXXXXX
    amountnumberYesAmount in KES (minimum 1)
    externalRefstringNoYour order or invoice ID
    callbackUrlstringNoURL to notify when payment finishes (one-off)
    settleToobjectNoAuto-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.

    POST
    /api/v1/payments/expect

    Create 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.

    PriorityMatchResult
    1stPending tx with exact account numberComplete that tx, fire callbacks & webhooks
    2ndExact Merchant Reference (e.g. SP1234)Create a new tx, fire webhooks
    3rdRegistered Paybill/Till as account (e.g. 998877) — see Sub-MerchantsRoute to that sub-merchant, fire webhooks
    4thSeparator match (e.g. 5T0042)Create tx with subReference for tenant routing
    NoneNo match foundReject — 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.

    Inline Settlement — auto-forward to a shop's till/paybill

    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.

    FieldTypeRequiredDescription
    settleTo.type"till" | "paybill"YesKind of destination
    settleTo.shortcodestringYesTill or Paybill number (4–9 digits)
    settleTo.accountReferencestringNoRequired 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"
      }
    }
    How it works
    1. Customer pays the full amount (e.g. KES 1000) into your SunPay shortcode.
    2. SunPay deducts the platform fee (default 1.5%).
    3. SunPay B2B-forwards the net (e.g. KES 985) to the till/paybill in settleTo.
    4. 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.

    Send Money

    Business → Business (B2B)

    Transfer funds from your business to another business's Paybill or Till. Use for supplier payments and inter-company transfers.

    POST
    /api/v1/payments/b2b
    FieldTypeRequiredDescription
    receiverShortcodestringYesReceiver's Paybill or Till number
    amountnumberYesAmount in KES
    accountReferencestringYesAccount number for the receiver
    remarksstringNoDescription of the payment
    commandIdstringNoBusinessPayBill, BusinessBuyGoods, or MerchantToMerchantTransfer
    receiverIdentifierTypestringNo"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.

    POST
    /api/v1/payments/b2c
    FieldTypeRequiredDescription
    phoneNumberstringYesRecipient phone, 254XXXXXXXXX
    amountnumberYesAmount in KES
    remarksstringNoDescription of the payment
    occasionstringNoOccasion 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.

    POST
    /api/v1/payments/reversal
    FieldTypeRequiredDescription
    transactionIdstringYesOriginal M-Pesa transaction ID (e.g. QJG7A5BKLN)
    amountnumberYesAmount to reverse in KES
    receiverPartystringYesYour shortcode (where funds return)
    remarksstringNoReason for reversal
    occasionstringNoAdditional 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.

    Payment Status
    GET
    /api/v1/payments/:id

    Look 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

    pending
    completed
    failed
    Webhooks

    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

    FieldTypeRequiredDescription
    transactionIdstringNoSunPay internal transaction ID
    externalRefstringNoYour reference, or the M-Pesa account number for direct C2B
    subReferencestringNoTenant ID from separator routing (null if not used)
    statusstringNocompleted or failed
    paymentTypestringNostk_push for STK Push, c2b for Paybill, b2c for payouts
    mpesaRefstringNoM-Pesa transaction code (e.g. QJG7A5BKLN)
    amountstringNoAmount paid in KES
    phoneNumberstringNoPayer's phone (254XXXXXXXXX)
    payerNamestringNoPayer's full name from M-Pesa
    resultCodenumberNoM-Pesa result code (0 = success)
    resultDescstringNoHuman-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

    payment.completed
    payment.failed
    withdrawal.completed
    withdrawal.failed
    settlement.completed
    settlement.failed

    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.

    Sub-Merchants — register shops under your POS

    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.

    Why this is useful
    • 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.
    POST
    /api/v1/merchants

    Register a sub-merchant under the authenticated parent merchant.

    FieldTypeRequiredDescription
    businessNamestringYesShop's display name (2–255 chars).
    phonestringNoShop owner's phone in 254XXXXXXXXX format (12 digits total).
    emailstringNoOptional. Auto-generated as submerchant+{shortcode}@sunpay.local if omitted.
    passwordstringNoOptional (min 8 chars). If omitted, the shop can't log in directly — you manage them via the API.
    paybill.type"till" | "paybill"YesKind of shortcode the shop owns.
    paybill.shortcodestringYesShop's till or paybill number (4–9 digits). Becomes the Account Number customers type for incoming payments.
    webhookUrlstringNoOptional. 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.
    GET
    /api/v1/merchants

    List 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"
    GET
    /api/v1/merchants/:id

    Fetch 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"
    DELETE
    /api/v1/merchants/:id

    Permanently 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
    1. Customer opens M-Pesa → Lipa na M-Pesa → Pay Bill.
    2. Business no.: SunPay's Paybill (e.g. 600000).
    3. Account no.: the sub-merchant's own till/paybill (e.g. 998877).
    4. SunPay matches the account number against your registered sub-merchants.
    5. The transaction is credited to that sub-merchant's wallet.
    6. The sub-merchant's webhook fires with the payment.completed event — this is the URL you passed as webhookUrl, or the parent's webhook if you inherited it. Verify the X-Webhook-Signature against the sub-merchant's own secret.
    7. 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.completed webhooks. 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.

    POS Integration — drop-in modules

    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.

    Recommended POS flow
    1. Cashier rings up a sale.
    2. POS calls chargeStkPush (push to phone) or expectPaybillPayment (customer pays via Paybill menu).
    3. POS shows a "Waiting for payment" screen.
    4. The signed webhook fires → POS marks the receipt paid, prints, opens the drawer.
    5. As a fallback, waitForPayment polls 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 tx

    PHP — 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).

    Code Examples

    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'))
    Pricing

    Simple, transparent pricing. No setup or monthly fees.

    1.5%

    per successful transaction

    • No setup fees
    • No monthly fees
    • Pay only for successful transactions
    • Withdraw earnings anytime
    Need Help?

    Questions or stuck on integration? Get started or jump back into your dashboard.