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.
| install | source | |
|---|---|---|
| Node 18+ | npm install @pamopay/node | sdk/node |
| Python 3.10+ | pip install pamopay | sdk/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", {...})