PoaPesa API (v1)
PoaPesa ("Pay with PoaPesa") is Netpoa Limited's own payment gateway. Netpoa's apps (PoaSMS, Kanisa MS subscriptions, Netpoa hosting and domains) use it to take mobile money payments and, where allowed, to send payouts.
- Base URL:
https://poapesa.com/api/v1(the old addresshttps://pay.netpoa.com/api/v1keeps working) - Format: JSON in, JSON out. Amounts are whole Tanzanian shillings (integers, no decimals).
- Time: timestamps are ISO 8601 in East Africa Time (+03:00).
- Only for Netpoa's own products. Money collected for third parties (e.g. church offerings) does not go through PoaPesa.
Quick start
- An admin registers your app on poapesa.com → Client apps and gives you a key ID and a signing
secret (shown once). Put them in your app's
.env; never in code, Git, logs or a browser. - Sign every request (see Signing requests).
- Start a payment the easy way with a hosted checkout: create one and redirect the customer to
checkout_url. Or collect the network and phone yourself with POST /payments. - Activate what the customer paid for only when the
payment.succeededwebhook arrives (orGET /payments/{id}sayssucceeded). Never trust a browser redirect. - Test with
channel: "sandbox", where the phone number's last 3 digits decide the outcome.
Renamed on 2 Nov 2026. Headers are now
X-PoaPesa-*and the variablesPOAPESA_*(wereX-Netpoa-*andNETPOA_PAY_*). Signatures, keys and secrets did not change. The old header names stopped working on 2 Nov 2026, once PoaSMS had switched.
POAPESA_URL=https://poapesa.com
POAPESA_KEY_ID=np_xxxxxxxxxxxxxxxxxxxxxxxx
POAPESA_SECRET=...64 characters...
Signing requests
Every request carries three headers:
| Header | Value |
|---|---|
X-PoaPesa-Key |
your key ID |
X-PoaPesa-Timestamp |
the current Unix time in seconds; must be within 5 minutes of our clock |
X-PoaPesa-Signature |
lowercase hex HMAC-SHA256 of the string below, keyed with your signing secret |
The string to sign is four parts joined by a newline (\n):
<timestamp>
<HTTP METHOD in capitals>
<path and query exactly as sent, e.g. /api/v1/payments or /api/v1/payments/01J…>
<raw request body; empty string for GET>
Sign the exact bytes you send: build the JSON once, sign it, send that same string. A wrong or old signature
gets 401 {"code":"unauthenticated"} (we don't say which part was wrong).
PHP (works in any framework)
function poapesa(string $method, string $path, ?array $body = null): array
{
$json = $body === null ? '' : json_encode($body);
$ts = (string) time();
$signature = hash_hmac('sha256', $ts."\n".strtoupper($method)."\n".$path."\n".$json, getenv('POAPESA_SECRET'));
$ch = curl_init(getenv('POAPESA_URL').$path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_POSTFIELDS => $body === null ? null : $json,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-PoaPesa-Key: '.getenv('POAPESA_KEY_ID'),
'X-PoaPesa-Timestamp: '.$ts,
'X-PoaPesa-Signature: '.$signature,
],
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) {
// Network error: we don't know if it arrived. Send the SAME request again with the SAME
// idempotency_key: PoaPesa returns the original and never charges twice.
throw new RuntimeException('PoaPesa did not answer');
}
return ['status' => $status, 'body' => json_decode($response, true)];
}
Laravel
use Illuminate\Support\Facades\Http;
function poapesa(string $method, string $path, ?array $body = null): array
{
$json = $body === null ? '' : json_encode($body);
$ts = (string) time();
$res = Http::withHeaders([
'X-PoaPesa-Key' => config('services.poapesa.key_id'),
'X-PoaPesa-Timestamp' => $ts,
'X-PoaPesa-Signature' => hash_hmac('sha256', $ts."\n".strtoupper($method)."\n".$path."\n".$json, config('services.poapesa.secret')),
'Accept' => 'application/json',
])->withBody($json, 'application/json')->timeout(60)->send($method, config('services.poapesa.url').$path);
return ['status' => $res->status(), 'body' => $res->json()];
}
Idempotency: never charge twice
POST /payments, POST /checkouts and POST /payouts need an idempotency_key (your own unique id for this
purchase, e.g. order-1234). Save it before calling us.
- Same key and same details again → you get the original back (
200), nothing new is created. - Same key with different details →
409 {"code":"idempotency_conflict"}. - After a timeout, always retry with the same key.
Hosted checkout (recommended)
PoaPesa shows the customer a page (Swahili by default, English on a tap) with the amount and your app's name, lets them pick the network and enter their number, tells them "Ada ya mtandao italipwa na mlipaji" (the network fee is paid by the payer) and waits while they approve with their PIN.
POST /checkouts
| Field | Required | Notes |
|---|---|---|
idempotency_key |
yes | up to 100 characters |
product |
yes | your product code, e.g. poasms |
amount |
yes | whole TZS, 1 – 100,000,000 |
reference |
no | your invoice / order number (up to 100); comes back as client_reference |
customer_ref |
no | your customer id (up to 100) |
description |
no | shown to the customer (up to 255) |
return_url |
no | https:// page the "Back to …" button opens |
callback_url |
no | https:// webhook URL for this payment (default: your app's webhook URL) |
$r = poapesa('POST', '/api/v1/checkouts', [
'idempotency_key' => 'topup-'.$order->id,
'product' => 'poasms',
'amount' => 20000,
'reference' => 'TOPUP-'.$order->id,
'customer_ref' => 'ACC-'.$account->id,
'description' => 'SMS credit 1,000 SMS',
'return_url' => 'https://poasms.co.tz/billing/orders/'.$order->id,
]);
// 201 (new) or 200 (same idempotency key again)
header('Location: '.$r['body']['checkout_url']);
Response:
{
"id": "01JBXK4M7Q9V2N8R5T3W6Y0Z1A",
"status": "open",
"amount": 20000,
"currency": "TZS",
"product": "poasms",
"reference": "TOPUP-88231",
"checkout_url": "https://poapesa.com/checkout/Ab3…48 characters…",
"expires_at": "2026-11-01T10:30:00+03:00",
"payment": null
}
- The link works for 60 minutes. The customer can try again after a declined payment (up to 5 tries), but only one try can be waiting on their phone at a time.
- The customer comes back to
return_url?checkout=<id>&status=paid|unpaid&reference=<reference>. Do not activate anything from that redirect — wait for the webhook or check the status.
GET /checkouts/{id}
Returns the same object. status is open, paid or expired; payment is the latest payment try (see
The payment object).
Payments (you collect the network and phone)
POST /payments
| Field | Required | Notes |
|---|---|---|
idempotency_key |
yes | up to 100 characters |
channel |
yes | network code: sandbox (testing), later mpesa, airtel, mixx, halopesa, tpesa. Only networks switched on are accepted |
product |
yes | e.g. poasms |
amount |
yes | whole TZS, 1 – 100,000,000 |
phone |
yes | Tanzanian mobile number, any common format (0754 123 456, 255754123456) |
reference, customer_ref, description, callback_url |
no | as for checkouts |
The customer gets a prompt on their phone to enter their PIN. The answer is usually pending.
$r = poapesa('POST', '/api/v1/payments', [
'idempotency_key' => 'topup-'.$order->id,
'channel' => 'sandbox',
'product' => 'poasms',
'amount' => 20000,
'phone' => '0754123000',
'reference' => 'TOPUP-'.$order->id,
]);
$order->update(['poapesa_payment_id' => $r['body']['id']]);
GET /payments/{id}
Returns the payment object. Use it as a backstop (e.g. a job that checks orders still waiting after 15 minutes). The webhook is the normal path.
The payment object
{
"id": "01JBXK9S1D4F6G8H0J2K4L6M8N",
"reference": "SMS-10231",
"status": "succeeded",
"amount": 20000,
"currency": "TZS",
"channel": "mpesa",
"phone": "0754 ••• 000",
"product": "poasms",
"client_reference": "TOPUP-88231",
"customer_ref": "ACC-512",
"description": "SMS credit 1,000 SMS",
"network_receipt": "QJT4K8LW7R",
"failure_reason": null,
"checkout_url": null,
"created_at": "2026-11-01T09:31:02+03:00",
"succeeded_at": "2026-11-01T09:31:40+03:00",
"late": false,
"failed_at": null,
"expired_at": null,
"expires_at": "2026-11-01T10:31:02+03:00"
}
status |
Meaning | What to do |
|---|---|---|
created, requested, pending |
waiting for the customer / network | show "waiting"; do nothing yet |
succeeded |
money received (confirmed with the network) | activate / deliver |
failed |
declined, wrong PIN, not enough balance… (failure_reason) |
let the customer try again |
expired |
not paid within 60 minutes | treat as failed |
reversed |
refunded to the customer by Netpoa staff | undo what was delivered |
reference is Netpoa's own reference (e.g. SMS-10231); client_reference is yours.
Late payments. Rarely, a customer completes a payment after we reported it failed or expired (for example
they entered the PIN very late). When Netpoa finance confirms it with the network, the same payment becomes
succeeded with "late": true, and you get a payment.succeeded webhook. Treat it like any success: deliver,
or ask Netpoa to refund it if you can no longer deliver. This is the only time a payment leaves failed or
expired.
Webhooks
When a payment or payout ends, PoaPesa POSTs JSON to the callback_url you gave, else your app's webhook
URL (set on Client apps).
| Event | When |
|---|---|
payment.succeeded |
money received — deliver now |
payment.failed |
declined or expired (data.status says which) |
payment.reversed |
refunded by Netpoa staff |
payout.succeeded |
a payout you requested was paid |
payout.failed |
a payout failed at the network or was rejected by staff |
Headers: X-PoaPesa-Event, X-PoaPesa-Delivery (unique id), X-PoaPesa-Timestamp, X-PoaPesa-Signature = hex
HMAC-SHA256 of <timestamp>\n<raw body> with your signing secret.
{
"event": "payment.succeeded",
"sent_for": "2026-11-01T09:31:41+03:00",
"data": { "...": "the payment (or payout) object" }
}
Your endpoint must:
- Check the signature and that the timestamp is within 5 minutes; otherwise answer
401. - Be safe to run twice (the same event can arrive more than once): if the order is already done, do nothing.
- Answer any 2xx within 10 seconds. Do slow work afterwards (queue it).
- Be a public
https://URL. Exclude it from CSRF protection.
If you don't answer 2xx we retry after 1, 5 and 15 minutes, then 1, 3, 6 and 12 hours, and stop after 24 hours.
// PHP
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_POAPESA_TIMESTAMP'] ?? '';
$expected = hash_hmac('sha256', $ts."\n".$body, getenv('POAPESA_SECRET'));
if (! ctype_digit($ts) || abs(time() - (int) $ts) > 300 || ! hash_equals($expected, $_SERVER['HTTP_X_POAPESA_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
$payment = $event['data'];
$order = Order::where('reference', $payment['client_reference'])->first();
if ($order && $order->status !== 'paid') {
match ($event['event']) {
'payment.succeeded' => $order->markPaid($payment['network_receipt']),
'payment.failed' => $order->markUnpaid($payment['failure_reason'] ?? $payment['status']),
'payment.reversed' => $order->markRefunded(),
default => null,
};
}
http_response_code(204);
Payouts (only if your app is allowed)
An admin must enable "May request payouts" for your app. A payout waits for a Netpoa staff member to approve it; nothing is sent automatically. The network fee comes from Netpoa's float, not from the recipient.
POST /payouts
| Field | Required | Notes |
|---|---|---|
idempotency_key |
yes | up to 80 characters |
channel |
yes | network code |
amount |
yes | whole TZS |
phone |
yes | recipient's number |
name |
no | the name you expect; staff compare it with the network's registered name |
reason |
yes | up to 255 |
reference, callback_url |
no |
Answer 201 with status: "awaiting_approval". GET /payouts/{id} for the latest status: awaiting_approval,
approved, sent, succeeded, failed, rejected. Results also arrive as payout.succeeded /
payout.failed webhooks.
Errors
| HTTP | Body | Meaning |
|---|---|---|
| 401 | {"code":"unauthenticated"} |
missing, old or wrong signature, or app switched off |
| 403 | {"code":"forbidden"} |
e.g. payouts not allowed for this app |
| 404 | not found, or not yours | |
| 409 | {"code":"idempotency_conflict"} |
key reused with different details |
| 422 | {"code":"invalid_request","message":"…"} or {"message":"…","errors":{…}} |
bad input (message says why) |
| 429 | too many requests: 60 a minute per app (300 per IP) | |
| 5xx | our side: retry later with the same idempotency key |
Testing on the Sandbox
Use channel: "sandbox" (or choose Sandbox on the checkout page). No money moves. The last 3 digits of
the phone number decide what happens:
| Number ends in | Result |
|---|---|
000 (or anything not listed) |
paid |
111 |
customer declines → failed |
222 |
our request times out but the customer paid → pending, then succeeded after a re-check |
333 |
stays pending (customer slow / late callback) |
444 |
rejected at once (invalid number) → failed |
Test at least: a paid order, a declined one, sending the same idempotency key twice (no second payment), a webhook arriving twice (no double delivery), and a forged webhook (rejected with 401).
Products
| Code | Product |
|---|---|
poasms |
PoaSMS |
kanisa |
Kanisa MS subscriptions |
hosting |
Netpoa hosting |
domains |
Domains |
Your app can only use the products it was registered for.
Going live
Nothing changes in your code. When a network (M-Pesa, Airtel, Mixx by Yas, …) is switched on at PoaPesa, it
appears on the checkout page and its code is accepted as channel. Keep your old payment provider as a fallback
for the first 30 days.
Questions: info@netpoa.com · 0755 212 222 · NETPOA LIMITED, P.O. Box 62228, Dar es Salaam.