API Docs
Everything a developer needs to connect their platform to Haminass Pay
What this is, in one sentence
Your service asks Haminass Pay to create a payment, we handle the gateway directly (you never see NBC's or Vodacom's credentials), and we hand you back what you need to get the customer paying — a link for card, a phone prompt for M-Pesa. Pick a payment method on the left to see exactly how.
Your base URL
Every example on this page uses this — copy it as-is:
https://accesspay.eopsprimax.com
Getting an API key
Ask a Haminass Pay admin to create a platform for you on the Platforms page. They'll need to know your platform's name and which domain(s) you want customers redirected back to after paying (card only — M-Pesa doesn't need this). You'll get a key that looks like a long random string — treat it like a password: store it as a secret/environment variable on your server, never in code, chat, or a public repo.
The one required header
Every request to any endpoint on this page needs this, regardless of payment method:
Authorization: Bearer YOUR_API_KEY
Missing or wrong key → 401, with this body:
{"error": "Invalid API key"}
Creates a new card payment and returns a link to NBC's hosted card-entry page. Your customer enters their card details there, never on your site or ours.
Request headers
| Header | Required? | Value |
|---|---|---|
Authorization | Yes | Bearer YOUR_API_KEY |
Content-Type | Yes | application/json |
Idempotency-Key | Recommended | Any unique string you generate per attempt |
Request body fields
| Field | Type | Required? | Meaning |
|---|---|---|---|
amount | number | Yes | In normal currency units, not cents — 5000 means 5,000 TZS, not 50 TZS. We convert to the smallest unit internally. |
currency | string | Yes | 3-letter ISO 4217 code, e.g. TZS, USD |
payment_method | string | No | "card" — this is the default, so you can omit it entirely |
email | string | No | Customer's email, if you have it — stored with the order, not required by NBC |
redirect_url | string | No | Where NBC sends the customer's browser after they pay. Falls back to a default if omitted. |
Full example
curl -X POST https://accesspay.eopsprimax.com/api/v1/payments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2026-07-13-00042" \
-d '{
"amount": 5000,
"currency": "TZS",
"email": "customer@example.com",
"redirect_url": "https://your-domain.com/payment-result/"
}'
redirect_url on a different domain
and you get 403 instead of a payment — this stops a
leaked key from being used to redirect customers to a scam site.
Response — 200 OK
| Field | Type | Meaning |
|---|---|---|
orderReference | string | Unique ID for this payment — save it, you'll need it to check status later |
paymentPageUrl | string | Send your customer's browser here to pay |
status | string | Always "PENDING" at this point — nobody's paid yet |
{
"orderReference": "82732295-74f3-421d-a528-e0bacc474ddd",
"paymentPageUrl": "https://paypage.nbc.ngenius-payments.com/?code=55ca6510320c9525",
"status": "PENDING"
}
Error responses
| Status | Body | What it means |
|---|---|---|
| 401 | {"error": "Missing or invalid Authorization header"} | No Authorization header sent at all |
| 401 | {"error": "Invalid API key"} | Key sent, but it's wrong or disabled |
| 400 | {"error": "amount must be a valid number"} | amount missing, or not a number |
| 400 | {"error": "amount must be greater than zero"} | amount was zero or negative |
| 400 | {"error": "amount is too large"} | amount exceeds what we can store — almost certainly a units mistake on your end |
| 400 | {"error": "currency must be a 3-letter currency code"} | currency missing, or not exactly 3 letters |
| 403 | {"error": "redirect_url host '...' is not allowed for this client"} | Your redirect_url isn't on your approved list |
| 429 | {"error": "Too many requests. Please try again shortly."} | More than 120 payment-creation requests in a minute — see the FAQ |
| 502 | {"error": "Failed to create payment order"} | NBC's own system didn't respond — not your fault, safe to retry (use the same Idempotency-Key) |
Refunds all or part of one of your own paid orders, back to the card the customer paid with. Can be called more than once on the same order for multiple partial refunds, up to the original amount.
Request body fields (all optional)
| Field | Type | Meaning |
|---|---|---|
amount | number | How much to refund, in normal currency units. Omit it entirely to refund the full remaining amount. |
reason | string | Free text, kept in our audit log — not shown to the customer |
Full example — refund everything left
curl -X POST https://accesspay.eopsprimax.com/api/v1/payments/82732295-74f3-421d-a528-e0bacc474ddd/refund \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "customer requested cancellation"}'
Full example — partial refund
curl -X POST https://accesspay.eopsprimax.com/api/v1/payments/82732295-74f3-421d-a528-e0bacc474ddd/refund \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 1500, "reason": "one item out of stock"}'
Response — 200 OK
| Field | Type | Meaning |
|---|---|---|
orderReference | string | Same ID you called with |
status | string | PARTIALLY_REFUNDED or REFUNDED |
refundedAmount | string | Total refunded on this order so far, including this call and any earlier ones |
totalAmount | string | The order's original amount, for comparison |
{
"orderReference": "82732295-74f3-421d-a528-e0bacc474ddd",
"status": "REFUNDED",
"refundedAmount": "5000.00",
"totalAmount": "5000.00"
}
Error responses
| Status | Body | What it means |
|---|---|---|
| 404 | {"error": "Order not found"} | Doesn't exist, or belongs to a different platform |
| 400 | {"error": "Cannot refund 6000 TZS -- only 5000.00 TZS is left to refund."} | Amount exceeds what's left to refund on this order |
| 400 | {"error": "This order has already been fully refunded."} | Nothing left to refund |
| 400 | {"error": "NBC isn't currently offering a refund on this order..."} | The order isn't in a refundable state right now |
| 502 | {"error": "Could not process the refund with NBC"} | See the callout above — check the order's status before retrying |
Creates a new M-Pesa payment — sends an approval prompt directly to the customer's phone. Same endpoint as card, different payment_method.
Request headers
| Header | Required? | Value |
|---|---|---|
Authorization | Yes | Bearer YOUR_API_KEY |
Content-Type | Yes | application/json |
Idempotency-Key | Recommended | Any unique string you generate per attempt |
Request body fields
| Field | Type | Required? | Meaning |
|---|---|---|---|
amount | number | Yes | In normal currency units, not cents. |
currency | string | Yes | Must be TZS. |
payment_method | string | Yes | Must be "mpesa" |
phone_number | string | Yes | Tanzanian MSISDN, e.g. 0712345678 or 255712345678. The customer gets the approval prompt on this phone. |
Full example
curl -X POST https://accesspay.eopsprimax.com/api/v1/payments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2026-07-13-00043" \
-d '{
"amount": 5000,
"currency": "TZS",
"payment_method": "mpesa",
"phone_number": "0712345678"
}'
Response — 200 OK (once available)
No paymentPageUrl — there's no hosted page, the prompt goes straight to the customer's phone. A message field replaces it.
| Field | Type | Meaning |
|---|---|---|
orderReference | string | Unique ID for this payment — save it, you'll need it to check status later |
status | string | Always "PENDING" at this point |
message | string | Human-readable, safe to show your customer as-is |
{
"orderReference": "a1b2c3d4e5f6...",
"status": "PENDING",
"message": "Check your phone to approve the M-Pesa payment request."
}
Error responses
| Status | Body | What it means |
|---|---|---|
| 401 | {"error": "Missing or invalid Authorization header"} | No Authorization header sent at all |
| 401 | {"error": "Invalid API key"} | Key sent, but it's wrong or disabled |
| 400 | {"error": "amount must be a valid number"} | amount missing, or not a number |
| 400 | {"error": "amount must be greater than zero"} | amount was zero or negative |
| 400 | {"error": "M-Pesa payments must be in TZS"} | currency wasn't TZS |
| 400 | {"error": "phone_number must be a valid Tanzanian number, e.g. 0712345678 or 255712345678"} | Missing or invalid phone number |
| 429 | {"error": "Too many requests. Please try again shortly."} | More than 120 payment-creation requests in a minute — see the FAQ |
| 502 | {"error": "Failed to initiate M-Pesa payment"} | See the callout above |
These two endpoints work the same way regardless of payment method — a M-Pesa order and a card order both show up here with a paymentMethod field telling you which is which.
Looks up a single payment you already created, by the reference you got back when creating it.
Full example
curl https://accesspay.eopsprimax.com/api/v1/payments/82732295-74f3-421d-a528-e0bacc474ddd \ -H "Authorization: Bearer YOUR_API_KEY"
Response — 200 OK
| Field | Type | Meaning |
|---|---|---|
orderReference | string | Same ID you looked up |
status | string | One of PENDING, PAID, FAILED, CANCELLED, EXPIRED (auto-closed after 48h with no confirmed outcome), PARTIALLY_REFUNDED, REFUNDED |
paymentMethod | string | "CARD" or "MPESA" |
amount | string | Decimal string, e.g. "5000.00" — always 2 decimal places |
currency | string | The currency you created it with |
phoneNumber | string or null | M-Pesa orders only |
createdAt | string | ISO 8601 timestamp, UTC |
updatedAt | string | Same format — changes when the status changes |
{
"orderReference": "82732295-74f3-421d-a528-e0bacc474ddd",
"status": "PAID",
"paymentMethod": "CARD",
"amount": "5000.00",
"currency": "TZS",
"phoneNumber": null,
"createdAt": "2026-07-13T08:30:47.551072+00:00",
"updatedAt": "2026-07-13T08:31:02.112904+00:00"
}
Error responses
| Status | Body | What it means |
|---|---|---|
| 401 | {"error": "Invalid API key"} | Bad or missing key |
| 404 | {"error": "Order not found"} | Either that reference doesn't exist, or it belongs to a different platform — you'll never be told which, both look identical from the outside |
Returns every payment your platform has ever created here — card and M-Pesa mixed together, newest first, paginated. This is how you'd build your own "payment history" screen.
Query parameters (all optional, add as ?param=value)
| Param | Type | Default | Meaning |
|---|---|---|---|
page | number | 1 | Which page of results |
page_size | number | 25 | Results per page, capped at 100 |
status | string | (none) | Only show one status: PENDING, PAID, FAILED, CANCELLED, EXPIRED, PARTIALLY_REFUNDED, or REFUNDED |
Full example
curl "https://accesspay.eopsprimax.com/api/v1/payments?page=1&page_size=25&status=PAID" \ -H "Authorization: Bearer YOUR_API_KEY"
Response — 200 OK
| Field | Type | Meaning |
|---|---|---|
count | number | Total matching payments, across all pages |
page | number | Which page this response is |
totalPages | number | How many pages exist in total |
hasNext | boolean | Whether page + 1 exists |
hasPrevious | boolean | Whether page - 1 exists |
results | array | The payments themselves — same fields as the single-status endpoint, plus email |
{
"count": 42,
"page": 1,
"totalPages": 2,
"hasNext": true,
"hasPrevious": false,
"results": [
{
"orderReference": "82732295-74f3-421d-a528-e0bacc474ddd",
"status": "PAID",
"paymentMethod": "CARD",
"amount": "5000.00",
"currency": "TZS",
"email": "customer@example.com",
"phoneNumber": null,
"createdAt": "2026-07-13T08:30:47.551072+00:00",
"updatedAt": "2026-07-13T08:31:02.112904+00:00"
},
{
"orderReference": "37930c8d-b070-4bbf-9cd8-16891c67fb3d",
"status": "PENDING",
"paymentMethod": "MPESA",
"amount": "1000.00",
"currency": "TZS",
"email": null,
"phoneNumber": "255712345678",
"createdAt": "2026-07-13T08:46:33.408918+00:00",
"updatedAt": "2026-07-13T08:46:33.408918+00:00"
}
]
}
results: [] with
count: 0 just means you haven't created any yet.
Sends money out to someone directly — not tied to any prior payment. Different from a refund: there's no existing order involved.
Request body fields
| Field | Type | Required? | Meaning |
|---|---|---|---|
recipient_name | string | Yes | Who's receiving the money |
recipient_identifier | string | Yes | Phone number, bank account number, or IBAN — exact requirements not yet confirmed |
amount | number | Yes | Normal currency units, not cents |
currency | string | Yes | 3-letter ISO 4217 code |
reason | string | No | Free text, kept in our audit log |
Full example
curl -X POST https://accesspay.eopsprimax.com/api/v1/payouts \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipient_name": "Jane Customer",
"recipient_identifier": "255700000000",
"amount": 5000,
"currency": "TZS",
"reason": "goodwill refund via mobile money"
}'
Response — 200 OK (once enabled)
{
"reference": "payout-1a2b3c4d5e6f7a8b9c0d1e2f",
"status": "SENT"
}
Error responses
| Status | Body | What it means |
|---|---|---|
| 400 | {"error": "recipient_name and recipient_identifier are required"} | Missing a required field |
| 502 | {"reference": "...", "status": "FAILED", "error": "NBC rejected the payout"} | See the callout above |
Same pattern as payments: look up one payout by reference, or list all of your platform's payouts (paginated, supports page/page_size, newest first).
Full example — one payout
curl https://accesspay.eopsprimax.com/api/v1/payouts/payout-1a2b3c4d5e6f7a8b9c0d1e2f \ -H "Authorization: Bearer YOUR_API_KEY"
Response — 200 OK
{
"reference": "payout-1a2b3c4d5e6f7a8b9c0d1e2f",
"status": "FAILED",
"recipientName": "Jane Customer",
"recipientIdentifier": "255700000000",
"amount": "5000.00",
"currency": "TZS",
"reason": "goodwill refund via mobile money",
"createdAt": "2026-07-14T09:00:00.000000+00:00",
"updatedAt": "2026-07-14T09:00:01.000000+00:00"
}
Full example — list
curl "https://accesspay.eopsprimax.com/api/v1/payouts?page=1&page_size=25" \ -H "Authorization: Bearer YOUR_API_KEY"
Response shape matches the payments list endpoint (count, page, totalPages, hasNext, hasPrevious, results) — results contains objects with the same fields as the single-payout response above.
A common thing to build on your side: a table showing your platform's own payment history, with columns like Reference, Amount, Method, Status, Email/Phone, Created, plus whatever action buttons make sense for you. Note there's no "Platform" column on your side — every row already belongs to you, that column only makes sense on our internal admin view where staff see multiple platforms at once.
Example — JavaScript (fetch)
async function loadMyPayments(page = 1) {
const res = await fetch(
`https://accesspay.eopsprimax.com/api/v1/payments?page=${page}&page_size=25`,
{ headers: { Authorization: "Bearer YOUR_API_KEY" } }
);
const data = await res.json();
// data.results is your row data -- render it into your own table:
// Reference -> row.orderReference
// Amount -> `${row.amount} ${row.currency}`
// Method -> row.paymentMethod
// Status -> row.status
// Email/Phone -> row.email ?? row.phoneNumber ?? "—"
// Created -> new Date(row.createdAt).toLocaleString()
// Actions -> whatever your own UI needs
return data;
}
Example — Python (requests)
import requests
def load_my_payments(page=1, page_size=25, status=None):
params = {"page": page, "page_size": page_size}
if status:
params["status"] = status
response = requests.get(
"https://accesspay.eopsprimax.com/api/v1/payments",
headers={"Authorization": "Bearer YOUR_API_KEY"},
params=params,
)
response.raise_for_status()
return response.json() # ["results"] is your row data, same fields as above
Is amount in cents or whole units?
Whole units. Send 5000 for 5,000 TZS, not 500000. We convert to the smallest currency unit internally before talking to the gateway — you never need to think about that conversion.
What timezone are the timestamps in?
UTC, always, in ISO 8601 format (2026-07-13T08:30:47.551072+00:00). Convert to your own local timezone on your side for display.
Is there a sandbox / test mode?
No — this integration talks to the live gateways. There is no separate sandbox environment. Use small real amounts when testing, and note test transactions still show up in your payment history like any other.
How is M-Pesa different from card, from my side?
You send phone_number instead of redirect_url/email, and there's no paymentPageUrl to redirect your customer to — the approval prompt appears directly on their phone. Everything else is identical: same endpoint, same auth, same idempotency handling, same status-check and list endpoints, same webhook/callback notification once they approve or decline.
Is there a rate limit?
Yes, per platform (your key, not shared with anyone else): creating a payment is limited to 120 requests/minute, refunds and payouts to 30/minute each, and status polling (any GET endpoint) to 300/minute. Going over any of these returns 429 with {"error": "Too many requests. Please try again shortly."}. In practice you shouldn't be near any of these — the webhook already tells you the moment a payment's status changes, so polling should be a backup, not your primary mechanism.
My redirect_url keeps getting rejected with 403 — why?
Its domain isn't on your platform's approved list. Ask whoever manages your Haminass Pay platform entry (on the Platforms page) to add it, or drop redirect_url entirely to use the default. Card only — M-Pesa doesn't use this.
I created a payment but never got a webhook — what do I do?
Poll GET /api/v1/payments/<orderReference> for that specific order to check its real status directly, rather than waiting indefinitely.
Can I cancel or refund a payment through this API?
Refund — see the Card tab (card payments only), but as flagged there, it's not currently available. There's no separate "cancel" endpoint; a PENDING order that's never paid just stays PENDING and eventually auto-expires (see the EXPIRED status) rather than needing to be explicitly cancelled.
Can I send money to a customer who didn't pay me, through this API?
That's the Payouts tab — but as flagged there, it's not currently available. The endpoint exists and is documented so you're ready for it once it is.
What happens if I lose my API key?
Ask an admin to rotate it for you on your platform's page (Platforms → your platform → Rotate key). The old key stops working immediately, so coordinate the swap on your side first.