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.
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.
HashPeer P2P
Sync M-Pesa SMS transactions, confirm payments, and query by code or details.
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.
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"
}
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); } });
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.
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.
Wallet B2C API
Manage your HashPay wallet — check the balance, top up via STK Push, and send B2C withdrawals to customer phone numbers.
| 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"
}
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"
}
HashPeer P2P API
HashPeer P2P is a payment confirmation layer built on top of M-Pesa. Merchants integrate to confirm incoming payments and query transaction status in real time. The P2P base URL is separate from the HashBack API.
https://p2p.hashback.co.ke — all P2P endpoints use this domain./api/v1/) use an X-Api-Key header and consume one credit per call. The Confirm Payment endpoint uses a JWT Bearer token obtained via the HashPeer app.External merchant endpoint to query a transaction by code. Authenticated with an API key header, not a JWT. Consumes 1 credit and fires a payment.queried webhook. Rate-limited to 60 requests per 60 seconds per key.
| Header / Param | Type | Required | Description |
|---|---|---|---|
X-Api-Key (header) | string | required | Your merchant API key from the HashPeer dashboard |
tx_code (query string) | string | required | M-Pesa transaction code to look up |
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.Code examples
curl -G https://p2p.hashback.co.ke/api/v1/query \ -H "X-Api-Key: YOUR_API_KEY" \ --data-urlencode "tx_code=QH7X8LKJ22"
const res = await fetch(
'https://p2p.hashback.co.ke/api/v1/query?tx_code=QH7X8LKJ22',
{ headers: { 'X-Api-Key': 'YOUR_API_KEY' } }
);
const data = await res.json();
$ch = curl_init(
'https://p2p.hashback.co.ke/api/v1/query?tx_code=' . urlencode('QH7X8LKJ22')
);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['X-Api-Key: YOUR_API_KEY'],
CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
Success response 200 OK
{
"success": true,
"message": "Transaction found.",
"transaction": {
"id": 1234,
"tx_code": "QH7X8LKJ22",
"type": "RECEIVED",
"amount": 1500,
"contact": "0704971999",
"contact_name": "John Doe",
"tx_timestamp": 1716800000000,
"status": 0,
"status_label": "pending",
"confirmed_at": null,
"added_at": "2026-05-27 10:30:00"
}
}
External merchant endpoint to confirm a pending payment. Accepts the same Option A (by tx_code) and Option B (by amount + details) payloads as the app endpoint. Only RECEIVED-type transactions can be confirmed. Consumes 1 credit and fires a payment.confirmed webhook.
| Header | Value |
|---|---|
X-Api-Key | Your merchant API key |
Content-Type | application/json |
Request body — same as app confirm
curl -X POST https://p2p.hashback.co.ke/api/v1/confirm \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tx_code": "QH7X8LKJ22"}'
curl -X POST https://p2p.hashback.co.ke/api/v1/confirm \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 1500,
"name": "John Doe",
"phone": "0704971999"
}'
const res = await fetch('https://p2p.hashback.co.ke/api/v1/confirm', {
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: 1500,
name: 'John', // partial name match works
phone: '0704***999' // masked phone works too
})
});
const data = await res.json();
Success response 200 OK
{
"success": true,
"message": "Payment confirmed.",
"transaction": {
"id": 1234,
"tx_code": "QH7X8LKJ22",
"type": "RECEIVED",
"amount": 1500,
"contact": "0704971999",
"contact_name": "John Doe",
"tx_timestamp": 1716800000000,
"status": 1,
"confirmed_at": "2026-05-27 10:40:00",
"added_at": "2026-05-27 10:30:00"
}
}
P2P Webhooks
HashPeer fires a POST to each of your active webhook URLs after store (payment.received), app confirm (payment.confirmed), and API v1 query / confirm. Configure webhooks in the HashPeer dashboard.
Incoming request headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-HashPeer-Signature | HMAC-SHA256 of the raw JSON body signed with your webhook secret (only present if a secret is configured) |
Webhook payload
{
"event": "payment.confirmed",
"transaction": {
"id": 1234,
"tx_code": "QH7X8LKJ22",
"type": "RECEIVED",
"amount": 1500,
"contact": "0704971999",
"contact_name": "John Doe",
"tx_timestamp": 1716800000000,
"status": 1,
"confirmed_at": "2026-05-27 10:40:00",
"added_at": "2026-05-27 10:30:00"
}
}
Verifying the X-HashPeer-Signature
$secret = 'YOUR_WEBHOOK_SECRET';
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_HASHPEER_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('Signature mismatch');
}
$payload = json_decode($raw, true);
$event = $payload['event']; // "payment.confirmed"
$tx = $payload['transaction']; // full transaction object
const crypto = require('crypto');
app.post('/webhook/hashpeer', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.HASHPEER_WEBHOOK_SECRET;
const sig = req.headers['x-hashpeer-signature'];
const expected = crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).send('Signature mismatch');
}
const { event, transaction } = JSON.parse(req.body);
// handle event …
res.status(200).end();
});
import hmac, hashlib
from flask import request, abort
SECRET = b'YOUR_WEBHOOK_SECRET'
@app.route('/webhook/hashpeer', methods=['POST'])
def hashpeer_webhook():
sig = request.headers.get('X-HashPeer-Signature', '')
expected = hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
payload = request.get_json()
event = payload['event']
tx = payload['transaction']
return '', 200
Transaction object fields
| Field | Type | Description |
|---|---|---|
id | integer | Database row ID |
tx_code | string | M-Pesa transaction code, e.g. QH7X8LKJ22 |
type | string | RECEIVED | SENT | WITHDRAW | PAYMENT | REVERSAL | OTHER |
amount | float | Amount in KES |
contact | string | Phone number (may be masked, e.g. 0704***999) |
contact_name | string|null | Sender / recipient name from SMS |
tx_timestamp | integer | Unix timestamp in milliseconds |
status | integer | 0 = pending, 1 = confirmed |
status_label | string | "pending" | "confirmed" (query endpoints only) |
confirmed_at | string|null | ISO-8601 datetime when confirmed, otherwise null |
added_at | string | ISO-8601 datetime when the record was created |
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 |
| 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