Skip to main content

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

FieldTypeDescription
eventstringAlways "transaction.updated"
event_typestringTerminal event — see table below
transaction.idstringPalPluss transaction UUID
transaction.typestringSTK or B2C
transaction.statusstringSUCCESS, FAILED, CANCELLED, or EXPIRED
transaction.amountnumberTransaction amount in KES
transaction.phone_numberstringCustomer phone (254XXXXXXXXX)
transaction.external_referencestringYour accountReference / reference
transaction.mpesa_receiptstring | nullM-Pesa receipt number — present on SUCCESS, null on failure
transaction.transaction_feenumberService wallet fee charged. 0 on FAILED transactions.
transaction.result_codestringProvider result code ("0" = success)
transaction.result_descstringHuman-readable result message from provider
transaction.provider_request_idstringProvider’s internal request reference
transaction.provider_checkout_idstringCheckout/conversation ID from provider

event_type values

ValueMeaning
transaction.successTransaction complete. Funds moved.
transaction.failedTransaction failed. No funds moved (B2C wallet reversed if applicable).
transaction.cancelledCustomer cancelled the STK Push prompt.
transaction.expiredTransaction timed out before completion.

M-Pesa result_code

CodeMeaning
"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.
AttemptDelay
110 seconds
230 seconds
31 minute
42 minutes
55 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"
}