Send payments to your own server.
The phone posts each payment to an HTTPS address you control. Signed, retried, and the same format every time.
Read this first. A payment SMS is not proof of payment. It can be faked or delayed. Treat these requests as notifications, and confirm money-moving events through Safaricom’s Daraja callbacks.
- HTTPS only A certificate the phone trusts. No redirects, ten-second timeout.
- At least once The same payment can arrive twice. Use the idempotency key.
- Signed HMAC-SHA256 over the timestamp and the raw body.
- Queued Up to 500 payments for seven days if your server is down.
Try it in five minutes
You do not need to write a receiver first. The repository has a test server that checks the token and signature and prints what the phone sends.
- Start the test server on your computer. It needs Node and nothing else to install:
node scripts/webhook-test-server.mjs --port 3919 --token mytoken --secret mysecret - Give it an HTTPS address. The phone refuses plain HTTP. Use a tunnel:
cloudflared tunnel --url http://localhost:3919ngrok http 3919works too. - Fill in the Webhook card in the phone app: the tunnel address ending in
/hook, the tokenmytokenand the secretmysecret. Press Save and test. The server prints the test ping. - Turn the switch on and send yourself a real M-PESA payment, or use the setup checklist’s end-to-end test.
Type 500, 429, 401, slow or ok into the test server’s terminal to change its answer and watch the phone retry or pause.
The request
POST https://your.server/pesacast
Content-Type: application/json
Idempotency-Key: ABC123XYZ@2026-10-24T10:00:00+03:00
X-PesaCast-Device-Id: 5b0f6c1e-6a9a-4f4f-9e0e-2c5d7c0a9d11
X-PesaCast-Timestamp: 1761292801
X-PesaCast-Signature: v1=<hex> (only if you set a signing secret)
Authorization: Bearer <token> (only if you set a token)
{
"schema_version": 1,
"source": "android",
"device_id": "5b0f6c1e-6a9a-4f4f-9e0e-2c5d7c0a9d11",
"exported_at": "2026-10-24T10:00:01.234Z",
"count": 1,
"transactions": [{
"type": "mpesa_txn",
"direction": "received",
"amount": 500.0,
"currency": "KES",
"from": "JANE DOE",
"ref": "ABC123XYZ",
"time": "2026-10-24T10:00:00+03:00",
"balance": 1500.0
}]
}
The desktop app can post the same envelope, with "source": "desktop". Ignore fields you do not know; new optional fields can appear without changing schema_version.
What each field means
| Field | Meaning |
|---|---|
schema_version | Integer, currently 1. Breaking changes raise it; new optional fields do not. |
source | android from the phone, desktop from the desktop app. |
device_id | Identifies the phone, so one receiver can serve several shops. |
transactions[].direction | received, sent, paid, withdrawn, airtime or fuliza. New values can appear: treat an unknown one as other. |
amount, balance | Numbers in currency (KES). The balance is after the payment. |
from | The sender’s name, or the merchant for payments you made. |
ref | The M-PESA reference code. Unique per payment. |
time | When the payment happened, ISO 8601 with offset. |
transaction_cost | Optional. The fee, when the message states one. |
What your server should do
- Check the signature.
v1=plus the hex HMAC-SHA256 of the text<timestamp>.followed by the raw body, keyed with your secret. Compare in constant time. Reject timestamps more than a few minutes old. - Ignore test pings. The “Save and test” button sends
"test": truewith no transactions. Answer 2xx and record nothing. - De-duplicate. Delivery is at-least-once. Use
Idempotency-Key, or a unique constraint onrefplustime. The phone and the desktop use the same key. - Answer quickly. Store, reply, process later.
What the phone does with your answer
| You answer | The phone |
|---|---|
| 2xx | Done. Removes it from its queue. |
| 408, 429, 5xx, or no answer | Retries with backoff (5 seconds, doubling, up to 15 minutes), same key. |
| 401, 403 | Pauses and keeps the queue until the settings change. Nothing is lost. |
| Redirects, other 4xx | Drops that transaction and shows the error. Do not use 4xx for something you could process later. |
The queue holds up to 500 transactions for up to seven days. Requests must be HTTPS, redirects are not followed, and there is a ten-second timeout.
Verify a signature
Node
import crypto from "node:crypto";
function verify(secret, timestamp, rawBody, header) {
const expected = "v1=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex");
return header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
}
Python
import hashlib, hmac, time
def verify(secret: str, timestamp: str, raw_body: bytes, header: str) -> bool:
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
expected = "v1=" + mac.hexdigest()
fresh = abs(time.time() - int(timestamp)) < 300
return fresh and hmac.compare_digest(header, expected)
Sign the bytes you received, not a re-serialised copy of the JSON: any change to spacing or key order changes the signature.
Build it with an AI assistant
Paste this into Claude, ChatGPT, Cursor or any coding assistant, and fill in your stack on the last line. It points the assistant at the real spec files, so it does not have to guess the format. llms.txt lists the same files for tools that look for it.
Build a webhook receiver for PesaCast, which forwards M-PESA payment notifications from an Android phone to my server.
Read these first and follow them exactly instead of guessing:
- https://raw.githubusercontent.com/davidamunga/pesacast/main/docs/webhooks.md
- https://raw.githubusercontent.com/davidamunga/pesacast/main/docs/webhook-payload.schema.json
- https://raw.githubusercontent.com/davidamunga/pesacast/main/docs/openapi/pesacast-webhook.yaml
- https://github.com/davidamunga/pesacast/tree/main/examples/webhook-receiver (a reference receiver in Python)
Requirements:
1. Accept POST over HTTPS. Verify X-PesaCast-Signature: "v1=" plus the hex HMAC-SHA256 of "<timestamp>." followed by the RAW body, compared in constant time. Reject timestamps older than 5 minutes. Sign the raw bytes, never re-serialised JSON.
2. Answer 2xx quickly and process later. Ignore bodies with "test": true.
3. De-duplicate with the Idempotency-Key header. Delivery is at least once.
4. Return 401 or 403 only for bad credentials. Any other 4xx makes the phone drop the payment, so do not use it for something that could succeed later.
5. Treat each payment as a notification, not proof of payment. Anything that releases goods or credits an account must be confirmed through Safaricom Daraja callbacks.
6. Never log the signing secret, the bearer token or raw message bodies.
My stack is <language, framework, database>. First list the files you will create, then write them, then give me a curl command that sends a correctly signed test request.
Check what it writes: run it against the test server, and send a real payment before you rely on it.
Everything else
- JSON Schema of the body, and the OpenAPI description of the endpoint you implement. Tests check the apps against both.
- A reference receiver in Python with SQLite: signature check, test pings, de-duplication, raw event storage.
- The full guide, including a test receiver you can run in one command and expose through a tunnel.
- Send the address and credentials from your computer instead of typing them on the phone: phone setup.