Webhooks
Results arrive at your server the moment a transaction settles. No polling required.
Supply a callback URL on each payment request. PalPluss sends a POST to that URL when the transaction reaches a terminal state.
Your endpoint must safely handle repeated deliveries — the same callback may arrive more than once on retry.
Callback payload
{
"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",
"mpesa_receipt": "LGR019G3J2",
"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"
}
}
Payload fields
| Field | Type | Description |
|---|
event | string | Always "transaction.updated" |
event_type | string | Terminal event — see table below |
transaction.id | string | PalPluss transaction UUID |
transaction.type | string | STK or B2C |
transaction.status | string | SUCCESS, FAILED, CANCELLED, or EXPIRED |
transaction.amount | number | Transaction amount in KES |
transaction.phone_number | string | Customer phone (254XXXXXXXXX) |
transaction.external_reference | string | Your accountReference / reference |
transaction.mpesa_receipt | string | null | M-Pesa receipt number — present on SUCCESS, null on failure |
transaction.transaction_fee | number | Service wallet fee charged. 0 on FAILED transactions. |
transaction.result_code | string | Provider result code ("0" = success) |
transaction.result_desc | string | Human-readable result message from provider |
transaction.provider_request_id | string | Provider’s internal request reference |
transaction.provider_checkout_id | string | Checkout/conversation ID from provider |
event_type values
| Value | Meaning |
|---|
transaction.success | Transaction complete. Funds moved. |
transaction.failed | Transaction failed. No funds moved (B2C wallet reversed if applicable). |
transaction.cancelled | Customer cancelled the STK Push prompt. |
transaction.expired | Transaction timed out before completion. |
M-Pesa result_code
| 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.
Use mpesa_receipt as proof of payment in your records. It is the official M-Pesa confirmation number and is unique per successful transaction.
Delivery and retries
Return a 2xx status code to acknowledge receipt. Any other response triggers a retry.
| Attempt | Delay |
|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 5 minutes |
After 5 failed attempts, delivery stops. Poll GET /transactions/{id} to retrieve the final status.
Callback URL requirements
- Must be a publicly accessible HTTPS URL
- Must respond within 30 seconds
- Must return
2xx regardless of how you process the payload
- Must handle duplicate deliveries safely
Do not use localhost, 127.0.0.1, or private/RFC1918 addresses. PalPluss cannot reach these from its servers.
Idempotent handler
Use transaction.id as your idempotency key to guard against duplicate deliveries.
app.post("/webhooks/mpesa", async (req, res) => {
// Acknowledge immediately — process asynchronously
res.sendStatus(200);
const { event_type, transaction } = req.body;
// Guard against duplicates
const alreadyProcessed = await db.find({ transactionId: transaction.id });
if (alreadyProcessed) return;
// Handle result
if (event_type === "transaction.success") {
await fulfillOrder({
reference: transaction.external_reference,
mpesaReceipt: transaction.mpesa_receipt, // e.g. "LGR019G3J2"
amount: transaction.amount,
});
} else if (event_type === "transaction.failed") {
await handleFailedPayment(transaction.external_reference);
}
});
Return 200 before processing. This prevents delivery timeouts from triggering unnecessary retries.
Local testing
Expose your local server with ngrok or localtunnel:
ngrok http 3000
# Your callback URL: https://abc123.ngrok.io/webhooks/mpesa
Per-request callback URLs
PalPluss does not use a single global webhook URL. Each payment request carries its own callback URL. This lets you route callbacks for different flows or environments to different endpoints.
Both endpoints use callbackUrl (camelCase):
STK Push:
{
"amount": 1000,
"phone": "0712345678",
"accountReference": "INV-001",
"transactionDesc": "Payment",
"callbackUrl": "https://yourserver.com/webhooks/stk"
}
B2C Payout:
{
"amount": 500,
"phone": "0712345678",
"reference": "WD-2024-001",
"callbackUrl": "https://yourserver.com/webhooks/b2c"
}