The gateway takes a payment request from your system, sends it to the customer's mobile money operator, and tells you the result. You send one request. We call your server back when the payment is complete.
Important
Payments are asynchronous. A 202 response means we accepted the request, not that the customer has paid. Wait
for the FINAL callback, or fetch the transaction, before you release goods or mark an order as paid.
How it works#
- You send
POST/api/momowith the customer's phone number, an amount and your own reference. - We reply straight away with
202 Accepted. The customer gets a payment prompt on their phone. - When the payment reaches a final status, we send a
FINALcallback to yournotification_url.
For payments that the customer starts on their own (for example by paying your business number from their phone), we
first send a VALIDATE callback so that you can confirm the payment is expected. See Callbacks.
Before you start#
You receive these during onboarding:
| Item | Where you use it |
|---|---|
| Base URL | The address of the gateway. Every path below is added to it. |
X-Client-Id |
Sent as a header on every request. Identifies your account. |
X-Client-Secret |
Sent as a header on every request. |
| Signature key | Used to compute X-Signature. Never sent. It only lives on your server. |
| Our IP addresses | The addresses we send callbacks from. Whitelist them on your firewall. |
All communication uses HTTPS. Requests and responses are JSON (application/json, UTF-8).
Warning
Keep the client secret and the signature key on your server. Never put them in a mobile app, in browser code or in a public repository.
Signing requests#
Every request to the gateway carries an X-Signature header, and so does every callback we send to you. The signature
proves that the message came from the other side and was not changed on the way.
| Header | Value |
|---|---|
X-Timestamp |
The current time in Unix seconds, such as 1792335559. |
X-Signature |
The signature, as described below. |
To compute the signature:
- Take the
X-Timestampvalue and the raw JSON body, and join them as text:timestamp + body. - Remove every whitespace character from that text (spaces, tabs and new lines), including spaces inside values.
"Airtel Money"becomes"AirtelMoney". - Compute an HMAC with SHA-256, using your signature key as the key.
- Encode the result as Base64 (standard alphabet, with padding).
Note
Sign the same bytes you send. If your HTTP library re-formats the JSON after you sign it, the signature will not match. Build the body string once, sign it, then send that exact string.
Use this example to test your code. With the signature key sk_test_example_key, timestamp 1792333239 and this body:
{
"type": "FINAL",
"data": {
"reference": "3137804641763844",
"transaction_reference": "21453994292708209",
"receipt": null,
"completed": "2026-10-18T14:20:37.698824Z",
"status": "FAILED",
"status_description": "Failed",
"operator": "Airtel Money"
}
}
the signature is:
FC68dcFwYbpNBT+STbH7ZmE+5Ij4rW+8jz2o72+QOKg=
Each snippet below returns that value for the example above.
Python
import base64, hashlib, hmac, re
def sign(timestamp: str, payload: str, secret: str) -> str:
data = re.sub(r"\s+", "", timestamp + payload)
digest = hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
JavaScript (Node.js)
const crypto = require("node:crypto");
function sign(timestamp, payload, secret) {
const data = (timestamp + payload).replace(/\s+/g, "");
return crypto.createHmac("sha256", secret).update(data, "utf8").digest("base64");
}
PHP
function sign(string $timestamp, string $payload, string $secret): string {
$data = preg_replace('/\s+/', '', $timestamp . $payload);
return base64_encode(hash_hmac('sha256', $data, $secret, true));
}
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"regexp"
)
var whitespace = regexp.MustCompile(`\s+`)
func sign(timestamp, payload, secret string) string {
data := whitespace.ReplaceAllString(timestamp+payload, "")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(data))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
Rust (crates hmac, sha2 and base64)
use base64::{engine::general_purpose::STANDARD, Engine};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
fn sign(timestamp: &str, payload: &str, secret: &str) -> String {
let data: String = format!("{timestamp}{payload}")
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key length works");
mac.update(data.as_bytes());
STANDARD.encode(mac.finalize().into_bytes())
}
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
static String sign(String timestamp, String payload, String secret) throws Exception {
String data = (timestamp + payload).replaceAll("\\s+", "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
Create a transaction#
POST/api/momo
Starts a payment. Sign the request as described in Signing requests.
Headers
| Header | Required | Description |
|---|---|---|
X-Client-Id |
Yes | Your client ID. |
X-Client-Secret |
Yes | Your client secret. |
X-Timestamp |
Yes | Current time in Unix seconds. |
X-Signature |
Yes | The request signature. |
Idempotency-Key |
Yes | A unique value for this request. Sending the same key again does not create a second payment. |
Content-Type |
Yes | application/json |
Body
| Field | Type | Description |
|---|---|---|
msisdn |
string | The customer's phone number in international format, without +, such as 255685007797. |
reference |
string | Your own reference for this payment. Use it to find the transaction later. |
amount |
number | The amount to collect. |
type |
string | COLLECTION to collect money from the customer. |
notification_url |
string | The HTTPS address on your server where we send callbacks. |
curl -X POST "https://GATEWAY_URL/api/momo" \
-H "X-Client-Id: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-H "X-Timestamp: 1792335559" \
-H "X-Signature: iv80dq3Qv8WCDaHFUuXgzLje8NM1OfRcNflf1EERbAM=" \
-H "Idempotency-Key: 3141259380648434" \
-H "Content-Type: application/json" \
-d '{"msisdn":"255685007797","reference":"3141259380648434","amount":100000,"type":"COLLECTION","notification_url":"https://shop.example.com/hooks/momo"}'
The signature in this example uses the test key sk_test_example_key. Yours will differ.
Response 202 Accepted
{
"status": true,
"reference": "3141259380648434",
"message": "Transaction accepted",
"data": null
}
reference is the value you sent. The customer now receives a prompt on their phone. The result comes later by
callback.
Other responses
| HTTP status | Meaning | What to do |
|---|---|---|
| 400 | Invalid request body. | Fix the request. Do not retry it as it is. |
| 403 | The request came from an address that is not whitelisted, or your credentials are disabled. | Check that you call us from a whitelisted address, then contact support. |
| 404 | Credentials not found. | Check X-Client-Id and X-Client-Secret. |
All error responses use the same JSON shape as the success response, with "status": false and a message that
explains the problem.
Get a transaction#
GET/api/momo/{thirdPartyReference}
Returns the latest result of a transaction. {thirdPartyReference} is the reference you sent when you created it.
Use it when a callback does not arrive, or to double-check a payment before you release goods. Do not poll in a tight loop. Callbacks are the main way to learn the result.
Send the same X-Client-Id, X-Client-Secret, X-Timestamp and X-Signature headers. A GET has no body, so you
sign only the timestamp.
curl "https://GATEWAY_URL/api/momo/3141259380648434" \
-H "X-Client-Id: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIGNATURE"
Response 200 OK
The data field holds the same object that a FINAL callback sends.
{
"status": true,
"reference": "3141259380648434",
"message": "Transaction found",
"data": {
"type": "FINAL",
"data": {
"reference": "3141259380648434",
"transaction_reference": "21453994292708209",
"receipt": null,
"completed": "2026-10-18T14:20:37.698824Z",
"status": "FAILED",
"status_description": "Failed",
"operator": "Airtel Money"
}
}
}
Other responses
| HTTP status | Meaning |
|---|---|
| 403 | The request came from an address that is not whitelisted, or your credentials are disabled. |
| 404 | Credentials not found, or no transaction with that reference. |
Callbacks#
We send callbacks as POST requests to the notification_url you gave in the request. There are two types, shown in
the type field.
type |
When we send it | What you do |
|---|---|---|
VALIDATE |
Before a payment that the customer started themselves is completed. | Check the details and answer with success to accept the payment. |
FINAL |
When the transaction reaches its final status. | Update your order and answer with success. |
Every callback has these headers:
| Header | Description |
|---|---|
X-Signature |
The signature. Verify it. See Signing requests. |
X-Timestamp |
The time we sent the callback, in Unix seconds. |
Idempotency-Key |
A unique ID for this transaction (the same as transaction_reference). Use it to ignore repeats. |
Content-Type |
application/json |
FINAL callback#
{
"type": "FINAL",
"data": {
"reference": "3137804641763844",
"transaction_reference": "21453994292708209",
"receipt": null,
"completed": "2026-10-18T14:20:37.698824Z",
"status": "FAILED",
"status_description": "Failed",
"operator": "Airtel Money"
}
}
| Field | Type | Description |
|---|---|---|
reference |
string | Your own reference, as you sent it. |
transaction_reference |
string | Our unique ID for the transaction. Quote it when you contact support. |
receipt |
string or null | The operator's receipt number. It is null when the operator gives none, which is usual for failed payments. |
completed |
string | When the transaction reached its final status. ISO 8601, in UTC. |
status |
string | The final status. See below. |
status_description |
string | A short readable explanation of the status. |
operator |
string | The mobile money operator, such as Airtel Money. |
status |
Meaning |
|---|---|
SUCCESS |
The customer paid. The money is yours. |
FAILED |
The payment did not go through. For example the customer cancelled, or had too little money. |
VALIDATE callback#
{
"type": "VALIDATE",
"data": {
"reference": "3137804641763844",
"transaction_reference": "21453994292708209",
"receipt": null,
"operator": "Airtel Money",
"amount": 1000,
"business_number": 2121213
}
}
| Field | Type | Description |
|---|---|---|
reference |
string | The customer's reference for the payment. |
transaction_reference |
string | Our unique ID for the transaction. |
receipt |
string | The operator's receipt number. Some operators do not send one, so the field may be missing or null. |
operator |
string | The mobile money operator. |
amount |
number | The amount the customer is paying. |
business_number |
number | The business number the customer is paying to. |
Check the reference and the amount against your own records. If they are right, answer with success. The payment then
continues, and you receive the FINAL callback when it finishes.
Your response#
Answer every callback with HTTP 200 and this body:
{
"status": true,
"message": "Success"
}
Tip
Callbacks can arrive more than once. Store the Idempotency-Key of each callback you have handled, and when you see
the same one again, answer with success without processing it a second time.
Respond quickly. Save the result and do slow work, such as sending emails, after you have replied.
Security checklist#
Caution
Callbacks that are not checked can be forged. Anyone who knows your callback URL can send a fake "payment succeeded" message.
- Whitelist our IP addresses. Accept callbacks only from the addresses we gave you. Requests to the gateway from
other addresses are refused with
403. - Use HTTPS. Your
notification_urlmust use HTTPS in production. - Verify the signature on every callback. Compute it from the raw body and the
X-Timestampheader, and compare it withX-Signature. Reject the callback if they differ. Compare in constant time, with a function such ashmac.compare_digestin Python orMessageDigest.isEqualin Java. - Check the timestamp. Reject callbacks whose
X-Timestampis far from your clock, for example by more than 5 minutes. This stops someone from replaying an old message. - Check the idempotency key. Ignore callbacks you have already processed.
- Check the details. Match
referenceand the amount with your own order before you mark it paid. - Protect your secrets. Keep the client secret and the signature key in a secrets manager or environment variables. Do not log them, and tell us at once if you think they leaked.
Getting help#
If you contact support, include the transaction_reference and your own reference. With these two we can trace the
payment from request to callback.