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

# Webhooks

> Get told when a payment, refund or payout finishes, and check that the message really came from PrimePay.

Mobile money payments finish on the customer's phone, after your API call has returned. A webhook is how you learn the result: PrimePay sends an HTTPS `POST` to your server when a payment, refund or payout succeeds or fails.

## Set up an endpoint

**1. Build a route on your server**

It must accept a `POST` with a JSON body at a public `https://` address, for example `https://api.yourshop.mw/webhooks/primepay`.

**2. Add it in the dashboard**

Open your account in the [dashboard](https://dashboard.primepay.mw/), go to **Settings › Webhooks** and add the address. Choose which events it receives.

**3. Save the signing secret**

Each endpoint has its own signing secret, starting with `whsec_`. Store it on your server like a password. You use it to check every webhook.

**4. Send a test event**

Send a test event from the endpoint's page in the dashboard. Your endpoint receives a `webhook.test` event; answer `200` and the dashboard shows it as delivered.

Rules for the address:

* It must start with `https://`, on port 443 or 8443.
* It must use a public host name, not an IP address or `localhost`.
* It can't contain a user name or password.
* An account can have up to 5 endpoints.

## Events

| Event | When it is sent | `data.object` |
| - | - | - |
| `payment.succeeded` | A customer paid. | A payment |
| `payment.failed` | A payment was declined, expired or cancelled. `status` says which. | A payment |
| `payment.reversed` | A paid payment was returned. | A payment |
| `refund.succeeded` | A refund reached the customer. | A refund |
| `refund.failed` | A refund could not be sent. Its money is back in your balance. | A refund |
| `payout.succeeded` | A payout was paid. | A payout |
| `payout.failed` | A payout could not be paid. Its money is back in your balance. | A payout |
| `checkout.session.completed` | A hosted checkout was paid. Sent as well as `payment.succeeded`. | A checkout session |
| `checkout.session.expired` | A hosted checkout was never paid. | A checkout session |
| `webhook.test` | You sent a test event from the dashboard. | A test message |

Transfers have no events: a transfer's result is in the API response.

## What a webhook looks like

```http theme={null}
POST /webhooks/primepay HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: PrimePay-Webhooks/1.0
PrimePay-Event-Id: evt_3f6c0f0d9d0a4c1e8a7b6c5d4e3f2a1b
PrimePay-Event-Type: payment.succeeded
PrimePay-Signature: t=1794219341,v1=5b2f0c9a…
```

```json theme={null}
{
  "id": "evt_3f6c0f0d9d0a4c1e8a7b6c5d4e3f2a1b",
  "type": "payment.succeeded",
  "api_version": "2026-11-01",
  "created": "2026-11-09T10:15:41Z",
  "livemode": true,
  "account": "10293847",
  "data": {
    "object": {
      "object": "payment",
      "id": "0b6f6a51-6f1e-4b0e-9a55-2f4f1c9d7e10",
      "reference": "TXN-WRYF6S2W3DQW",
      "merchant_reference": "ORDER-5531",
      "status": "success",
      "amount": "25000.00",
      "fee": "750.00",
      "currency": "MWK",
      "method": "mobile_money",
      "description": "2 x School shoes",
      "failure_code": null,
      "created": "2026-11-09T10:15:02Z",
      "completed": "2026-11-09T10:15:41Z",
      "customer": { "msisdn": "+26599****567" },
      "checkout_session": null,
      "metadata": { "cart": "88412" }
    }
  }
}
```

> [!NOTE]
>
> Webhook bodies use `snake_case` names and send amounts as text with two decimals (`"25000.00"`). The API itself uses `camelCase` and numbers. `merchant_reference` is the `merchantOrderId` you sent.

| Field | Meaning |
| - | - |
| `id` | The event's id. The same event always has the same id, including on retries. |
| `type` | The event, from the table above. |
| `api_version` | The version of this body's layout. |
| `created` | When the event happened, in UTC. |
| `livemode` | `true` for live, `false` for sandbox. |
| `account` | The account code the event belongs to. |
| `data.object` | The payment, refund, payout or checkout session as it was at the event. |

Inside `data.object`:

* **Payments** have `customer.msisdn` (the payer's number, partly hidden), `checkout_session` when the payment came through a hosted checkout or payment link, and `sub_merchant` (the account code) on aggregator payments.
* **Refunds** have `payment`, the id of the payment refunded, and `destination`.
* **Payouts** have `destination`, where the money went.
* `metadata` is whatever you sent when you created it.

## Check the signature

Anyone can send a `POST` to your address, so check every webhook before you trust it. The `PrimePay-Signature` header has a timestamp and one or more signatures:

```text theme={null}
PrimePay-Signature: t=1794219341,v1=5b2f0c9a…
```

To check it:

1. Read `t` and every `v1` from the header.
2. Refuse the webhook if `t` is more than 5 minutes from now. This stops an old webhook being replayed.
3. Compute an HMAC-SHA256 of `t`, a full stop, and the **raw request body**, using your whole signing secret (`whsec_…`) as the key. Write it as lowercase hex.
4. Accept the webhook if your result equals any `v1`, compared in constant time.

> [!WARNING]
>
> Use the body exactly as it arrived, byte for byte. If your framework parses the JSON and you turn it back into text, the signature won't match.

```javascript Node.js theme={null}
const crypto = require("crypto");

// rawBody: a Buffer of the request body, before any JSON parsing
function isFromPrimePay(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.split("="));
  const t = (parts.find(([k]) => k === "t") || [])[1];
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  return signatures.some(
    (s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
  );
}
```

```python Python theme={null}
import hashlib
import hmac
import time


def is_from_primepay(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), None)
    signatures = [v for k, v in parts if k == "v1"]
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False

    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in signatures)
```

```php PHP theme={null}
<?php
// $rawBody = file_get_contents('php://input');
function isFromPrimePay(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        if ($key === 't') $t = $value;
        if ($key === 'v1') $signatures[] = $value;
    }
    if ($t === null || abs(time() - (int) $t) > $tolerance) return false;

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) return true;
    }
    return false;
}
```

```csharp C# theme={null}
using System.Security.Cryptography;
using System.Text;

// rawBody: the request body bytes, before any JSON parsing
static bool IsFromPrimePay(byte[] rawBody, string header, string secret, int toleranceSeconds = 300)
{
    long? t = null;
    var signatures = new List<string>();
    foreach (var part in header.Split(','))
    {
        var kv = part.Split('=', 2);
        if (kv.Length != 2) continue;
        if (kv[0] == "t" && long.TryParse(kv[1], out var seconds)) t = seconds;
        if (kv[0] == "v1") signatures.Add(kv[1]);
    }
    if (t is null || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t.Value) > toleranceSeconds) return false;

    var prefix = Encoding.UTF8.GetBytes(t.Value + ".");
    var message = prefix.Concat(rawBody).ToArray();
    var expected = Encoding.ASCII.GetBytes(
        Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), message)).ToLowerInvariant());
    return signatures.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(s), expected));
}
```

### Changing a signing secret

You can change an endpoint's secret in the dashboard. If you keep the old one working during the change, PrimePay signs with both for 24 hours and the header carries two `v1` values. Accepting a webhook when **any** `v1` matches, as the code above does, means nothing breaks while you switch.

## Answer quickly

* Answer with any `2xx` status as soon as you have stored the event. Do slow work (emails, stock updates) afterwards.
* PrimePay waits up to **10 seconds** for your answer. No answer in that time counts as a failure.
* Redirects are not followed. A `3xx` answer counts as a failure.
* Answer with JSON or an empty body. If your address answers with a web page, the dashboard flags it, because that usually means a website answered and not your webhook code.

## Retries

If your endpoint doesn't answer `2xx`, PrimePay tries again, up to 9 times over about 3 days:

| Attempt | Waits after the one before |
| - | - |
| 1 | Straight away |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
| 9 | 24 hours |

If you answer with a `Retry-After` header, PrimePay waits at least that long, up to 24 hours.

After 5 failures in a row you get an email. An endpoint that has failed every delivery for 3 days is switched off; fix it, switch it back on in the dashboard and resend what you missed from the endpoint's delivery list.

## Handle each event once

Because of retries, the same event can reach you more than once. Before acting on an event, check whether you have already handled its `id` (also in the `PrimePay-Event-Id` header), and answer `2xx` without doing the work again if you have.

Events can also arrive out of order. Don't rely on the order; use the `status` in the event, or fetch the payment with `GET /v1/payments/{reference}`.

## A payment's own callbackUrl

When you create a payment you can send a `callbackUrl`. That payment's events are then sent there **as well as** to your endpoints.

* The address must be HTTPS and its host must be one of your endpoints' hosts, or a host listed under **Settings › Security** as an allowed callback host. Otherwise the delivery is skipped.
* These deliveries are signed with the secret of your account's default endpoint, so check them the same way.

## Testing

* A test event from the dashboard sends `webhook.test` to one endpoint.
* Sandbox payments, refunds and payouts send webhooks too, with `"livemode": false`.
* Every delivery, with your server's answer, is listed under **Settings › Webhooks** for each endpoint, and you can resend one from there.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.