Live API Reference

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.

API Key Auth <50ms decode Kenya (Safaricom & Airtel) M-Pesa STK Push

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.

Checking your session…
Generate your API key from the Settings tab in your HashBack dashboard after registration.

Header method (HashBack Decode)

HTTP Header
API_KEY: YOUR_API_KEY_HERE

Body method (HashPay endpoints)

JSON Body
{
  "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.

Base URLs
# 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 groupLimitWindow
All endpoints100 requestsPer minute
HashBack Decode10 requestsPer 5 seconds
When rate limited you receive HTTP 429. Use exponential backoff before retrying.
429 Response
{
  "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.

Decode MSISDN Hash
POST https://api.hashback.co.ke/decode
Try it out POST https://api.hashback.co.ke/decode READ ONLY
Checking authorization…
HTTP 200

            

Request parameters

ParameterTypeRequiredDescription
hashStringRequiredThe MSISDN hash string to decode
API_KEYStringRequiredYour API key — passed as an HTTP header

Code examples

bash
curl -X POST https://api.hashback.co.ke/decode \
  -d 'hash=4f87c55d393937f18fbf3003512195aa8e62be340946ab547c2eada26cc43c1e' \
  -H 'API_KEY: YOUR_API_KEY_HERE'
javascript
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
<?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
?>
python
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

200 OK
{
  "ResultCode": "0",
  "MSISDN":     "254712345678"
}
Token Balance
GET https://api.hashback.co.ke/credits/balance
Try it out POST https://api.hashback.co.ke/credits/balance READ ONLY
Checking authorization…
HTTP 200

            

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.

Accepts GET or POST. The key may be sent as an 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

ParameterTypeRequiredDescription
API_KEYStringRequiredYour API key — header, bearer token, or api_key field

Code examples

bash
curl https://api.hashback.co.ke/credits/balance \
  -H 'API_KEY: YOUR_API_KEY_HERE'
javascript
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
<?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
?>
python
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

200 OK
{
  "success":         true,
  "balance":         480,
  "plan":            "silver",
  "rate_per_token":  0.5,
  "min_topup":       100,
  "estimated_value": 240,
  "low_balance":     false,
  "currency":        "KES"
}

Response fields

FieldTypeDescription
balanceNumberService tokens remaining. One token per decode
planStringregular, silver, premium, patner or platinum
rate_per_tokenNumberKES paid per token on this plan
min_topupNumberSmallest 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_valueNumberWhat the remaining tokens cost at this plan's rate — for showing a KES figure beside the count
low_balanceBooleantrue below 10 tokens — mirrors the portal's low-balance warning

Plan rates

PlanKES / tokenMinimum top-up
regular0.80KES 100
silver0.50KES 200
premium0.35KES 300
patner0.25KES 700
platinum0.35KES 500
401 — Unauthorized
{
  "success": false,
  "message": "Unauthorized: Invalid API key"
}

// A missing key returns 401 with "Missing API key"
429 — Too Many Requests
{
  "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.

How it works

Three steps — the merchant only ever exposes their public account ID.

StepWhat happens
1. EmbedLoad hashpay.js and call HashPay.setup({...}) with your public account_id and the amount.
2. PayThe popup collects the payer's phone and fires the M-PESA STK push server-side, returning a checkout_id.
3. ConfirmThe popup polls status until the M-PESA callback records the result, then shows success / failed / cancelled and calls your handler.
The 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.
Live Demo

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.

Integration

Drop this into any page — just add the CDN <script> and a button.

html
<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>
html
<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

JSON
{
  "reference":  "order_12345",
  "receipt":    "SGH3XYZ123",     // M-PESA receipt number
  "amount":     100,
  "checkoutid": "ws_CO_...",
  "status":     "success"
}
Always validate the amount before fulfilling. Because this is a client-only integration the amount lives in the page and a payer could edit it in the browser dev tools. In your 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.
validate the amount against your order
// 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);
  }
});
Prefill Mobile Number

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.

html
<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>
javascript
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 attributeConfig keyTypeDescription
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 passModal shows
07123456780712 345 678
+254 712 345 6780712 345 678
2547123456780712 345 678
7123456780712 345 678
01123456780112 345 678

What the payer sees

CaseModal behaviour
No phoneEmpty field, caption “Please enter your mobile money number to begin this payment”, keyboard focus on the input.
phone onlyField 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 + lockField 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.
An invalid or unrecognised number is silently dropped — the modal simply opens with an empty field instead of blocking the payer with an error. A bad prefill can never break checkout.
The lock is a UI convenience, not a security control. Like the amount, the prefilled number sits in the page and can be edited in browser dev tools. If the payment must come from a specific number, verify txn against your own records server-side (or via the webhook) before fulfilling.
Webhook Callbacks

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.

Set up your webhook URL before initiating transactions. The URL must be a public HTTPS endpoint that responds with a 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.

Channels are matched by 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

HeaderValueNotes
Content-Typeapplication/jsonBody is always JSON
X-Hashpay-Signaturesha256=<hex-digest>Always verify this before processing
X-Forwarded-ForHashPay origin IPMay be set by proxy/load balancer
X-Forwarded-ProtohttpsConfirms HTTPS delivery

Webhook payload (success)

JSON — Incoming POST to your server
{
  "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.

Compute the HMAC over the raw, unmodified request body bytes — not a re-serialised JSON string. Any whitespace difference will cause a mismatch.
php
<?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);
?>
node.js
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');
});
python
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
Use constant-time comparison (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.

Prefer to start from working code? Grab the ready-made sample project — web button, Telegram bot, Android and webhook, all runnable: Hashback-Solutions/hashpaybuttonintegration.
How it works
StepWhat the customer sees
1. TapThe customer taps your Pay button in your app or Telegram bot.
2. Link createdA payment link is generated automatically for that exact amount — valid for 1 hour.
3. Redirected to payThe customer is taken to the secure HashPay payment page, enters their number and M-PESA PIN.
4. Back to youAfter paying they’re returned to your app or bot, and you’re notified the payment succeeded.
The amount is locked when the link is created, so the customer can’t change it. Generating these links is free — no service credit is used.
In a Telegram bot

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.

In your Android app

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.

Initiate STK Push
POST https://api.hashback.co.ke/initiatestk
Try it out POST https://api.hashback.co.ke/initiatestk LIVE API
Checking authorization…
Only your live and Pay-As-You-Go channels can accept a push.
Test pushes go to your own nominated numbers only.
HTTP 200

            
ParameterTypeRequiredDescription
api_keyStringRequiredYour API key
account_idStringRequiredYour HashPay account ID
amountStringRequiredPayment amount in KES (e.g. "1")
msisdnStringRequiredCustomer phone number — format 2547XXXXXXXX
referenceStringRequiredUnique transaction reference (URL-encoded)
bash
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"
  }'
javascript
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
<?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

200 OK
{
  "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"
}
FieldTypeWhat it is
successBooleanThe prompt was accepted for processing — not that it was paid
checkout_idStringStore this. It is what /transactionstatus takes, and what your webhook echoes back
CheckoutRequestIDStringSafaricom's own name for the same value — identical to checkout_id
MerchantRequestIDStringSafaricom's reference for the request. Useful when raising a query with them
ResponseCodeString"0" means accepted. Anything else is a rejection
ResponseDescriptionStringSafaricom's wording for the acceptance or rejection
CustomerMessageStringText intended to be shown to the payer
This is an acknowledgement, not a payment. It means the prompt reached the payer's handset. They still have to enter their PIN — and they may cancel, time out, or have insufficient funds. Wait for your webhook, or poll /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.

Check Transaction Status
POST https://api.hashback.co.ke/transactionstatus
Try it out POST https://api.hashback.co.ke/transactionstatus READ ONLY
Checking authorization…
HTTP 200

            
ParameterTypeRequiredDescription
api_keyStringRequiredYour API key
account_idStringRequiredYour HashPay account ID
checkoutidStringRequiredThe checkout_id from STK initiation
bash
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_..."}'
200 OK
{
  "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.

Check Wallet Balance
POST https://api.hashback.co.ke/walletbalance
Try it out POST https://api.hashback.co.ke/walletbalance READ ONLY
Checking authorization…
This is your HW… developer wallet — not a payment channel ID.
HTTP 200

            
ParameterTypeRequiredDescription
api_keyStringRequiredYour API key
account_idStringRequiredYour HashPay wallet ID
php
<?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'];
?>
javascript
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}`);
python
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

200 OK
{
  "success":  true,
  "walletId": "WALLET_ID",
  "balance":  47,
  "status":   "Active",
  "currency": "KES"
}
Top Up Wallet via STK Push
POST https://api.hashback.co.ke/v2/topup
ParameterTypeRequiredDescription
api_keyStringRequiredYour API key
walletidStringRequiredYour HashPay wallet ID
amountStringRequiredTop-up amount in KES
msisdnStringRequiredNominated phone number (must match portal)
The STK Push only succeeds when the customer pays using the nominated number registered in your HashPay portal.
javascript
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'
  })
});
Process Withdrawal (B2C)
V1 Obsolete: /processwithdrawal was removed after October 26, 2025. Use V2 below.
POST https://api.hashback.co.ke/V2/processwithdrawal
ParameterTypeRequiredDescription
api_keyStringRequiredYour API key
msisdnStringRequiredPhone number to receive the withdrawal
amountStringRequiredWithdrawal amount in KES
SecurityCredentialStringRequiredSecurity credential from your HashPay portal
python
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())
200 OK — Success
{
  "success": true,
  "message": "Withdrawal processed successfully",
  "details": {
    "amount":  50,
    "fee":     5,
    "total":   55,
    "balance": 92
  }
}
200 — Insufficient Funds
{
  "success": false,
  "message": "Insufficient funds. You need KES 18.00 more"
}

PULL API

Retrieve detailed information about any transaction by ID. Use this for reconciliation, auditing, and generating receipts.

Get Transaction Details
POST https://api.hashback.co.ke/v1/pullapi
ParameterTypeRequiredDescription
api_keyStringRequiredYour API key
account_idStringRequiredYour HashPay account ID
transaction_idStringRequiredThe transaction ID to retrieve
bash
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"
  }'
200 — Found
{
  "success": true,
  "data": {
    "transactionId": "TRANS_ID",
    "amount":        499,
    "billreference": "BILL_REF",
    "AccName":       "ACC NAME"
  }
}
404 — Not Found
{
  "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.

Base URL 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.

SenderRate per unitWho 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
Getting your own sender ID is one-way. The moment one is assigned, the shared sender stops being available on that account — and your rate drops to 0.35.

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:

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.
A 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.
Send a single message
POST https://api.hashback.co.ke/sms/send
Try it out POST https://api.hashback.co.ke/sms/send LIVE API
Checking authorization…
Optional. Must be live on your account.
HTTP 200

            

Request parameters

ParameterTypeRequiredDescription
toStringRequiredOne Kenyan number. 07…, 2547… and +2547… all work
messageStringRequiredThe text. Or use template_id instead
template_idIntegerOptionalSend a saved template rather than literal text
varsObjectOptionalMerge values for the template's placeholders
sender_idStringOptionalOmit for your default sender

Code examples

bash
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"
  }'
javascript
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();
php
$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

json
{
  "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.

Send to many recipients
POST https://api.hashback.co.ke/sms/bulk
Try it out POST https://api.hashback.co.ke/sms/bulk LIVE API
Checking authorization…
Comma-separated. Duplicates are collapsed and charged once.
Optional. Omit to send now.
HTTP 200

            

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.

All or nothing. The whole list is priced first and the balance is checked against that total. If it will not cover every message, the request is refused with 402 INSUFFICIENT_FUNDS and nothing is charged or queued — a batch never goes out half-sent, leaving you to work out who got it.
Duplicates are charged once. The same number twice in one list is collapsed before billing, and matching is on the normalised number — so 0712345678, 254712345678 and +254 712 345 678 count as one recipient, not three. duplicates on the response says how many were removed.
ParameterTypeRequiredDescription
toArrayRequiredNumbers, or objects carrying merge values. Duplicates are collapsed
messageStringRequired*Or template_id
nameStringOptionalLabel for the batch in your logs
schedule_atStringOptionalYYYY-MM-DD HH:MM:SS. Charged now, sent then
sender_idStringOptionalOmit 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}”.

bash
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

json
{
  "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.

Price a send without charging
POST https://api.hashback.co.ke/sms/quote
Try it out POST https://api.hashback.co.ke/sms/quote READ ONLY
Checking authorization…
HTTP 200

            

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.

json
{
  "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.

Sender IDs available to you
GET https://api.hashback.co.ke/sms/senders
Try it out GET https://api.hashback.co.ke/sms/senders READ ONLY
Checking authorization…
HTTP 200

            

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

json
{
  "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 }
  ]
}
What happened to a message
GET https://api.hashback.co.ke/sms/status?id=…
Try it out GET https://api.hashback.co.ke/sms/status READ ONLY
Checking authorization…
HTTP 200

            

Delivery states

StatusMeaningCharged?
queuedAccepted and paid for, waiting to go outYes
sentHanded to the network, no report yetYes
deliveredConfirmed on the handsetYes
failedThe network could not deliver itRefunded if enabled
rejectedThe provider refused it outrightRefunded
expiredUndelivered before the validity window closedRefunded if enabled
blacklistedThe sender ID was blocked — not the numberRefunded if enabled

refunded on the response tells you whether the money came back, so you never have to infer it from the status.

Balance, logs and templates
GET https://api.hashback.co.ke/sms/balance
Try it out GET https://api.hashback.co.ke/sms/balance READ ONLY
Checking authorization…
HTTP 200

            

Returns balance, your current rate, and units — how many single-part messages that money buys right now.

GET https://api.hashback.co.ke/sms/messages
Try it out GET https://api.hashback.co.ke/sms/messages READ ONLY
Checking authorization…
HTTP 200

            
GET https://api.hashback.co.ke/sms/templates
Try it out GET https://api.hashback.co.ke/sms/templates READ ONLY
Checking authorization…
HTTP 200

            

Errors worth handling

Branch on code, never on the English in message — the wording may be improved, the codes are stable.

HTTPCodeWhat to do
402INSUFFICIENT_FUNDSTop up the SMS wallet
403SENDER_NOT_FOUNDThat sender ID isn't on your account — check /sms/senders
403SENDER_SUSPENDEDRegistered but not usable right now
403GLOBAL_PERMANENTLY_BLOCKEDYou have your own sender ID — send under it instead
400TEMPLATE_VARS_MISSINGThe response lists exactly which placeholders had no value
400NO_RECIPIENTSNo usable number in the request
429RATE_LIMITEDBack off for the seconds in Retry-After
A send is charged when it is queued, not when it is delivered — so a 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.

Download Postman collection Every endpoint below, with example requests and responses — import it and set your API_KEY collection variable.
Partner accounts only. Every endpoint requires a developer 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

KES 0.25 per request
No monthly fee. No renewals. Every request is pay-as-you-go at the partner rate of KES 0.25 — one service token per STK prompt, and you are only charged when Safaricom accepts the push (ResponseCode 0). Linking a channel is a one-off 10 service tokens; after that you pay only for what actually runs.
ActionCostNotes
Link a channel10 service tokensOne-off charge per successful link. No plan, no renewals
Edit a channelFreeRename, switch type, change shortcode
Switch a channel to Pay-As-You-Go20 service tokensOne-off, in the dashboard. Channels created through /linkaccount are already on PAYG — no extra charge
Enter PAYG after a cancellation restriction50 service tokensCharged when a channel is moved onto PAYG because its cancellation rate breached the success floor
Register / clear webhookFreeUnlimited changes
List accounts, banks & paybillsFreeRead-only endpoints
STK promptKES 0.251 service token, charged only on ResponseCode 0
RenewalsN/ARetired — PAYG channels never expire while tokens last
Top up service tokens in your dashboard under Credits. Your token balance funds every STK prompt across all your linked channels — a channel with no tokens behind it stops prompting, so keep a working balance.
Terms apply. Use of the STK STK Partner API is governed by the HashBack Terms & Conditions and your partner agreement. Rates quoted here are partner rates and may be revised with notice; onboarding, KYC and channel verification requirements apply, and channels found in breach may be suspended.

Base URL & authentication

BASE https://api.hashback.co.ke

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.
About 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).
List Linked Accounts
GET https://api.hashback.co.ke/listlinkedaccounts?API_KEY=YOUR_PARTNER_KEY&status=payg
Try it out POST https://api.hashback.co.ke/listlinkedaccounts READ ONLY
Checking authorization…
HTTP 200

            

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.

ParameterTypeRequiredDescription
API_KEYStringRequiredPartner developer key
statusStringOptionalFilter: unactivated, active, expired, archived, pending, suspended, payg, sandbox

Channel status values

LabelCodeMeaning
unactivated0Created but never activated
active1Live on a subscription window
expired2Subscription window lapsed
archived3Archived
pending4Awaiting admin review
suspended5Suspended — admin lift only
payg7Live on Pay-As-You-Go. Newly linked partner channels land here
sandbox8Pre-go-live test mode
200 — Accounts listed
{
  "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.

Edit Linked Account
POST https://api.hashback.co.ke/editlinkedaccount

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.

ParameterTypeRequiredDescription
API_KEYStringRequiredPartner developer key
account_idStringRequiredThe channel to edit
accountTypeStringRequiredAlways send it, even if unchanged
accountNameStringOptionalNew display name
till_noStringConditionalRequired when switching to CustomerBuyGoodsOnline
paybill_noStringConditionalRequired when switching to CustomerPayBillOnline
account_noStringOptionalSend an empty string to clear it
callback_webhookStringOptionalSend 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_fields as e.g. paybill_no (cleared).
  • If accountName is omitted but Safaricom returns a merchant name, that name is used.
  • Sending nothing changeable returns 400 No changes detected.
bash
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"
  }'
200 — Updated successfully
{
  "ResultCode": "0",
  "message": "Account updated successfully",
  "account_id": "HPAP202608130417",
  "updated_fields": ["till_no", "paybill_no (cleared)", "accountType", "accountName"],
  "rows_affected": 1
}
Register Webhook
POST https://api.hashback.co.ke/registerwebhook

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.

Most partners never need this endpoint. Set one global webhook in the portal under Dashboard → Webhooks (choose 🌐 All accounts (Global webhook)) and every channel you own — present and future — delivers callbacks there automatically. Register a per-channel webhook only when one channel must post to a different URL; a per-channel webhook takes precedence over the global one for that channel.
ParameterTypeRequiredDescription
API_KEYStringRequiredPartner developer key
account_idStringRequiredThe channel to update
webhook_urlStringRequiredValid URL, or "" to clear
200 — Webhook set
{
  "ResultCode": "0",
  "message": "Webhook updated successfully",
  "account_id": "HPAP202608130417",
  "webhook_url": "https://example.com/hashback/callback"
}
200 — Webhook removed
{
  "ResultCode": "0",
  "message": "Webhook removed successfully",
  "account_id": "HPAP202608130417",
  "webhook_url": null
}
List Banks & Paybills
GET https://api.hashback.co.ke/listbankspaybill?API_KEY=YOUR_PARTNER_KEY
Try it out POST https://api.hashback.co.ke/listbankspaybill READ ONLY
Checking authorization…
HTTP 200

            

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.

200 — Banks listed
{
  "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" }
  ]
}
Renew Account — retired
POST https://api.hashback.co.ke/renewhashpayaccount 410 Gone

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.

410 — Gone
{
  "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

StatusMessageFix
400Bad Request — missing or invalid fieldCheck required fields for the endpoint
401Authentication error: Invalid API keyRegenerate the key in Settings
403Only partner accounts can access this endpointYour account is not patner — contact Support for an account upgrade
404Account not found or does not belong to your accountCheck account_id
405Method Not AllowedUse the documented method
410Renewals are no longer requiredRemove the renew call — top up tokens instead
429Too Many RequestsBack off — 30/10s per IP, 40/60s per key
500Could not link the account. Please try again.Retry; contact support if it persists
All STK STK Partner API usage is subject to the HashBack Terms & Conditions. Pay-As-You-Go pricing of KES 0.25 per request is the current partner rate and may be revised with notice. Partner status is granted by HashBack — contact Support for an account upgrade.

Error Codes

All endpoints follow standard HTTP status codes. Every error response includes a message field with a human-readable description.

StatusNameDescriptionFix
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 Support

Email: hashbacksolutions@gmail.com