Webhooks
PalPluss delivers payment results to your server via HTTP POST callbacks. Supply a
callbackUrl on each payment request and PalPluss will notify you when the transaction
reaches a terminal state.
Callback payload
All callbacks use the same envelope structure:
{
"event": "transaction.updated",
"event_type": "transaction.success",
"transaction": {
"id": "fa98a577-95ea-4a8f-8467-1fbe74f5d6f4",
"tenant_id": "b6fbe75f-ce87-4d44-b318-6cfdb8b7de4d",
"type": "STK",
"status": "SUCCESS",
"amount": 1000,
"currency": "KES",
"phone_number": "254712345678",
"external_reference": "INV-001",
"provider": "m-pesa",
"provider_request_id": "29115-34620561-1",
"provider_checkout_id": "ws_CO_191220191020363925",
"result_code": "0",
"result_desc": "The service request is processed successfully.",
"created_at": "2026-03-01T08:00:00.000Z",
"updated_at": "2026-03-01T08:01:30.000Z"
}
}
event_type values
| Value | Meaning |
|---|
transaction.success | Transaction completed. Funds moved. |
transaction.failed | Transaction failed. No funds moved (B2C wallet reversed if applicable). |
transaction.cancelled | Customer cancelled the STK Push prompt. |
result_code (M-Pesa)
| Code | Meaning |
|---|
"0" | Success |
"1" | Insufficient funds |
"17" | Risk limit exceeded |
"20" | Too many requests |
"1032" | Request cancelled by user |
"1037" | DS timeout |
Non-zero codes indicate failure.
Delivery and retries
Your endpoint must return a 2xx HTTP status code to acknowledge receipt.
Any other response (non-2xx, timeout, connection error) triggers a retry.
Retry schedule:
| Attempt | Delay |
|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 5 minutes |
After 5 failed attempts, no further delivery is attempted. Poll
GET /transactions/{id} to retrieve the final status.
Callback URL requirements
- Must be a publicly accessible HTTPS URL.
- Must respond within 30 seconds to avoid timeout.
- Must return a
2xx status code regardless of how you process the payload.
- Must handle duplicate deliveries (the same callback may be delivered more than once on retry).
Do not use localhost, 127.0.0.1, or private/RFC1918 addresses as callback URLs.
PalPluss cannot reach these addresses from its servers.
Making your endpoint idempotent
Because callbacks may be retried, your endpoint must handle duplicate deliveries safely.
Use the transaction.id as an idempotency key:
app.post("/webhooks/mpesa", async (req, res) => {
// Acknowledge immediately — process asynchronously
res.sendStatus(200);
const { transaction } = req.body;
// Guard against duplicates
const alreadyProcessed = await db.find({ transactionId: transaction.id });
if (alreadyProcessed) return;
// Process the result
if (transaction.status === "SUCCESS") {
await fulfillOrder(transaction.external_reference);
} else if (transaction.status === "FAILED") {
await handleFailedPayment(transaction.external_reference);
}
});
Return 200 immediately and process the payload asynchronously. This prevents
delivery timeouts from triggering unnecessary retries.
Testing callbacks locally
Use a tool like ngrok or localtunnel
to expose your local server during development:
# Using ngrok
ngrok http 3000
# Your callback URL becomes something like:
# https://abc123.ngrok.io/webhooks/mpesa
Callback URL per request
PalPluss does not maintain a single global webhook URL. Each payment request includes
its own callbackUrl. This lets you route callbacks for different payment flows or
environments to different endpoints.
{
"amount": 1000,
"phone": "0712345678",
"accountReference": "INV-001",
"transactionDesc": "Payment",
"callbackUrl": "https://yourserver.com/webhooks/mpesa"
}