PalmPesa Payments API v2
Collect mobile-money payments in Tanzania with one API call. The customer approves a prompt on their phone, and PalmPesa notifies your server when the payment is final. Built and maintained by Eagle Star Technology.
How a payment works
-
1Your server requests a payment
Send the amount, the customer's phone number and your
callback_url. PalmPesa answers 202 Accepted with anorder_idstraight away and sends the prompt in the background. -
2The customer approves on their phone
The customer receives a mobile-money prompt (USSD push) and enters their PIN. Supported wallets include M-Pesa, Mixx by Yas (Tigo Pesa), Airtel Money and HaloPesa.
-
3PalmPesa notifies your server
When the payment reaches a final state, PalmPesa sends a callback to your
callback_url. You can also check the status at any time. OnlyCOMPLETEDmeans the money was received.
Getting Started
-
1Create a developer account
Register at palmpesa.co.tz and complete the developer registration and verification.
-
2Get your API token
After your account is approved, generate your API token from the dashboard. Requests made before approval return
403. -
3Prepare a callback endpoint
Expose a public HTTPS URL on your server that accepts
POSTrequests and answers200 OKquickly. See Callbacks. -
4Test with a small amount
Send a real payment of a few hundred TZS to your own phone and confirm your callback handler records it before going live.
Never put your API token in mobile apps, browser JavaScript or public repositories. Call the API only from your server.
Try it in Postman
Request Payment and Payment Status, ready to send, with example responses for each.
-
1Import
In Postman, choose Import and select the downloaded file.
-
2Set
api_tokenand your test detailsOpen the collection's Variables tab and enter your
api_token, your own phone number aspayer_phone(for example255712345678) and your HTTPScallback_url. -
3Send "Request payment"
Approve the prompt on your phone, then send Get payment status. The
order_idis saved for you, and every send of Request payment uses a newtransaction_id.
These are real payments: each one charges amount TZS
(1,000 by default) to payer_phone. Don't share or
export the collection after adding your token.
Authentication
Send your API token as a Bearer token in the
Authorization header of every request. Always send
Accept: application/json so that errors come back as
JSON.
Authorization: Bearer YOUR_API_TOKEN Content-Type: application/json Accept: application/json
| Status | Body | Meaning |
|---|---|---|
| 401 | {"error":"Unauthorized"} |
No Authorization: Bearer header was sent. |
| 401 | {"error":"Invalid Token"} |
The token is wrong or has been replaced. |
| 403 |
{"error":"Account verification pending. API access is available after admin approval."}
|
Your account has not been approved yet. |
Base URL
Prepend the base URL to every path on this page, for example
https://palmpesa.co.tz/api/v2/async/payments.
Request Payment
Endpoint 01Starts a mobile-money payment. PalmPesa records the request and answers 202 Accepted straight away, then sends the prompt to the customer's phone in the background. Follow the payment with callbacks or the Payment Status endpoint.
Request Headers
| Header | Required | Value |
|---|---|---|
| Authorization | Required | Bearer YOUR_API_TOKEN |
| Content-Type | Required | application/json |
| Accept | Required | application/json |
Request Body
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
| transaction_id | string | Required |
Your own unique ID for this payment. Up to 191 characters:
letters, digits, . _
: -. Reusing it safely retries the
same payment (see Retries).
|
"ORDER-2026-000123" |
| amount | integer | Required | Amount in whole Tanzanian shillings, 100 to 100,000,000. | 10000 |
| currency | string | Required | Must be TZS. |
"TZS" |
| phone | string | Required |
The customer's mobile-money number. 0712345678,
712345678, +255 712 345 678 and
255712345678 are all accepted and normalised to
2556XXXXXXXX or 2557XXXXXXXX.
|
"255712345678" |
| name | string | Required | Customer name, up to 255 characters. | "Juma Hassan" |
| string | Required | A valid email address for the customer. | "juma@example.com" | |
| callback_url | string | Required |
Where PalmPesa sends the result. Must be a public
HTTPS URL (no localhost or
private IP addresses), up to 2048 characters.
|
"https://shop.example.com/palmpesa/callback" |
| buyer_remarks | string | Optional | Note shown with the payment, up to 255 characters. | "Payment for order 123" |
| merchant_remarks | string | Optional | Your internal note, up to 255 characters. | "Web shop" |
Example Request
curl -X POST 'https://palmpesa.co.tz/api/v2/async/payments' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "transaction_id": "ORDER-2026-000123", "amount": 10000, "currency": "TZS", "phone": "255712345678", "name": "Juma Hassan", "email": "juma@example.com", "callback_url": "https://shop.example.com/palmpesa/callback", "buyer_remarks": "Payment for order 123" }'
Response 202 Accepted
{
"success": true,
"message": "Payment accepted.",
"data": {
"transaction_id": "ORDER-2026-000123",
"order_id": "PALMPESAV2A20261008120000123EFGH5678",
"status": "INITIATING",
"failure_reason": null,
"amount": 10000,
"currency": "TZS",
"reference": null,
"channel": null,
"created_at": "2026-10-08 12:00:00",
"updated_at": "2026-10-08 12:00:00",
"status_url": "https://palmpesa.co.tz/api/v2/async/payments/PALMPESAV2A20261008120000123EFGH5678"
}
}
Store transaction_id and order_id with your
order. A 202 and INITIATING do
not mean the money was received: wait for a
payment.completed callback.
Response fields
| Field | Description |
|---|---|
| transaction_id | Your ID from the request. |
| order_id | PalmPesa's ID for the payment. Use it with the Payment Status endpoint. |
| status |
The current status. Always
INITIATING on a new payment.
|
| failure_reason |
Why the prompt could not be sent, when the status is
INITIATION_FAILED. Otherwise null.
|
| amount, currency | As requested. |
| reference |
The payment gateway's reference for the payment, once it
has one. null at first.
|
| channel |
The mobile-money network that handled the payment, for
example TIGOPESATZ. null until
the network reports it.
|
| created_at, updated_at | When the payment was created and when it last changed. |
| status_url | The full Payment Status URL for this payment. |
If the prompt cannot be sent
The request itself still succeeds with 202. If the
mobile-money provider refuses the payment or is unavailable, the
payment becomes INITIATION_FAILED with a
failure_reason, and you
receive a payment.initiation_failed callback. Nothing
was charged. To try again, send a new request with a
new transaction_id.
Retries and duplicate requests
Your account plus transaction_id identify a payment. If
your request times out, send the same request
again:
-
Same
transaction_idand same details: 202 Accepted with the originalorder_idand the payment's current status. The customer is not prompted a second time. -
Same
transaction_idwith different details: 409 Conflict{"message":"This transaction_id was already used with different payment details."} -
A
transaction_idbelongs to one payment for good. To charge the customer again afterINITIATION_FAILED,CANCELLED,REJECTEDorEXPIRED, use a newtransaction_id.
Payment Status
Endpoint 02
Returns the current status of a payment created with
Request Payment. Use the
status_url from that response, or put the
order_id in the path.
Example Request
curl 'https://palmpesa.co.tz/api/v2/async/payments/PALMPESAV2A20261008120000123EFGH5678' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'Accept: application/json'
Response 200 OK
{
"success": true,
"data": {
"transaction_id": "ORDER-2026-000123",
"order_id": "PALMPESAV2A20261008120000123EFGH5678",
"status": "COMPLETED",
"failure_reason": null,
"amount": 10000,
"currency": "TZS",
"reference": "0289999288",
"channel": "TIGOPESATZ",
"created_at": "2026-10-08 12:00:00",
"updated_at": "2026-10-08 12:01:30",
"status_url": "https://palmpesa.co.tz/api/v2/async/payments/PALMPESAV2A20261008120000123EFGH5678"
}
}
The fields are the same as in the Request Payment response.
Response 404 Not Found
The order does not exist or belongs to another account.
{ "message": "Payment not found.", "order_id": "PALMPESAV2A20261008120000123EFGH5678" }
This endpoint reads PalmPesa's records and does not contact the mobile-money network, so it is cheap to call. Prefer callbacks. If you poll, wait a few seconds between calls (limit: 120 requests per minute) and stop once the status is final.
Payment Statuses
| Status | Meaning | Final |
|---|---|---|
| INITIATING | Accepted; the prompt has not been sent yet. | No |
| PENDING | Prompt sent; waiting for the customer to approve. | No |
| AMBIGUOUS | The network did not confirm the prompt in time; PalmPesa is checking the outcome. Do not resend the payment. | No |
| COMPLETED | Money received. The only status that means paid. | Yes |
| CANCELLED | The customer cancelled or did not approve. | Yes |
| REJECTED | The mobile-money network rejected the payment. | Yes |
| INITIATION_FAILED |
The prompt could not be sent. Nothing was charged. Retry with
a new transaction_id.
|
Yes |
| EXPIRED | No payment was received: the customer had not paid within about an hour of the request. Do not fulfil the order. See Expired payments. | Yes (a late COMPLETED can still follow) |
Failure reasons
When a payment is INITIATION_FAILED,
failure_reason explains why:
| failure_reason | Meaning |
|---|---|
| SELCOM_REJECTED_ORDER | The payment gateway refused to create the order. |
| SELCOM_REJECTED_PUSH | The gateway refused to send the prompt to the phone. |
| PROVIDER_UNAVAILABLE | The gateway is temporarily unavailable; try again later. |
| PROVIDER_ERROR | The gateway kept failing after several attempts. |
Expired payments
If the customer hasn't paid within about an hour,
the payment becomes EXPIRED and you receive a
payment.expired callback. A mobile-money payment request
stays open for 30 minutes, and PalmPesa waits a further grace period
for late confirmations. Then, if the payment is still
INITIATING, PENDING or
AMBIGUOUS, PalmPesa checks it with the network one last
time:
-
Paid: it becomes
COMPLETEDand you receivepayment.completed. -
Cancelled or rejected: it becomes
CANCELLEDorREJECTED, with the matching callback. -
Anything else: it becomes
EXPIREDand you receive apayment.expiredcallback.
This usually happens 45 to 60 minutes after the payment was created,
occasionally later if the network cannot be reached.
EXPIRED means no payment was received: do not fulfil the
order. To collect the money, create a new payment with a new
transaction_id.
A payment can still complete minutes after the prompt was sent,
and in rare cases the network confirms it only after it has
expired. You then receive payment.completed after
payment.expired, and the status changes to
COMPLETED. COMPLETED always means paid,
whenever it arrives: fulfil the order, or contact the customer if
you have already closed it. Keep accepting callbacks,
deduplicate them on event_id, and never mark an
order as paid until you receive COMPLETED.
Callbacks
When a payment reaches a final status, PalmPesa sends a
POST request with a JSON body to the
callback_url you supplied. There are no callbacks for
INITIATING, PENDING or
AMBIGUOUS.
Request headers sent to you
| Header | Always | Value |
|---|---|---|
| Content-Type | Yes | application/json |
| X-PalmPesa-Event-Id | Yes | Same value as event_id in the body |
| X-PalmPesa-Timestamp | Only if signing is enabled | Unix time in seconds when the callback was signed |
| X-PalmPesa-Signature | Only if signing is enabled |
sha256= followed by a hex HMAC (see
signed callbacks)
|
Body
{
"event": "payment.completed",
"event_id": "82c12c18a92fc6f3bfb61f89bbf0b8f10f7790a3cfe7b0940734eb43eca87eb1",
"transaction_id": "ORDER-2026-000123",
"order_id": "PALMPESAV2A20261008120000123EFGH5678",
"selcom_reference": "0289999288",
"status": "COMPLETED",
"amount": 10000,
"currency": "TZS",
"channel": "TIGOPESATZ",
"phone": "255712345678",
"processed_at": "2026-10-08T12:01:31+03:00"
}
Events
| event | status | What to do |
|---|---|---|
| payment.completed | COMPLETED | Money received. Fulfil the order. |
| payment.cancelled | CANCELLED | The customer cancelled. Do not fulfil. |
| payment.rejected | REJECTED | The network rejected the payment. Do not fulfil. |
| payment.initiation_failed | INITIATION_FAILED |
The prompt was never sent and nothing was charged. Retry with a
new transaction_id if you want to.
|
| payment.expired | EXPIRED |
Not paid within about an hour. Do not fulfil. A late
payment.completed can still follow (see
Expired payments).
|
Every event has the same fields. channel and
selcom_reference may be null for payments
that did not complete.
Responding and retries
-
Answer with any 2xx status within
10 seconds, for example
200 {"received": true}. Do slow work after you respond. - If your endpoint is unreachable or answers with a non-2xx status, PalmPesa retries delivery (up to 5 attempts in total).
-
You can receive the same event more than once. Use
event_idas an idempotency key and process each event only once. -
A
payment.completedevent is never followed by a different final status for the same payment. The reverse can happen: in rare casespayment.completedarrives afterpayment.expired. Treat it as paid.
Verifying a callback
Before you fulfil an order, check that the callback matches your records:
-
transaction_idandorder_idbelong to an order you created. amountandcurrencyequal what you requested.- You can also confirm with the Payment Status endpoint before fulfilling.
Signed callbacks (optional)
Signing is off unless PalmPesa has issued you a
signing secret. Without one, callbacks carry no
signature headers; rely on the checks above. With one, every
callback also carries X-PalmPesa-Timestamp and
X-PalmPesa-Signature, computed over the exact raw
request body:
signed_data = X-PalmPesa-Timestamp + "." + raw_request_body signature = "sha256=" + hex( HMAC-SHA256(signed_data, YOUR_SIGNING_SECRET) )
Example callback handler (PHP)
<?php
$rawBody = file_get_contents('php://input');
$secret = getenv('PALMPESA_SIGNING_SECRET'); // only if one was issued to you
if ($secret) {
$timestamp = $_SERVER['HTTP_X_PALMPESA_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_PALMPESA_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// 1. Ignore events you have already processed (look up $event['event_id']).
// 2. Match transaction_id, order_id, amount and currency with your order.
// 3. Mark the order paid only when $event['status'] === 'COMPLETED',
// even if it arrives after a payment.expired event.
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);
Errors & Limits
| Status | When | What to do |
|---|---|---|
| 401 | Missing or invalid API token | Check the Authorization: Bearer header. |
| 403 | Account awaiting approval | Wait for approval, or contact support. |
| 403 |
{"success":false,"code":"ACCOUNT_FROZEN",…} —
the account is temporarily restricted
|
Contact PalmPesa Support. |
| 404 | Unknown order_id on the status endpoint |
Check the ID and the API token used. |
| 409 |
transaction_id already used for a payment with
different details
|
Use a new transaction_id for a new payment. |
| 422 | Validation failed | Read errors for the field and the message. |
| 429 | Rate limit exceeded | Slow down and retry after a short wait. |
| 500 | Unexpected error |
Retry with the same transaction_id;
you will not be charged twice.
|
Validation error 422
{
"message": "The callback URL must be a publicly reachable HTTPS URL.",
"errors": {
"callback_url": ["The callback URL must be a publicly reachable HTTPS URL."]
}
}
Rate limits
| Endpoint | Limit |
|---|---|
POST /api/v2/async/payments |
30 requests per minute |
GET /api/v2/async/payments/{order_id} |
120 requests per minute |
Callbacks
When a payment reaches a final status, PalmPesa sends a POST request with a JSON body to the callback_url supplied when initiating payment. No callbacks are sent for INITIATING, PENDING, or AMBIGUOUS statuses.
Request Headers
| Header | Always | Value |
|---|---|---|
Content-Type | Yes | application/json |
X-PalmPesa-Event-Id | Yes | Same value as event_id in the body |
X-PalmPesa-Timestamp | Only if signing is enabled | Unix time in seconds when the callback was signed |
X-PalmPesa-Signature | Only if signing is enabled | sha256= followed by a hex HMAC |
Example Callback Body — payment.completed
{
"event": "payment.completed",
"event_id": "82c12c18a92fc6f3bfb61f89bbf0b8f10f7790a3cfe7b0940734eb43eca87eb1",
"transaction_id": "ORDER-2026-000123",
"order_id": "PALMPESAV2A20261008120000123EFGH5678",
"selcom_reference": "0289999288",
"status": "COMPLETED",
"amount": 10000,
"currency": "TZS",
"channel": "TIGOPESATZ",
"phone": "255712345678",
"processed_at": "2026-10-08T12:01:31+03:00"
}Events
| Event | Status | What to do |
|---|---|---|
payment.completed | COMPLETED | Payment received. Fulfil the order. |
payment.cancelled | CANCELLED | The customer cancelled. Do not fulfil. |
payment.rejected | REJECTED | The network rejected the payment. Do not fulfil. |
payment.initiation_failed | INITIATION_FAILED | The prompt was never sent and nothing was charged. Retry with a new transaction_id if desired. |
payment.expired | EXPIRED | Payment was not received within about an hour. Do not fulfil based on this event alone; a late payment.completed callback may still arrive. |
Match callbacks to your records using order_id and transaction_id. Process callbacks idempotently using event_id, since delivery retries can occur.
Support & Resources
Our team is available to help you integrate PalmPesa into your platform.
Postman Collection
Request Payment and Payment Status, ready to send
Download collection
Import, set api_token, send.
How to use it
Integration Checklist
- Call the API from your server only; keep the token secret.
-
Generate a unique
transaction_idper payment (for example a UUID) and store the returnedorder_id. -
On timeouts, retry with the same
transaction_id; for a new payment, use a new one. -
Treat only
COMPLETEDas paid. Never fulfil onINITIATING,PENDING,AMBIGUOUS,EXPIREDor a202. -
Handle a
payment.completedthat arrives afterpayment.expired: the customer has paid. -
Make your
callback_urlpublic HTTPS, answer 2xx within 10 seconds, and deduplicate onevent_id. - Test end to end with a small real payment to your own phone.