PoaPesa API v1 Markdown version

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.

Quick start

  1. 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.
  2. Sign every request (see Signing requests).
  3. 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.
  4. Activate what the customer paid for only when the payment.succeeded webhook arrives (or GET /payments/{id} says succeeded). Never trust a browser redirect.
  5. 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 variables POAPESA_* (were X-Netpoa-* and NETPOA_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.

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
}

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:

  1. Check the signature and that the timestamp is within 5 minutes; otherwise answer 401.
  2. Be safe to run twice (the same event can arrive more than once): if the order is already done, do nothing.
  3. Answer any 2xx within 10 seconds. Do slow work afterwards (queue it).
  4. 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.