HashBack & HashPay APIs
Complete REST API reference for MSISDN decoding, M-Pesa STK Push payments, Wallet B2C transfers, and transaction lookups. Every endpoint documented with request examples in cURL, JavaScript, PHP, and Python.
HashBack Decode API
Decode hashed MSISDNs to real phone numbers. Safaricom & Airtel supported. Check your token balance for free.
Payment Button
Drop-in “Pay with M-PESA” popup — one line of JavaScript, public account ID only.
Wallet B2C
Check balances, top up via STK Push, and send money to customers.
PULL API
Look up any transaction by ID for reconciliation and reporting.
HashPay SMS Per message
Bulk and transactional SMS to Kenyan networks. Price a campaign before you commit, then read back what reached each handset.
STK Partner API PAYG
Provision M-Pesa Paybill & Till channels for your own merchants. Free to link — Pay-As-You-Go at KES 0.25 per request.
Authentication
All API requests require authentication via your API key. Pass it either as a request body field or as an HTTP header depending on the endpoint.
Header method (HashBack Decode)
API_KEY: YOUR_API_KEY_HERE
Body method (HashPay endpoints)
{
"api_key": "YOUR_API_KEY_HERE",
"account_id": "YOUR_ACCOUNT_ID"
}
Base URL
All endpoints are served over HTTPS. Use the correct versioned path for each product.
# HashBack Decode https://api.hashback.co.ke/ # HashPay STK + PULL https://api.hashback.co.ke/ # Wallet B2C V2 (current) https://api.hashback.co.ke/V2/
Rate Limiting
Requests are throttled per API key to protect service stability.
| Endpoint group | Limit | Window |
|---|---|---|
| All endpoints | 100 requests | Per minute |
| HashBack Decode | 10 requests | Per 5 seconds |
429. Use exponential backoff before retrying.
{
"error": {
"code": 429,
"message": "Too many requests. Please try again later."
}
}
HashBack Decode API
Decode hashed phone numbers back to their real MSISDN format. Supports both Safaricom and Airtel Kenya. Typical response time is under 50 ms. Each decode spends one service token — check your balance any time, free.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| hash | String | Required | The MSISDN hash string to decode |
| API_KEY | String | Required | Your API key — passed as an HTTP header |
Code examples
curl -X POST https://api.hashback.co.ke/decode \ -d 'hash=4f87c55d393937f18fbf3003512195aa8e62be340946ab547c2eada26cc43c1e' \ -H 'API_KEY: YOUR_API_KEY_HERE'
const res = await fetch('https://api.hashback.co.ke/decode', { method: 'POST', headers: { 'API_KEY': 'YOUR_API_KEY_HERE' }, body: new URLSearchParams({ hash: '4f87c55d393937f18fbf3003512195aa8e62be340946ab547c2eada26cc43c1e' }) }); const { MSISDN } = await res.json(); console.log(MSISDN); // "254712345678"
<?php $ch = curl_init('https://api.hashback.co.ke/decode'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['hash' => '4f87c55d...']) ); curl_setopt($ch, CURLOPT_HTTPHEADER, ['API_KEY: YOUR_API_KEY_HERE']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $r = json_decode(curl_exec($ch), true); curl_close($ch); echo $r['MSISDN']; // 254712345678 ?>
import requests r = requests.post( 'https://api.hashback.co.ke/decode', data={'hash': '4f87c55d...'}, headers={'API_KEY': 'YOUR_API_KEY_HERE'} ) print(r.json()['MSISDN']) # 254712345678
Response
{
"ResultCode": "0",
"MSISDN": "254712345678"
}
How many service tokens the authenticated account has left, and the plan rate they were bought at. Free — this call never spends a token. Each decode costs one token, so the balance is also your remaining decode count. Poll it before a batch job, or on your own dashboard, so a run doesn't stop halfway on an empty balance.
API_KEY header, an Authorization: Bearer header, or an
api_key field in the query string or JSON body — whichever suits your client.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
API_KEY | String | Required | Your API key — header, bearer token, or api_key field |
Code examples
curl https://api.hashback.co.ke/credits/balance \ -H 'API_KEY: YOUR_API_KEY_HERE'
const res = await fetch('https://api.hashback.co.ke/credits/balance', { headers: { 'API_KEY': 'YOUR_API_KEY_HERE' } }); const { balance, rate_per_token } = await res.json(); console.log(`${balance} tokens left`); // "480 tokens left" // Stop a batch before it runs dry if (balance < rows.length) throw new Error('Top up before running this batch');
<?php $ch = curl_init('https://api.hashback.co.ke/credits/balance'); curl_setopt($ch, CURLOPT_HTTPHEADER, ['API_KEY: YOUR_API_KEY_HERE']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $r = json_decode(curl_exec($ch), true); curl_close($ch); echo $r['balance']; // 480 echo $r['rate_per_token']; // 0.5 ?>
import requests r = requests.get( 'https://api.hashback.co.ke/credits/balance', headers={'API_KEY': 'YOUR_API_KEY_HERE'} ).json() print(r['balance'], 'tokens') # 480 tokens if r['low_balance']: print('Top up — minimum KES', r['min_topup'])
Response
{
"success": true,
"balance": 480,
"plan": "silver",
"rate_per_token": 0.5,
"min_topup": 100,
"estimated_value": 240,
"low_balance": false,
"currency": "KES"
}
Response fields
| Field | Type | Description |
|---|---|---|
balance | Number | Service tokens remaining. One token per decode |
plan | String | regular, silver, premium, patner or platinum |
rate_per_token | Number | KES paid per token on this plan |
min_topup | Number | Smallest top-up in KES that still earns the plan rate. Below this, a top-up is credited at the KES 0.8 fallback rate |
estimated_value | Number | What the remaining tokens cost at this plan's rate — for showing a KES figure beside the count |
low_balance | Boolean | true below 10 tokens — mirrors the portal's low-balance warning |
Plan rates
| Plan | KES / token | Minimum top-up |
|---|---|---|
regular | 0.80 | KES 100 |
silver | 0.50 | KES 200 |
premium | 0.35 | KES 300 |
patner | 0.25 | KES 700 |
platinum | 0.35 | KES 500 |
{
"success": false,
"message": "Unauthorized: Invalid API key"
}
// A missing key returns 401 with "Missing API key"
{
"success": false,
"message": "Too Many Requests"
}
// Limit: 30 requests per 60 seconds per IP.
// Cache the balance rather than polling it per decode.
Payment Button
A drop-in “Pay with M-PESA” popup. Embed one script and your public account_id — no secret key ever touches the browser. The button opens a hosted M-PESA modal, fires the STK push server-side, polls for the result, and calls your success / cancel / error handlers.
Three steps — the merchant only ever exposes their public account ID.
| Step | What happens |
|---|---|
| 1. Embed | Load hashpay.js and call HashPay.setup({...}) with your public account_id and the amount. |
| 2. Pay | The popup collects the payer's phone and fires the M-PESA STK push server-side, returning a checkout_id. |
| 3. Confirm | The popup polls status until the M-PESA callback records the result, then shows success / failed / cancelled and calls your handler. |
account_id is a public key — safe to expose. It can only start a payment into your own till/paybill. Your secret api_key is never used client-side.
Tap the button below — you get STK prompt to enter pin and complete checkout.
This live button loads hashpay.js from the CDN and calls HashPay.pay({...}) — exactly the snippet below.
Drop this into any page — just add the CDN <script> and a button.
<script src="https://pay.hashback.co.ke/hashpay.js"></script> <button id="pay">Pay KES 100</button> <script> var handler = HashPay.setup({ account: 'HP945692', // your public account id (required) amount: 100, // KES (required) reference: 'order_12345', // optional — auto-generated if omitted onSuccess: function (txn) { alert('Paid! Receipt: ' + txn.receipt); }, onCancel: function () { console.log('closed'); }, onError: function (e) { console.error(e); } }); document.getElementById('pay').addEventListener('click', handler.openIframe); </script>
<script src="https://pay.hashback.co.ke/hashpay.js"></script> <button class="hashpay-button" data-account="HP945692" data-amount="100" data-reference="order_12345">Pay KES 100</button>
No JavaScript needed. Auto-bound buttons emit DOM events on themselves: hashpay:success, hashpay:cancel, hashpay:error (payload on event.detail).
onSuccess payload
{
"reference": "order_12345",
"receipt": "SGH3XYZ123", // M-PESA receipt number
"amount": 100,
"checkoutid": "ws_CO_...",
"status": "success"
}
onSuccess handler, compare txn.amount against the real price of the package or order the customer selected — and only release the goods, service, or subscription if they match. Never trust the amount that comes back from the button on its own; check it against what the order should actually cost.
// The real price of the package/order the customer picked. const expectedAmount = ORDER.package.price; // e.g. 1000 — from YOUR data, not the page HashPay.setup({ account: 'HP945692', amount: expectedAmount, reference: ORDER.id, onSuccess: function (txn) { // Guard: confirm the paid amount matches what the order should cost. if (Number(txn.amount) !== Number(expectedAmount)) { // Amount was tampered with — do NOT fulfil. Flag / refund / contact support. console.error('Amount mismatch', txn.amount, 'expected', expectedAmount); return; } // Amounts match — safe to release the package / order. fulfilOrder(ORDER.id, txn.receipt); } });
If you already know the payer's number — they are logged in, or you captured it at checkout — pass it to the button and the popup opens with the field already filled in. The payer just taps Pay. Typing a 10-digit number on a phone keypad is the single biggest drop-off point in the flow, so this removes it.
<button class="hashpay-button" data-account="HP945692" data-amount="100" data-reference="order_12345" data-phone="0712345678">Pay KES 100</button> <!-- Lock it: the field becomes read-only and payment can only come from this number --> <button class="hashpay-button" data-account="HP945692" data-amount="100" data-phone="254712345678" data-phone-lock>Pay KES 100</button>
HashPay.pay({
account: 'HP945692',
amount: 100,
reference: 'order_12345',
phone: USER.msisdn, // '0712345678' | '+254712345678' | '712345678'
lockPhone: true, // optional — make the field read-only
onSuccess: function (txn) { fulfilOrder(txn); }
});
Options
| Data attribute | Config key | Type | Description |
|---|---|---|---|
data-phone |
phone (alias msisdn) |
string | Payer's mobile number, prefilled into the modal's number field. Optional. |
data-phone-lock |
lockPhone (alias phoneLock) |
boolean | Makes the prefilled field read-only, so the payment can only be made from that number. Ignored when no valid phone was supplied. As a data attribute the bare presence means true; use data-phone-lock="false" (or "0" / "no") to opt out. |
Accepted number formats
Pass the number in whatever shape your database holds it — the button normalises it to the local 07XXXXXXXX / 01XXXXXXXX form and displays it grouped as 0712 345 678.
| You pass | Modal shows |
|---|---|
0712345678 | 0712 345 678 |
+254 712 345 678 | 0712 345 678 |
254712345678 | 0712 345 678 |
712345678 | 0712 345 678 |
0112345678 | 0112 345 678 |
What the payer sees
| Case | Modal behaviour |
|---|---|
No phone | Empty field, caption “Please enter your mobile money number to begin this payment”, keyboard focus on the input. |
phone only | Field prefilled and still editable, caption “Confirm your mobile money number to begin this payment”. Focus goes to the Pay button so the on-screen keypad doesn't cover the modal. |
phone + lock | Field prefilled and read-only, caption “Confirm the M-PESA prompt on the number below…” plus the note “This payment can only be made from this number.” The STK push always goes to the locked number. |
txn against your own records server-side (or via the webhook) before fulfilling.
HashPay sends a POST request to your webhook URL when a payment transaction completes. Configure your URL in the Settings tab of your HashPay portal.
2xx status.
Global vs per-account endpoints
A webhook can be registered against a single payment channel or as a global webhook that covers every channel on your account — including channels you add later. Use the AccountID field in the payload to tell the channels apart. Exactly one endpoint fires per transaction: if the channel has its own webhook that one is used, otherwise the global one is. You may hold one global webhook, and it carries its own signing secret like any other endpoint.
AccountID, never by shortcode. If you have linked one till or paybill
several times — one channel per platform — each of those channels carries its own webhook and its own
secret, so you can point your website and your app at different endpoints even though the money lands in
the same till.
Incoming request headers
| Header | Value | Notes |
|---|---|---|
Content-Type | application/json | Body is always JSON |
X-Hashpay-Signature | sha256=<hex-digest> | Always verify this before processing |
X-Forwarded-For | HashPay origin IP | May be set by proxy/load balancer |
X-Forwarded-Proto | https | Confirms HTTPS delivery |
Webhook payload (success)
{
"event": "payment.success",
"ResponseCode": 0,
"ResponseDescription": "Success. Request accepted for processing",
"MerchantRequestID": "ws_CO_12052026084940",
"CheckoutRequestID": "ws_CO_12052026084940776662",
"TransactionID": "UEC496402X",
"TransactionAmount": 1,
"TransactionReceipt": "UEC496402X",
"TransactionDate": 20260512084950,
"TransactionReference": "HPL1XBF0",
"Msisdn": 254701234567,
"AccountID": "HP56"
}
Verifying the X-Hashpay-Signature
Every webhook request includes an X-Hashpay-Signature header containing an HMAC-SHA256 digest of the raw request body, signed with your webhook secret (found in the HashPay Settings portal). Always verify this signature before trusting the payload — reject any request that fails the check with a 401 response.
<?php // Retrieve raw body BEFORE reading $_POST or json_decode() $rawBody = file_get_contents('php://input'); $secret = 'YOUR_WEBHOOK_SECRET'; // from HashPay Settings $sigHeader = $_SERVER['HTTP_X_HASHPAY_SIGNATURE'] ?? ''; // Header format: "sha256=<hex>" $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret); if (!hash_equals($expected, $sigHeader)) { http_response_code(401); exit('Invalid signature'); } // Signature valid — safe to process $payload = json_decode($rawBody, true); if ($payload['event'] === 'payment.success' && $payload['ResponseCode'] === 0) { $txid = $payload['TransactionID']; // "UEC496402X" $ref = $payload['TransactionReference']; // your order ref $msisdn = $payload['Msisdn']; // 254701234567 // fulfil the order ... } http_response_code(200); ?>
const crypto = require('crypto'); // Express example — use express.raw() to get the raw buffer app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const secret = 'YOUR_WEBHOOK_SECRET'; const sigHeader = req.headers['x-hashpay-signature'] ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(req.body) // raw Buffer .digest('hex'); const valid = crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(sigHeader) ); if (!valid) return res.status(401).send('Invalid signature'); const payload = JSON.parse(req.body.toString()); if (payload.event === 'payment.success' && payload.ResponseCode === 0) { const { TransactionID, TransactionReference, Msisdn } = payload; // fulfil the order ... } res.status(200).send('OK'); });
import hmac, hashlib from flask import Flask, request, abort app = Flask(__name__) SECRET = b'YOUR_WEBHOOK_SECRET' @app.route('/webhook', methods=['POST']) def webhook(): raw_body = request.get_data() # raw bytes, before any parsing sig_header = request.headers.get('X-Hashpay-Signature', '') expected = 'sha256=' + hmac.new( SECRET, raw_body, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, sig_header): abort(401) payload = request.get_json() if payload.get('event') == 'payment.success' and payload.get('ResponseCode') == 0: tx_id = payload['TransactionID'] # "UEC496402X" ref = payload['TransactionReference'] # your order ref # fulfil the order ... return '', 200
hash_equals / hmac.compare_digest / timingSafeEqual) to prevent timing-attack leaks. Never use === or == for signature comparison.
Pay Button for Apps & Bots
Add a “Pay” button inside your Android app or Telegram bot. When the customer taps it, HashPay instantly creates a secure payment link and sends the customer straight to the payment page to pay with M-PESA — no typing, no copying links.
| Step | What the customer sees |
|---|---|
| 1. Tap | The customer taps your Pay button in your app or Telegram bot. |
| 2. Link created | A payment link is generated automatically for that exact amount — valid for 1 hour. |
| 3. Redirected to pay | The customer is taken to the secure HashPay payment page, enters their number and M-PESA PIN. |
| 4. Back to you | After paying they’re returned to your app or bot, and you’re notified the payment succeeded. |
Your bot shows a Pay button in the chat. The customer taps it and is taken to the HashPay payment page to pay with M-PESA. Once done, they’re brought back to the chat and the bot confirms the payment.
Place a Pay button anywhere in your app — on a cart, an order or a subscription screen. Tapping it opens the HashPay payment page; after paying, the customer lands right back in your app.
STK Push API
Low-level M-PESA STK Push endpoints. These remain available for existing integrations but are being phased out.
| Parameter | Type | Required | Description |
|---|---|---|---|
| api_key | String | Required | Your API key |
| account_id | String | Required | Your HashPay account ID |
| amount | String | Required | Payment amount in KES (e.g. "1") |
| msisdn | String | Required | Customer phone number — format 2547XXXXXXXX |
| reference | String | Required | Unique transaction reference (URL-encoded) |
curl -X POST https://api.hashback.co.ke/initiatestk \ -H 'Content-Type: application/json' \ -d '{ "api_key": "YOUR_KEY", "account_id": "ACC_ID", "amount": "1", "msisdn": "254712345678", "reference": "ORDER_001" }'
const res = await fetch('https://api.hashback.co.ke/initiatestk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: 'YOUR_KEY', account_id: 'ACC_ID', amount: '1', msisdn: '254712345678', reference: 'ORDER_001' }) }); const { checkout_id, success } = await res.json(); // Store checkout_id to poll transaction status
<?php $payload = json_encode([ 'api_key' => 'YOUR_KEY', 'account_id' => 'ACC_ID', 'amount' => '1', 'msisdn' => '254712345678', 'reference' => 'ORDER_001' ]); $ch = curl_init('https://api.hashback.co.ke/initiatestk'); curl_setopt_array($ch, [ CURLOPT_POST => 1, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true ]); $r = json_decode(curl_exec($ch), true); $checkoutId = $r['checkout_id']; ?>
Response
{
"success": true,
"message": "STK push initiated successfully",
"checkout_id": "ws_CO_16092026004400759796721744",
"MerchantRequestID": "f718-44d8-a5df-b9b3b074c8e717034596",
"CheckoutRequestID": "ws_CO_16092026004400759796721744",
"ResponseCode": "0",
"ResponseDescription": "Success. Request accepted for processing",
"CustomerMessage": "Success. Request accepted for processing"
}
| Field | Type | What it is |
|---|---|---|
| success | Boolean | The prompt was accepted for processing — not that it was paid |
| checkout_id | String | Store this. It is what /transactionstatus takes, and what your webhook echoes back |
| CheckoutRequestID | String | Safaricom's own name for the same value — identical to checkout_id |
| MerchantRequestID | String | Safaricom's reference for the request. Useful when raising a query with them |
| ResponseCode | String | "0" means accepted. Anything else is a rejection |
| ResponseDescription | String | Safaricom's wording for the acceptance or rejection |
| CustomerMessage | String | Text intended to be shown to the payer |
/transactionstatus with the checkout_id, before you
release anything of value.
checkout_id and CheckoutRequestID always carry the same value.
Both are returned so existing integrations written against Safaricom's field names keep
working — prefer checkout_id in new code.
| Parameter | Type | Required | Description |
|---|---|---|---|
| api_key | String | Required | Your API key |
| account_id | String | Required | Your HashPay account ID |
| checkoutid | String | Required | The checkout_id from STK initiation |
curl -X POST https://api.hashback.co.ke/transactionstatus \ -H 'Content-Type: application/json' \ -d '{"api_key":"YOUR_KEY","account_id":"ACC_ID","checkoutid":"ws_CO_..."}'
{
"ResponseCode": "0",
"ResponseDescription": "The service request has been accepted successfully",
"ResultCode": "0",
"ResultDesc": "The service request is processed successfully."
}
Wallet B2C API
Manage your HashPay wallet — check the balance, top up via STK Push, and send B2C withdrawals to customer phone numbers.
HW… developer wallet — not a payment channel ID.
| Parameter | Type | Required | Description |
|---|---|---|---|
| api_key | String | Required | Your API key |
| account_id | String | Required | Your HashPay wallet ID |
<?php $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => 'Content-type: application/json', 'content' => json_encode([ 'api_key' => 'KEY', 'account_id' => 'WALLET_ID' ]) ]]); $r = json_decode( file_get_contents('https://api.hashback.co.ke/walletbalance', false, $ctx), true ); echo "Balance: KES " . $r['balance']; ?>
const res = await fetch('https://api.hashback.co.ke/walletbalance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: 'KEY', account_id: 'WALLET_ID' }) }); const { balance, status } = await res.json(); console.log(`KES ${balance} — ${status}`);
import requests r = requests.post( 'https://api.hashback.co.ke/walletbalance', json={'api_key': 'KEY', 'account_id': 'WALLET_ID'} ) data = r.json() print(f"KES {data['balance']} — {data['status']}")
Response
{
"success": true,
"walletId": "WALLET_ID",
"balance": 47,
"status": "Active",
"currency": "KES"
}
| Parameter | Type | Required | Description |
|---|---|---|---|
| api_key | String | Required | Your API key |
| walletid | String | Required | Your HashPay wallet ID |
| amount | String | Required | Top-up amount in KES |
| msisdn | String | Required | Nominated phone number (must match portal) |
const res = await fetch('https://api.hashback.co.ke/v2/topup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: 'KEY', walletid: 'WALLET_ID', amount: '100', msisdn: '0712345678' }) });
| Parameter | Type | Required | Description |
|---|---|---|---|
| api_key | String | Required | Your API key |
| msisdn | String | Required | Phone number to receive the withdrawal |
| amount | String | Required | Withdrawal amount in KES |
| SecurityCredential | String | Required | Security credential from your HashPay portal |
import requests r = requests.post( 'https://api.hashback.co.ke/V2/processwithdrawal', json={ "api_key": "KEY", "msisdn": "07123456789", "amount": 20, "SecurityCredential": "CRED" } ) print(r.json())
{
"success": true,
"message": "Withdrawal processed successfully",
"details": {
"amount": 50,
"fee": 5,
"total": 55,
"balance": 92
}
}
{
"success": false,
"message": "Insufficient funds. You need KES 18.00 more"
}
/processwithdrawal) has been removed. Migrate all integrations to V2/processwithdrawal immediately.
PULL API
Retrieve detailed information about any transaction by ID. Use this for reconciliation, auditing, and generating receipts.
| Parameter | Type | Required | Description |
|---|---|---|---|
| api_key | String | Required | Your API key |
| account_id | String | Required | Your HashPay account ID |
| transaction_id | String | Required | The transaction ID to retrieve |
curl -X POST https://api.hashback.co.ke/v1/pullapi \ -H 'Content-Type: application/json' \ -d '{ "api_key": "API_KEY", "account_id": "ACCOUNT_ID", "transaction_id": "TRANS_ID" }'
{
"success": true,
"data": {
"transactionId": "TRANS_ID",
"amount": 499,
"billreference": "BILL_REF",
"AccName": "ACC NAME"
}
}
{
"success": false,
"message": "Transaction not found"
}
HashPay SMS API
Bulk and transactional SMS to Kenyan networks. Send one message or twenty thousand, price a campaign before you commit to it, and read back what actually reached each handset.
https://api.hashback.co.ke/sms/ — authenticate with
Authorization: Bearer YOUR_API_KEY.
What a message costs
You are billed per unit, and a unit is 168 characters. A longer message is simply more units — there is no separate "long SMS" price. One flat rate applies whichever sender ID the message goes out under.
| Sender | Rate per unit | Who gets it |
|---|---|---|
| Shared sender | KES 0.35 | Every account, from day one — no registration needed |
| Your own sender ID | KES 0.35 | Same rate — your own sender ID is about brand, not price |
Choosing a sender ID
An account can hold several sender IDs at once — say a transactional one for
receipts and a promotional one for campaigns. Every sending endpoint takes an optional
sender_id:
{ "to": "0712345678", "message": "Hi" } // your default sender
{ "to": "0712345678", "message": "Hi", "sender_id": null } // same thing
{ "to": "0712345678", "message": "Hi", "sender_id": "ACMEKE" } // that one specifically
- Omitted or null — your account default: your most recently assigned own sender ID, or the shared sender if you have none of your own.
- Named — that sender ID, which must be live on your account.
sender_id that isn't yours is refused, not quietly
swapped for your default. Sending under the wrong brand is harder to notice than an
error and impossible to take back once it is on a handset.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| to | String | Required | One Kenyan number. 07…, 2547… and +2547… all work |
| message | String | Required | The text. Or use template_id instead |
| template_id | Integer | Optional | Send a saved template rather than literal text |
| vars | Object | Optional | Merge values for the template's placeholders |
| sender_id | String | Optional | Omit for your default sender |
Code examples
curl -X POST https://api.hashback.co.ke/sms/send \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "to": "0712345678", "message": "Your code is 4821" }'
const res = await fetch('https://api.hashback.co.ke/sms/send', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: '0712345678', message: 'Your code is 4821', sender_id: null // null = your default sender }) }); const sms = await res.json();
$ch = curl_init('https://api.hashback.co.ke/sms/send'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer YOUR_API_KEY', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'to' => '0712345678', 'message' => 'Your code is 4821', ]), ]); $sms = json_decode(curl_exec($ch), true);
Response 201 Created
{
"success": true,
"id": "e3ff2f66e71a21a647bca309042e8694",
"to": "254712345678",
"sender_id": "ACMEKE",
"status": "sent",
"units": 1,
"cost": 0.35,
"balance": 412.60
}
Keep id — it is what /sms/status takes.
One wallet movement for the whole campaign, while each message keeps its own rate snapshot and refunds individually. Up to 20,000 recipients per request.
402 INSUFFICIENT_FUNDS and nothing is charged or
queued — a batch never goes out half-sent, leaving you to work out who got it.
0712345678, 254712345678 and +254 712 345 678
count as one recipient, not three. duplicates on the response says how
many were removed.
| Parameter | Type | Required | Description |
|---|---|---|---|
| to | Array | Required | Numbers, or objects carrying merge values. Duplicates are collapsed |
| message | String | Required* | Or template_id |
| name | String | Optional | Label for the batch in your logs |
| schedule_at | String | Optional | YYYY-MM-DD HH:MM:SS. Charged now, sent then |
| sender_id | String | Optional | Omit for your default sender |
* Either message or template_id.
Personalised bulk
Pass merge values per recipient and they are resolved before anything is charged — a missing placeholder is one clear error, not ten thousand messages reading “Hi {name}”.
curl -X POST https://api.hashback.co.ke/sms/bulk \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "template_id": 7, "to": [ { "to": "0712345678", "vars": { "name": "Asha", "order": "A-1024" } }, { "to": "0722000000", "vars": { "name": "Brian", "order": "A-1025" } } ] }'
Response 201 Created
{
"success": true,
"batch_id": "8e29fac815d3149f6dcd1da21a56389b",
"accepted": 2,
"invalid": [],
"duplicates": 0,
"sender_id": "ACMEKE",
"cost": 0.70,
"status": "processing",
"scheduled_at": null,
"balance": 411.90
}
invalid lists numbers that could not be used, and duplicates
counts entries collapsed into another — neither is ever charged for. A recipient total lower
than the list you sent is explained by these two.
Same body as /sms/send or /sms/bulk. It runs the identical pricing
the send path runs, so a quote and the real charge cannot disagree. Nothing is billed.
{
"success": true,
"sender_id": "ACMEKE",
"encoding": "GSM7",
"chars": 34,
"units": 1,
"rate": 0.35,
"recipients": 2,
"invalid": [],
"duplicates": 0,
"total_cost": 0.70,
"balance": 412.60,
"sufficient": true
}
encoding matters: one emoji or accented character switches the whole message to
UCS2 and roughly halves how much text fits in a unit.
Which names are valid in sender_id, and which one you get if you omit it.
Unusable senders are listed too, with a status saying why — so you can tell
“never registered” from “suspended”.
{
"success": true,
"default": "ACMEKE",
"senders": [
{ "sender_id": "ACMEKE", "kind": "own", "type": "transactional",
"status": "live", "usable": true, "rate": 0.35, "is_default": true },
{ "sender_id": "ACMEPROMO", "kind": "own", "type": "promotional",
"status": "pending", "usable": false, "rate": 0.35, "is_default": false }
]
}
Delivery states
| Status | Meaning | Charged? |
|---|---|---|
queued | Accepted and paid for, waiting to go out | Yes |
sent | Handed to the network, no report yet | Yes |
| delivered | Confirmed on the handset | Yes |
| failed | The network could not deliver it | Refunded if enabled |
| rejected | The provider refused it outright | Refunded |
| expired | Undelivered before the validity window closed | Refunded if enabled |
| blacklisted | The sender ID was blocked — not the number | Refunded if enabled |
refunded on the response tells you whether the money came back, so you never
have to infer it from the status.
Returns balance, your current rate, and units — how many
single-part messages that money buys right now.
Errors worth handling
Branch on code, never on the English in message — the wording may be
improved, the codes are stable.
| HTTP | Code | What to do |
|---|---|---|
| 402 | INSUFFICIENT_FUNDS | Top up the SMS wallet |
| 403 | SENDER_NOT_FOUND | That sender ID isn't on your account — check /sms/senders |
| 403 | SENDER_SUSPENDED | Registered but not usable right now |
| 403 | GLOBAL_PERMANENTLY_BLOCKED | You have your own sender ID — send under it instead |
| 400 | TEMPLATE_VARS_MISSING | The response lists exactly which placeholders had no value |
| 400 | NO_RECIPIENTS | No usable number in the request |
| 429 | RATE_LIMITED | Back off for the seconds in Retry-After |
201 means the message is paid for and ours to deliver. Refunds land back
in your wallet automatically and show on /sms/messages as
refunded: true.
STK Partner API
Provision and manage HashPay collection channels — M-Pesa Paybill and Till — for your own merchants, straight from your platform. This is the only API on HashBack that creates a payment channel, and it is reserved for partner accounts.
API_KEY collection variable.
API_KEY belonging
to an account of type patner. Any other account type receives 403.
Not a partner yet? Partner access is not self-service — contact Support for an account upgrade. Your existing API key keeps working; the upgrade simply unlocks these endpoints and the partner Pay-As-You-Go rate.
Pricing — Pay-As-You-Go
ResponseCode 0). Linking a channel is a one-off 10 service tokens;
after that you pay only for what actually runs.
| Action | Cost | Notes |
|---|---|---|
| Link a channel | 10 service tokens | One-off charge per successful link. No plan, no renewals |
| Edit a channel | Free | Rename, switch type, change shortcode |
| Switch a channel to Pay-As-You-Go | 20 service tokens | One-off, in the dashboard. Channels created through /linkaccount are already on PAYG — no extra charge |
| Enter PAYG after a cancellation restriction | 50 service tokens | Charged when a channel is moved onto PAYG because its cancellation rate breached the success floor |
| Register / clear webhook | Free | Unlimited changes |
| List accounts, banks & paybills | Free | Read-only endpoints |
| STK prompt | KES 0.25 | 1 service token, charged only on ResponseCode 0 |
| Renewals | N/A | Retired — PAYG channels never expire while tokens last |
Base URL & authentication
Authentication is your partner API_KEY, sent in the request body (JSON or form-data) or as a
query parameter on GET endpoints. Generate it from Settings in your dashboard.
- All endpoints accept JSON or form-data. List endpoints also accept GET query parameters.
- Every response carries a
ResultCode."0"means success; any other value mirrors the HTTP status. - Rate limits: 30 requests / 10s per IP and 40 requests / 60s per API key.
active_till. A PAYG channel is not governed by it — while Pay-As-You-Go
is on, the channel keeps collecting for as long as service tokens last and the date is ignored. It is a
dormant subscription window, read at exactly one moment: if you switch Pay-As-You-Go off in the dashboard,
a date still in the future returns the channel to 1 (Active), and a lapsed or unset one to
2 (Expired).
Creates a new collection channel under your partner account. The paybill or till is verified against Safaricom (Hakikisha) before anything is written, and the channel goes live immediately in Pay-As-You-Go mode. Linking costs 10 service tokens, charged once per successful link — the channel then costs KES 0.25 (1 token) per STK prompt.
403
costs you nothing. Make sure your balance covers it: with too few tokens the call is rejected and no
channel is created.
callback_webhook parameter below is optional and only worth using when one specific
channel must post somewhere different from your global endpoint.
The same paybill or till can be linked as many times as you need. One shortcode commonly
sits behind several platforms — a website, a mobile app, a Telegram bot — and each one should be its own
channel. Call this endpoint once per platform: every call returns a fresh account_id, and that
account_id is what tells the channels apart everywhere else in the API — webhook routing,
transaction history, and enabling or disabling a channel on its own. Money still settles into the same
till. Duplicate shortcodes are never rejected.
| Parameter | Type | Required | Description |
|---|---|---|---|
API_KEY | String | Required | Partner developer key |
accountName | String | Required | Display name. If omitted, the verified merchant name from Safaricom is used |
accountType | String | Required | CustomerPayBillOnline or CustomerBuyGoodsOnline |
paybill_no | String | Conditional | Required for CustomerPayBillOnline |
till_no | String | Conditional | Required for CustomerBuyGoodsOnline |
account_no | String | Optional | Account reference for paybill channels |
callback_webhook | String | Optional | Must be a valid URL. Not needed — set a global webhook in the portal instead. Use only to override the global endpoint for this one channel |
plan | String | Deprecated | Accepted and ignored — kept so older integrations don't break |
curl -X POST https://api.hashback.co.ke/linkaccount \ -H 'Content-Type: application/json' \ -d '{ "API_KEY": "YOUR_PARTNER_KEY", "accountName": "Mama Njeri Groceries", "accountType": "CustomerPayBillOnline", "paybill_no": "247247", "account_no": "MNG001" }'
{
"ResultCode": "0",
"message": "HashPay account linked successfully",
"account_id": "HPAP202608130417",
"accountName": "Mama Njeri Groceries",
"accountType": "CustomerPayBillOnline",
"status": "active",
"active_till": "2026-09-12 14:22:05",
"billing": {
"mode": "payg",
"link_cost_tokens": 10,
"cost_per_stk_tokens": 1,
"token_balance": 470,
"note": "Linking cost 10 service tokens. No renewal fee. Each STK prompt costs 1 service token."
},
"sibling_channels": [],
"validation": {
"type": "paybill",
"shortcode": "247247",
"merchant_name": "MAMA NJERI GROCERIES LTD",
"verified": true
}
}
// Linking 247247 / MNG001 a second time, this time for the mobile app. // A new account_id is issued; the channel linked earlier is reported // back under "sibling_channels" and is left untouched. { "ResultCode": "0", "message": "HashPay account linked successfully", "account_id": "HPAP202608147732", "accountName": "Mama Njeri — Mobile App", "accountType": "CustomerPayBillOnline", "status": "active", "sibling_channels": [ { "account_id": "HPAP202608130417", "accountName": "Mama Njeri Groceries" } ], "validation": { "type": "paybill", "shortcode": "247247", "merchant_name": "MAMA NJERI GROCERIES LTD", "verified": true } }
{
"ResultCode": "400",
"message": "Invalid till number: The till number does not exist or is not active",
"validation_error": "The till number does not exist or is not active",
"code": "4001"
}
sibling_channels lists the channels that were already pointing at this same paybill or till
before this call — empty on a first link. A non-empty list is perfectly normal when you are deliberately
adding a platform, but it is also what an accidental retry looks like, so check it if your client
auto-retries failed requests. Nothing is blocked either way. If you have opted into the
New Channel Linked WhatsApp alert (Settings → Notifications), a message is sent on success and
costs 1 service token.
Lists every channel belonging to your partner account with a status summary and your current service-token balance. Accepts GET query parameters, or POST with JSON / form-data. Free.
| Parameter | Type | Required | Description |
|---|---|---|---|
API_KEY | String | Required | Partner developer key |
status | String | Optional | Filter: unactivated, active, expired, archived, pending, suspended, payg, sandbox |
Channel status values
| Label | Code | Meaning |
|---|---|---|
unactivated | 0 | Created but never activated |
active | 1 | Live on a subscription window |
expired | 2 | Subscription window lapsed |
archived | 3 | Archived |
pending | 4 | Awaiting admin review |
suspended | 5 | Suspended — admin lift only |
payg | 7 | Live on Pay-As-You-Go. Newly linked partner channels land here |
sandbox | 8 | Pre-go-live test mode |
{
"ResultCode": "0",
"count": 2,
"summary": { "total": 2, "payg": 2, "active": 0, "suspended": 0 },
"billing": { "mode": "payg", "cost_per_stk_tokens": 1, "token_balance": 480 },
"data": [
{
"billing_mode": "payg",
"account_id": "HPAP202608130417",
"accountName": "Mama Njeri Groceries",
"accountType": "CustomerPayBillOnline",
"paybill_no": "247247",
"account_no": "MNG001",
"status": "payg",
"is_expired": false,
"days_remaining": null,
"callback_webhook": "https://example.com/hashback/callback"
}
]
}
is_expired and days_remaining are always false / null
for PAYG channels, since active_till does not gate them. token_balance funds every
STK prompt across all your channels.
Updates an existing channel — free, nothing is charged for edits. The account must belong
to your partner account; another partner's account_id returns 404.
| Parameter | Type | Required | Description |
|---|---|---|---|
API_KEY | String | Required | Partner developer key |
account_id | String | Required | The channel to edit |
accountType | String | Required | Always send it, even if unchanged |
accountName | String | Optional | New display name |
till_no | String | Conditional | Required when switching to CustomerBuyGoodsOnline |
paybill_no | String | Conditional | Required when switching to CustomerPayBillOnline |
account_no | String | Optional | Send an empty string to clear it |
callback_webhook | String | Optional | Send an empty string to remove it |
- Any new till or paybill is re-verified against Safaricom before it is saved.
- Switching type nullifies the other shortcode field — the cleared field appears in
updated_fieldsas e.g.paybill_no (cleared). - If
accountNameis omitted but Safaricom returns a merchant name, that name is used. - Sending nothing changeable returns 400 No changes detected.
curl -X POST https://api.hashback.co.ke/editlinkedaccount \ -H 'Content-Type: application/json' \ -d '{ "API_KEY": "YOUR_PARTNER_KEY", "account_id": "HPAP202608130417", "accountType": "CustomerBuyGoodsOnline", "till_no": "884422", "accountName": "Kibera Electronics" }'
{
"ResultCode": "0",
"message": "Account updated successfully",
"account_id": "HPAP202608130417",
"updated_fields": ["till_no", "paybill_no (cleared)", "accountType", "accountName"],
"rows_affected": 1
}
Sets or clears the payment callback URL for one channel — free. Send
webhook_url as an empty string to remove it. This is the same thing as passing
callback_webhook to Edit Linked Account; use whichever fits your flow.
| Parameter | Type | Required | Description |
|---|---|---|---|
API_KEY | String | Required | Partner developer key |
account_id | String | Required | The channel to update |
webhook_url | String | Required | Valid URL, or "" to clear |
{
"ResultCode": "0",
"message": "Webhook updated successfully",
"account_id": "HPAP202608130417",
"webhook_url": "https://example.com/hashback/callback"
}
{
"ResultCode": "0",
"message": "Webhook removed successfully",
"account_id": "HPAP202608130417",
"webhook_url": null
}
Reference list of Kenyan banks and their M-Pesa paybill numbers — useful for populating a bank picker in your onboarding UI. Accepts GET query parameters or POST with JSON / form-data. Free. The list is static and does not reflect your account in any way.
{
"ResultCode": "0",
"count": 42,
"data": [
{ "name": "ABC Bank", "paybill": "111777", "type": "bank" },
{ "name": "ABSA Bank", "paybill": "303030", "type": "bank" },
{ "name": "Cooperative Bank", "paybill": "400200", "type": "bank" },
{ "name": "Equity Bank", "paybill": "247247", "type": "bank" }
]
}
Retired — always returns 410. Partner channels run on Pay-As-You-Go, so there is no subscription to renew: linking is free and each STK prompt costs KES 0.25. If your integration still calls this, remove it and top up service tokens under Credits instead. The endpoint is kept alive rather than deleted so existing integrations get this explanation instead of a bare 404.
{
"ResultCode": "410",
"message": "Renewals are no longer required. Partner channels run on Pay-As-You-Go: linking is free and each STK prompt costs 1 service token. Top up your service tokens in the dashboard under Credits.",
"billing": { "mode": "payg", "cost_per_stk_tokens": 1 }
}
Partner error responses
| Status | Message | Fix |
|---|---|---|
| 400 | Bad Request — missing or invalid field | Check required fields for the endpoint |
| 401 | Authentication error: Invalid API key | Regenerate the key in Settings |
| 403 | Only partner accounts can access this endpoint | Your account is not patner — contact Support for an account upgrade |
| 404 | Account not found or does not belong to your account | Check account_id |
| 405 | Method Not Allowed | Use the documented method |
| 410 | Renewals are no longer required | Remove the renew call — top up tokens instead |
| 429 | Too Many Requests | Back off — 30/10s per IP, 40/60s per key |
| 500 | Could not link the account. Please try again. | Retry; contact support if it persists |
Error Codes
All endpoints follow standard HTTP status codes. Every error response includes a message field with a human-readable description.
| Status | Name | Description | Fix |
|---|---|---|---|
| 200 | OK | Request completed successfully | No action needed |
| 400 | Bad Request | Missing or invalid parameters in the request body | Validate all required fields |
| 401 | Unauthorized | API key is missing, invalid, or expired | Check your API key in Settings |
| 402 | Payment Required | The account has no balance left for a metered endpoint (e.g. IPRS lookups) | Top up in your dashboard |
| 403 | Forbidden | The key is valid but the endpoint is not enabled for this account | Contact support for access |
| 429 | Too Many Requests | Rate limit exceeded for the API key | Implement exponential backoff |
| 500 | Internal Server Error | An unexpected error occurred on the server | Retry once; contact support if it persists |
Support
Get real-time help
Join the WhatsApp developer support group for API questions, integration help, and change notifications from the HashBack team.
Join WhatsApp SupportEmail: hashbacksolutions@gmail.com