> ## Documentation Index
> Fetch the complete documentation index at: https://docs.palpluss.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time transaction results via HTTP callbacks.

# 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

```json theme={null}
{
  "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.

<Tip>
  Use `mpesa_receipt` as proof of payment in your records. It is the official M-Pesa confirmation number and is unique per successful transaction.
</Tip>

***

## 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

<Warning>
  Do not use `localhost`, `127.0.0.1`, or private/RFC1918 addresses. PalPluss cannot reach these from its servers.
</Warning>

***

## Idempotent handler

Use `transaction.id` as your idempotency key to guard against duplicate deliveries.

```javascript theme={null}
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);
  }
});
```

<Tip>
  Return `200` before processing. This prevents delivery timeouts from triggering unnecessary retries.
</Tip>

***

## Local testing

Expose your local server with [ngrok](https://ngrok.com) or [localtunnel](https://github.com/localtunnel/localtunnel):

```bash theme={null}
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:**

```json theme={null}
{
  "amount": 1000,
  "phone": "0712345678",
  "accountReference": "INV-001",
  "transactionDesc": "Payment",
  "callbackUrl": "https://yourserver.com/webhooks/stk"
}
```

**B2C Payout:**

```json theme={null}
{
  "amount": 500,
  "phone": "0712345678",
  "reference": "WD-2024-001",
  "callbackUrl": "https://yourserver.com/webhooks/b2c"
}
```
