Skip to content

Changelog ​

What changed in the API, newest first. The path is /v1 and stays /v1: every change below is additive, and a request that worked before a change works after it. When that stops being true there will be a /v2 and a notice here first.

2026-10-01 ​

A payment link can ask who is paying. POST /v1/payment_links takes collect, any of "name", "phone" and "email", and the hosted page asks the buyer for each one you list — telling them, beside the fields, that your business will see what they type. The answers come back as payer on the charge and on charge.succeeded, so a payment is no longer only a reference and an approval.

  • Additive everywhere. collect is also returned on the link. payer is { name, phone, email } or null, and is null on every charge nobody was asked about: an API charge, a counter code, a wallet payment, and any link made without collect. Links you have already made ask nothing and behave as before.
  • What you list is required of the buyer. There is no optional.
  • Not the paying number. payer.phone is the number the buyer chose to give you; the number a payment came from stays the provider's and is not returned. On a mobile-money prompt the paying number stands in unless they type another.
  • Test mode. The sandbox has no payment-link checkout, so a test charge's payer is always null.

See Knowing who paid.

2026-09-25 ​

Payers get a PamoPay receipt. When a charge the payer was prompted for succeeds, the number that approved the prompt receives one SMS from PamoPay LTD saying what they paid, to whom (your trading name), that PamoPay processed it, and the charge's ch_… id. Nothing in the API changed; this is what your customers will now be holding when they contact you.

  • In the language the checkout page was read in, and English when there was no page — an API charge, a dashboard prompt, a subscription prompt.
  • Not sent for a bank-app code (method: "tanqr"), which has no number, nor for a shop order or a ticket, whose buyer already gets a message about that payment. Never in test mode.
  • Sent after the payment settles and never before. A receipt that could not be sent within thirty minutes is dropped rather than sent late.

2026-09-23 ​

Subscriptions. Plans and subscriptions: POST /v1/plans, GET/DELETE on one of them, POST /v1/subscriptions, and POST /v1/subscriptions/{id}/cancel. Scopes subscriptions:read (in the default set) and subscriptions:write. Live only — see the reference for why a sandbox subscription would teach the wrong thing.

  • Every period is a bill we issue and text, not a debit: there is no mandate in Tanzanian mobile money, so somebody pays each one. The payment arrives as the charge.succeeded you already handle, carrying the bill object.
  • A plan's price, currency and interval are frozen at creation. Retire and make another to move a price; DELETE /v1/plans/{id} retires and deletes nothing.
  • start_on is the anchor, kept, so a tenancy on the 31st bills 31 Jan, 28 Feb, 31 Mar — the month-end clamp never compounds.
  • One live subscription per customer per plan: a second is ALREADY_SUBSCRIBED.
  • Three new events: subscription.cycle_created, subscription.payment_succeeded, subscription.payment_failed. The last is about a grace period running out, not a rail refusing.
  • New faults: BAD_INTERVAL, PLAN_INACTIVE, ALREADY_SUBSCRIBED.

Customers. An address book: POST /v1/customers, and GET/POST on one of them. A customer is somebody you enter on purpose — nothing here is created from a payment — and naming one on a charge or a bill joins those payments up. Scopes customers:read (in the default set) and customers:write. Mirrored in the sandbox.

  • msisdn is optional on a prompt when you name a customer: we ask the number on file. A number you send still wins.
  • customer is part of a charge's idempotency, so the same key with a different customer is IDEMPOTENT_MISMATCH rather than a quiet substitution. A request that names no customer hashes exactly as it did before, so nothing in flight was affected by this change.
  • Every charge and every bill carries customer on create, get and list, null where nobody was named. Additive: an integration that never reads it sees the shape it always did.
  • One person per number per business. A second row for a number you hold is MSISDN_TAKEN (409). There is no DELETE — forgetting somebody is null on their number and their address.
  • New faults: MSISDN_TAKEN, UNKNOWN_CUSTOMER, BAD_EMAIL.

2026-09-22 ​

Test mode sends webhooks. A test charge that resolves produces charge.succeeded or charge.failed at your test endpoint within a minute, with livemode: false. Until now it produced nothing, and an integration met its first event in production.

charge.failed is emitted, live and test, when the payer declines or has no float. It carries the charge.succeeded fields plus failure_code.

Rehearse a webhook without a payment. POST /v1/test/webhooks/trigger on the sandbox signs an event of any type with your test endpoint's secret, sends it, and answers with your server's status, latency and body. The dashboard has the same button under Developers, and its rehearsals appear in the event log with synthetic: true.

Every event carries delivery.last_latency_ms — how long your server took to answer the last attempt.

Two official libraries. npm install @pamopay/node and pip install pamopay. No dependencies either side; the key chooses the environment, every mutating call carries an idempotency key, retries are safe by construction, and a webhook is verified in one line. Each library has a test that reads this API's own OpenAPI document and fails if it cannot reach an operation the API serves. See Libraries.

A new scope is not retroactive, stated because logs:read is the first one to arrive since scopes did. A key minted before a scope existed does not hold it — its terms are fixed when it is made, which is the same rule that stops one being narrowed under you. Rotate to pick up a new one.

A request log. GET /v1/logs and GET /v1/logs/{id} answer what we received and what we answered, for thirty days, in both modes — status, latency, error code, and the bodies as sent and as answered. Refusals are in it too, including the ones you cannot see from your own side: a retired key, a live key sent to the sandbox, a call over the limit. Headers are never stored and a webhook signing secret is redacted. The new scope is logs:read, and it is in the default set for a new key.

A webhook address must be a hostname. An IP literal or localhost is refused with BAD_URL; a tunnel to a laptop has a name and still works.

2026-09-18 ​

A seller's page. https://pay.pamopay.co/s/{handle} lists a live business's open reusable payment links — title and price — and each row opens the checkout for that link. It is the one link for a social seller's bio. Read-only: nothing is paid at that address, and a closed, sold-out, expired or single-use link is not on it. The dashboard's Your shop screen shows the address and the page as a customer sees it.

A payer may pay by bank app. The hosted checkout — a payment link or a fee slip — now offers Mobile money or Bank app where the collection provider can mint a code: choosing the bank app shows a TAN-QR that any TIPS bank app scans, and no phone number is asked for. charge.succeeded carries method (push, tanqr, or null for a counter code or an in-app payment) and a bank-app payment reports rail: "tanqr". Both fields are additive; an integration that never reads them sees the shape it always did.

Individuals can go live. A sole trader — a driver, a seller on social media — is taken live on a NIDA number and a settlement account in their own name, in place of a BRELA certificate and TIN. Nothing changes on the API.

POST /v1/charges mints a bank-app code. Send method: "tanqr" and no rail or msisdn; the charge comes back with a qr to render, the same string on every read. Every charge now carries method and qr (null on a prompt). A deployment that cannot mint codes answers METHOD_UNAVAILABLE; the sandbox always can, and "scans" the code after three seconds — or on the branch a trigger number sent as msisdn names. See Charges.

2026-09-17 ​

Keys have scopes and lifetimes. A key names what it may reach, chosen at mint and never changed; a call outside them is FORBIDDEN_SCOPE (403) with scope in the body. A key may be given a lifetime, after which it is KEY_EXPIRED (401). Keys made before today hold every scope and never expire. The dashboard gained Replace it, which mints a key with the old one's terms and retires the old one in 24 hours. See Authentication.

Rate limits are real. 600 requests per minute per live key, 120 per test key; X-RateLimit-* on every response and Retry-After on a RATE_LIMITED (429). The code and the status were documented before; nothing enforced them. See Rate limits.

IDEMPOTENT_MISMATCH on POST /v1/charges is answered. A reused key with a different amount used to return the first charge as a 201. It is now the 409 the documentation always promised, and nothing is created.

The sandbox requires Idempotency-Key on POST /v1/charges, honours scopes, and returns the same charge on a retry — the same rules as live, so the retry path can be tested. A sandbox integration that never sent the header will start receiving IDEMPOTENCY_REQUIRED; live already required it.

POST /v1/payment_links honours an Idempotency-Key when one is sent. Still optional.

Webhook endpoints over the API. POST, GET and DELETE on /v1/webhook_endpoints, under webhooks:write and webhooks:read, so a deploy can move where events go. Live keys only; the test-mode address stays on the dashboard. See Webhook endpoints.

Three codes that were never emitted are gone from the catalogue — IDEMPOTENT_IN_FLIGHT, RAIL_DEGRADED, TEST_MODE_UNSUPPORTED — and ten that were emitted are now documented. An unhandled failure is 503, as documented, rather than 500. Every code has a page at its doc URL.

This site. The doc URL on every error resolves. The OpenAPI document and a Postman collection are published and held to the route tables by a test.

2026-09-16 ​

The integration guide, as a single document, and the bill entry page at pay.pamopay.co/b.

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