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.

  1. 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
  2. Give it an HTTPS address. The phone refuses plain HTTP. Use a tunnel:
    cloudflared tunnel --url http://localhost:3919
    ngrok http 3919 works too.
  3. Fill in the Webhook card in the phone app: the tunnel address ending in /hook, the token mytoken and the secret mysecret. Press Save and test. The server prints the test ping.
  4. 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

FieldMeaning
schema_versionInteger, currently 1. Breaking changes raise it; new optional fields do not.
sourceandroid from the phone, desktop from the desktop app.
device_idIdentifies the phone, so one receiver can serve several shops.
transactions[].directionreceived, sent, paid, withdrawn, airtime or fuliza. New values can appear: treat an unknown one as other.
amount, balanceNumbers in currency (KES). The balance is after the payment.
fromThe sender’s name, or the merchant for payments you made.
refThe M-PESA reference code. Unique per payment.
timeWhen the payment happened, ISO 8601 with offset.
transaction_costOptional. The fee, when the message states one.

What your server should do

  1. 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.
  2. Ignore test pings. The “Save and test” button sends "test": true with no transactions. Answer 2xx and record nothing.
  3. De-duplicate. Delivery is at-least-once. Use Idempotency-Key, or a unique constraint on ref plus time. The phone and the desktop use the same key.
  4. Answer quickly. Store, reply, process later.

What the phone does with your answer

You answerThe phone
2xxDone. Removes it from its queue.
408, 429, 5xx, or no answerRetries with backoff (5 seconds, doubling, up to 15 minutes), same key.
401, 403Pauses and keeps the queue until the settings change. Nothing is lost.
Redirects, other 4xxDrops 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.