# 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 address `https://pay.netpoa.com/api/v1` keeps 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

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](#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.

```dotenv
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)

```php
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

```php
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) |

```php
$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:

```json
{
  "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](#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`.

```php
$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](#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

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

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