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
- Your server exchanges your public key and secret key for an access token.
- Your server creates a payment and receives a payment_url.
- You redirect the customer to payment_url (the hosted checkout).
- Mullahc sends a signed webhook to your ipn_url and redirects the customer to your callback_url.
- 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.
| Key | Where it may be used |
|---|---|
public_key | Identifies your account. Safe to store in your server config. |
secret_key | Proves it is you and signs webhooks. Server only: never put it in a website, mobile app or public repository. |
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
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
| Field | Rules | Description |
|---|---|---|
amount | required, > 0 | Amount the customer pays you. |
currency | required, 3 letters | e.g. KES. You need a wallet in that currency. |
transaction_id | required, max 40, A–Z 0–9 _ - | Your order id. Re-sending the same id while pending returns the same checkout (safe retries). |
description | required, max 100 | Shown on the checkout. |
callback_url | optional URL | Where the customer returns after paying (?tnx=REFERENCE is added). |
ipn_url | optional https URL | Receives the signed webhook. |
customer_name, customer_email | optional | Shown 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
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.
- Optional: load the available networks for the currency to build your form.
- Your server sends a direct charge with the customer's phone number.
- The customer approves the prompt with their PIN.
- You get the signed payment.succeeded (or payment.failed) webhook, or poll the payment status every 3–5 seconds.
1. Networks, limits and fees
{
"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
| Field | Rules | Description |
|---|---|---|
amount, currency | required | What you receive. Mullahc and gateway fees are added on top for the customer, exactly as on the hosted checkout. |
phone | required | Customer's mobile number: 0712345678 or +254712345678. |
transaction_id | required, unique per charge | Your order id. Re-sending it while the prompt is pending returns the same charge (no second prompt). A finished id is refused with 409. |
description | required, max 100 | Shown in your records. |
network | optional | A network name from the networks endpoint, e.g. "Airtel Money". |
gateway | optional | mullahcpay (default) or mpesa |
ipn_url | optional https URL | Receives payment.succeeded / payment.failed. |
customer_name, customer_email | optional | For 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';
}
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.
| Field | Rules | Description |
|---|---|---|
amount, currency | required | What the recipient receives. Limits and fee: see the networks endpoint (payout). |
phone | required | Recipient's mobile number. |
network | optional | A 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_name | required, max 80 | Name on the mobile money account. |
transaction_id | required, unique | Your payout id. Re-sending the same id never pays twice: it returns the existing payout. |
description | optional | For your records. |
ipn_url | optional https URL | Receives 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": "…" }
}
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.
| HTTP | Payout errors |
|---|---|
| 400 | Validation, currency not available, or amount outside limits. |
| 402 | Not enough balance in your wallet for amount + fee. |
| 403 | Payouts 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.
| Phone | Result |
|---|---|
0712345001 | Success (any other number also succeeds) |
0712345002 | Failed (customer declined / payout rejected) |
0712345003 | Stays pending (test your timeout handling) |
Webhooks
Mullahc POSTs JSON to your ipn_url for these events:
| Event | When |
|---|---|
payment.succeeded | A hosted checkout or direct charge was paid. Your wallet is credited. |
payment.failed | A direct charge was declined, cancelled or timed out. |
payout.succeeded | A payout reached the recipient. |
payout.failed | A 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
| HTTP | Meaning |
|---|---|
| 400 | Validation failed: message lists the problems. |
| 401 | Missing or wrong keys, or the token is invalid / expired. |
| 403 | Merchant account not approved. |
| 402 | Payout: not enough wallet balance. |
| 404 | Payment or payout not found for your account. |
| 409 | Direct charge: this transaction_id was already used for a finished charge. |
| 422 | Direct charge: the prompt could not be sent (message says why). |
| 429 | Too 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.