Skip to content

Libraries ​

Two official clients. Both have no dependencies, both choose the base URL from your key, both put an Idempotency-Key on every mutating call, and both verify a webhook in one line.

installsource
Node 18+npm install @pamopay/nodesdk/node
Python 3.10+pip install pamopaysdk/python

You do not need either. The API is plain HTTP and the OpenAPI document generates a client in any language. What these save you is the part that is easy to get subtly wrong: signature verification, retry safety, and knowing which failures are worth retrying.

The first charge ​

js
import { PamoPay } from "@pamopay/node";

const pamopay = new PamoPay(process.env.PAMOPAY_KEY);

const charge = await pamopay.charges.create({
  amount_minor: 1_500_000,        // TZS 15,000
  rail: "mpesa",
  msisdn: "+255713000000",
});
python
import os
from pamopay import PamoPay

pamopay = PamoPay(os.environ["PAMOPAY_KEY"])

charge = pamopay.charges.create(
    amount_minor=1_500_000,       # TZS 15,000
    rail="mpesa",
    msisdn="+255713000000",
)

state comes back processing. That means not yet known, and never failed: the payer has to approve a prompt on their handset. Ship nothing on processing, and do not mark the order failed either.

Going live is one string ​

sk_test_… reaches the sandbox and sk_live_… reaches the live API, and the library reads the prefix. There is no base URL to change, and no way to send a test key to the live address by mistake.

Verifying a webhook ​

The raw body, never a parsed one — JSON.stringify(req.body) is not the bytes that were signed.

js
app.post("/pamopay", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = PamoPay.webhooks.verify(req.body, req.get("PamoPay-Signature"), SECRET);
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(200);        // answer first, work afterwards
  // …deduplicate on event.id
});
python
@app.post("/pamopay")
async def hook(request: Request):
    try:
        event = webhooks.verify(
            await request.body(),                 # not await request.json()
            request.headers.get("pamopay-signature"),
            SECRET,
        )
    except PamoPayError:
        return Response(status_code=400)
    return Response(status_code=200)

Both raise on BAD_SIGNATURE and, separately, on SIGNATURE_TOO_OLD — a signature that verifies but is older than five minutes, which is a replay or a clock rather than a forgery. The timestamp is inside the signed string for exactly that reason; see Webhooks.

Retries, and why they are safe ​

Both libraries retry a network failure, a 429 and a 5xx, and never retry a refusal about the request itself. That is only safe because every mutating call carries an idempotency key — yours if you pass one, otherwise one minted for you. Pass your own where you have a natural one:

js
await pamopay.charges.create(body, { idempotencyKey: `order-${order.id}` });
python
pamopay.charges.create(**body, idempotency_key=f"order-{order.id}")

An order id is the right key. A fresh random one per attempt is not a key at all — it is the thing that turns a timeout into a second payment.

Errors ​

Every failure is a code, never a sentence, because you render it into your own checkout in your own language. Both libraries raise one type carrying the code, the status, the request_id and the documentation URL the API itself gave.

Rehearsing a webhook in CI ​

With a test key, after you deploy:

js
const result = await pamopay.test.triggerWebhook({ type: "charge.succeeded" });
if (!result.delivered) throw new Error(`handler answered ${result.status}`);
python
result = pamopay.test.trigger_webhook(type="charge.succeeded")
assert result["delivered"], f'handler answered {result["status"]}'

What keeps them honest ​

Each library has a test that reads the OpenAPI document off disk and fails if the API serves an operation the library cannot reach, or if the library would call one the API does not serve. Both verifiers are pinned to the same recorded signature the server's own signer produced, so the two cannot drift apart or drift away from us.

Anything newer than your installed version is still reachable:

js
await pamopay.request("POST", "/v1/something_new", { … });
python
pamopay.request("POST", "/v1/something_new", {...})

Every code, scope and route on this site is rendered from the API's own source.