Mullahc Checkout API · v1

Accept payments with Mullahc

Create a payment on your server, send your customer to the secure Mullahc hosted checkout, and get notified the moment they pay. Customers can pay with their Mullahc wallet, M-Pesa or mobile money.

Overview

  1. Your server exchanges your public key and secret key for an access token.
  2. Your server creates a payment and receives a payment_url.
  3. You redirect the customer to payment_url (the hosted checkout).
  4. Mullahc sends a signed webhook to your ipn_url and redirects the customer to your callback_url.
  5. Your server confirms the payment with the status endpoint before fulfilling the order.

See it end to end in the demo shop.

Prefer your own payment page? Use Direct Charge to send the PIN prompt straight to your customer's phone, and Payouts to send money out of your wallet.

API keys

Find your keys in the Mullahc Merchant app or portal under Developers. Your merchant account must be approved.

KeyWhere it may be used
public_keyIdentifies your account. Safe to store in your server config.
secret_keyProves it is you and signs webhooks. Server only: never put it in a website, mobile app or public repository.
If a secret key is exposed, regenerate your keys immediately under Developers. Old keys stop working at once.

Sandbox & live

Sandbox

https://mullahc.com/api/merchant/sandbox

Same keys and requests; no real money moves. The checkout shows a sandbox banner.

Live

https://mullahc.com/api/merchant

Real payments, credited to your merchant wallet in the payment currency.

Access token

POST /api/merchant/access-token

Exchange your keys for a Bearer token valid for 60 minutes. Request a new one when it expires.

curl -X POST https://mullahc.com/api/merchant/access-token \
  -H "Content-Type: application/json" \
  -d '{"public_key":"YOUR_PUBLIC_KEY","secret_key":"YOUR_SECRET_KEY"}'
{
  "status": "success",
  "token": "31|8Rk…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "expires_at": "2026-09-26T10:30:00+00:00"
}

Create a payment

POST /api/merchant/make-payment
FieldRulesDescription
amountrequired, > 0Amount the customer pays you.
currencyrequired, 3 letterse.g. KES. You need a wallet in that currency.
transaction_idrequired, max 40, A–Z 0–9 _ -Your order id. Re-sending the same id while pending returns the same checkout (safe retries).
descriptionrequired, max 100Shown on the checkout.
callback_urloptional URLWhere the customer returns after paying (?tnx=REFERENCE is added).
ipn_urloptional https URLReceives the signed webhook.
customer_name, customer_emailoptionalShown on your records.
curl -X POST https://mullahc.com/api/merchant/make-payment \
  -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 1500, "currency": "KES",
    "transaction_id": "ORDER-1042", "description": "Order #1042",
    "callback_url": "https://shop.example/orders/1042",
    "ipn_url": "https://shop.example/webhooks/mullahc"
  }'
{ "status": "success", "payment_url": "https://mullahc.com/pay/TRXAB12CD34EF", "reference": "TRXAB12CD34EF" }

Hosted checkout

Redirect the customer to payment_url. Mullahc Checkout handles the payment method, phone prompts (M-Pesa / mobile money) and wallet sign-in, on a secure Mullahc page. Gateway fees and limits set by Mullahc are shown to the customer before they pay.

Verify a payment

GET /api/merchant/payment/{reference}

Always confirm with this endpoint (or a verified webhook) before fulfilling an order. Never trust the browser redirect alone.

{
  "status": "success",
  "data": {
    "reference": "TRXAB12CD34EF", "transaction_id": "ORDER-1042",
    "status": "success",            // pending | success | failed
    "amount": "1500.00000000", "charge": "75.00000000", "total_amount": "1575.00000000",
    "currency": "KES", "method": "M-Pesa",
    "created_at": "…", "updated_at": "…"
  }
}

Direct Charge (your own checkout)

Build the payment page yourself. Your customer types their phone number on your site or app, your server calls Mullahc, and the customer gets an M-Pesa / mobile money PIN prompt on their phone. No redirect to a Mullahc page.

  1. Optional: load the available networks for the currency to build your form.
  2. Your server sends a direct charge with the customer's phone number.
  3. The customer approves the prompt with their PIN.
  4. You get the signed payment.succeeded (or payment.failed) webhook, or poll the payment status every 3–5 seconds.

1. Networks, limits and fees

GET /api/merchant/direct-charge/networks?currency=KES
{
  "status": "success",
  "data": {
    "currency": "KES",
    "charge": [{
      "gateway": "mullahcpay", "name": "MullahCPay Direct",
      "min_amount": "100", "max_amount": "40000", "fee": "4", "fee_type": "percentage",
      "dial_code": "254",
      "networks": [ { "name": "M-Pesa", "prefixes": ["254710", "254711", "…"] },
                    { "name": "Airtel Money", "prefixes": ["254730", "…"] } ]
    }],
    "payout": { "name": "M-Pesa (MullahCPay)", "min_amount": "100", "max_amount": "70000",
                "fee": "4", "fee_type": "percentage", "automatic": true }
  }
}

Use the prefixes to pre-select the network as the customer types. If you do not send a network, Mullahc picks it from the number.

2. Send the prompt

POST /api/merchant/direct-charge
FieldRulesDescription
amount, currencyrequiredWhat you receive. Mullahc and gateway fees are added on top for the customer, exactly as on the hosted checkout.
phonerequiredCustomer's mobile number: 0712345678 or +254712345678.
transaction_idrequired, unique per chargeYour order id. Re-sending it while the prompt is pending returns the same charge (no second prompt). A finished id is refused with 409.
descriptionrequired, max 100Shown in your records.
networkoptionalA network name from the networks endpoint, e.g. "Airtel Money".
gatewayoptionalmullahcpay (default) or mpesa
ipn_urloptional https URLReceives payment.succeeded / payment.failed.
customer_name, customer_emailoptionalFor your records.
curl -X POST https://mullahc.com/api/merchant/direct-charge \
  -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 1500, "currency": "KES", "phone": "0712345678",
    "transaction_id": "ORDER-1043", "description": "Order #1043",
    "ipn_url": "https://shop.example/webhooks/mullahc"
  }'
{
  "status": "success",
  "message": "M-Pesa payment request sent to +254712345678. Enter your PIN on your phone to approve it.",
  "data": { "reference": "TRXQ7K2M9PL4D", "transaction_id": "ORDER-1043", "status": "pending",
            "amount": "1500.00000000", "charge": "…", "total_amount": "…", "currency": "KES",
            "method": "MullahCPay Direct", "created_at": "…", "updated_at": "…" }
}

If the prompt could not be sent (wrong number, network not available) you get HTTP 422 with a message you can show the customer; the charge is marked failed and you can retry with a new transaction_id.

3. Wait for the result

Poll GET /api/merchant/payment/{reference} every 3–5 seconds (Mullahc checks the provider live) until status is success or failed, or wait for the webhook. Prompts expire after about 2 minutes if the customer ignores them.

// Browser: your page shows "Check your phone…" and asks YOUR server for the status.
async function waitForPayment(reference) {
  for (let i = 0; i < 40; i++) {
    const r = await fetch('/orders/status?ref=' + reference).then(r => r.json()); // your server calls Mullahc
    if (r.status !== 'pending') return r.status;   // 'success' or 'failed'
    await new Promise(res => setTimeout(res, 4000));
  }
  return 'pending';
}
Never call the API from the browser or a mobile app with your keys. Your page talks to your server; your server talks to Mullahc.

Payouts

Send money from your merchant wallet to any mobile money number (refunds, supplier payments, cash-outs to your customers). The amount plus the payout fee is taken from your wallet in that currency.

POST /api/merchant/payout
FieldRulesDescription
amount, currencyrequiredWhat the recipient receives. Limits and fee: see the networks endpoint (payout).
phonerequiredRecipient's mobile number.
networkoptionalA payout network name from the networks endpoint, e.g. "Airtel Money". Without it the network is picked from the number; send it for numbers ported between networks.
recipient_namerequired, max 80Name on the mobile money account.
transaction_idrequired, uniqueYour payout id. Re-sending the same id never pays twice: it returns the existing payout.
descriptionoptionalFor your records.
ipn_urloptional https URLReceives payout.succeeded / payout.failed.
curl -X POST https://mullahc.com/api/merchant/payout \
  -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 500, "currency": "KES", "phone": "0712345678", "recipient_name": "Jane Wanjiru",
    "transaction_id": "PAYOUT-2201", "description": "Refund order #1043",
    "ipn_url": "https://shop.example/webhooks/mullahc"
  }'
{
  "status": "success",
  "message": "Payout sent. It completes when the provider confirms.",
  "data": { "reference": "TRXP4Y8N2WQ7C", "transaction_id": "PAYOUT-2201", "status": "pending",
            "amount": "500", "charge": "20", "total_amount": "520", "currency": "KES",
            "phone": "0712345678", "recipient_name": "Jane Wanjiru", "failure_reason": null,
            "sandbox": false, "created_at": "…", "updated_at": "…" }
}
GET /api/merchant/payout/{reference}

Returns the same data object. A failed payout is refunded to your wallet automatically (amount and fee) and failure_reason says why. If payouts are set to manual approval on your account, the payout stays pending until Mullahc approves it.

HTTPPayout errors
400Validation, currency not available, or amount outside limits.
402Not enough balance in your wallet for amount + fee.
403Payouts disabled on your account.

Sandbox test numbers

Direct Charge and Payouts work the same on https://mullahc.com/api/merchant/sandbox (/sandbox/direct-charge, /sandbox/payout, /sandbox/payment/{reference}, /sandbox/payout/{reference}). No money moves and no prompt is sent; the result is decided by the last three digits of the phone number and arrives about 5 seconds later, with the same webhooks as live.

PhoneResult
0712345001Success (any other number also succeeds)
0712345002Failed (customer declined / payout rejected)
0712345003Stays pending (test your timeout handling)

Webhooks

Mullahc POSTs JSON to your ipn_url for these events:

EventWhen
payment.succeededA hosted checkout or direct charge was paid. Your wallet is credited.
payment.failedA direct charge was declined, cancelled or timed out.
payout.succeededA payout reached the recipient.
payout.failedA payout failed or was rejected; amount and fee are back in your wallet.

Every webhook carries two headers:

  • X-Mullahc-Timestamp — Unix time the webhook was sent.
  • X-Mullahc-Signature — sha256=HMAC-SHA256(timestamp + "." + raw body, secret_key)
{
  "event": "payment.succeeded",
  "status": "success",
  "data": { "reference": "TRXAB12CD34EF", "transaction_id": "ORDER-1042",
            "amount": "1500.00", "total_amount": "1575.00", "currency": "KES",
            "method": "M-Pesa", "sandbox": false }
}

PHP

$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_MULLAHC_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_MULLAHC_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, getenv('MULLAHC_SECRET_KEY'));

if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(400); exit('invalid signature');
}
$event = json_decode($raw, true);
// Look up $event['data']['transaction_id'], confirm the amount, mark the order paid (idempotently).

Node.js

import crypto from 'node:crypto';
app.post('/webhooks/mullahc', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('X-Mullahc-Timestamp');
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.MULLAHC_SECRET_KEY)
    .update(ts + '.' + req.body).digest('hex');
  const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-Mullahc-Signature') || ''));
  if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).send('invalid signature');
  const event = JSON.parse(req.body);
  // mark event.data.transaction_id as paid
  res.json({ received: true });
});

Respond with any 2xx status. Handle duplicates: the same payment can be notified more than once.

Errors & limits

HTTPMeaning
400Validation failed: message lists the problems.
401Missing or wrong keys, or the token is invalid / expired.
403Merchant account not approved.
402Payout: not enough wallet balance.
404Payment or payout not found for your account.
409Direct charge: this transaction_id was already used for a finished charge.
422Direct charge: the prompt could not be sent (message says why).
429Too many requests: 20 token / 120 payment / 60 direct charge / 60 payout / 240 status requests per minute.
{ "status": "error", "message": ["The amount field must be greater than 0."] }

Security checklist

  • Call the API from your server only; keep secret_key in environment variables.
  • Verify every webhook signature and reject old timestamps.
  • Confirm status and amount with the API before shipping goods.
  • Use HTTPS for callback_url and ipn_url.
  • Regenerate keys if you suspect a leak.
Ready to try it? Open the demo shop →