Skip to main content

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

ValueMeaning
transaction.successTransaction completed. Funds moved.
transaction.failedTransaction failed. No funds moved (B2C wallet reversed if applicable).
transaction.cancelledCustomer cancelled the STK Push prompt.

result_code (M-Pesa)

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.

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