# Webhooks

Payd posts an event to the `callback_url` you set on a payment each time the payment succeeds, fails or settles. Every webhook is signed, so you can trust it once you check the signature.

## Set the URL

Put the URL in `payment_config.callback_url` on each payin or payout:

```json
"payment_config": {
  "callback_url": "https://example.com/webhooks/payd",
  "reference": "order-8842"
}
```

There is no account-wide webhook setting. A payment sent without a `callback_url` gets no webhook. Use an HTTPS URL.

## Events

| `event` | When | `status` |
| --- | --- | --- |
| `payin.succeeded` | The customer paid. | `success` |
| `payin.failed` | The collection failed, expired or the customer cancelled. | `failed` |
| `payin.settled` | Card payments only: Payd released the money to your balance. | `settled` |
| `payout.succeeded` | The payout reached the recipient. | `success` |
| `payout.failed` | The network refused the payout. The amount is back in your balance. | `failed` |

Each payment gets at most one `succeeded` or `failed` event. A card payment gets `payin.succeeded` first and `payin.settled` later. See [how long payments take](./limits-and-settlement.md).

## What Payd sends

Payd sends a `POST` with a JSON body, `Content-Type: application/json` and an `X-Payd-Signature` header:

```json
{
  "event": "payout.succeeded",
  "success": true,
  "status": "success",
  "message": "transaction processed successfully",
  "reference": "SID131831221eW",
  "external_reference": "payout-1042",
  "amount": 2500,
  "currency": "KES",
  "transaction_type": "withdrawal",
  "transaction_date": "2026-10-16T11:42:03Z",
  "transaction_id": "AG_20261016_0102b3c4d5e6",
  "provider_reference": "UJG8K2L4QX",
  "callback_urls": ["https://example.com/webhooks/payd"]
}
```

| Field | Meaning |
| --- | --- |
| `event` | What happened. See [events](#events). |
| `status` | `success`, `settled` (cards only) or `failed`. |
| `success` | `false` only when `status` is `failed`. |
| `reference` | Payd's reference for the payment, the `merchant_reference` you stored. |
| `external_reference` | The `payment_config.reference` you sent. |
| `amount`, `currency` | The amount of the payment, in the currency you sent. |
| `transaction_type` | `receipt` for a collection, `withdrawal` for a payout. |
| `transaction_date` | When you created the payment, not when it finished. |
| `transaction_id` | The network's id for the payment. |
| `provider_reference` | The receipt number from the network, such as the M-Pesa confirmation code. Empty when the network gives none. Show it to customers who ask for proof of payment. |
| `message` | Text about the result. Fine for your logs. Do not parse it. |

The body has other fields. Ignore them.

### The older format

Integrations built on the [older M-Pesa collection endpoint](./kenya-payins.md#the-older-collection-endpoint) can also receive this body, sometimes right after the one above, at the same URL. It is signed the same way.

```json
{
  "transaction_reference": "9BD103350408eR",
  "result_code": 0,
  "remarks": "transaction processed successfully",
  "third_party_trans_id": "UJG8K2L4QX",
  "amount": 1500,
  "phone_number": "254712345678",
  "channel": "momo",
  "forward_url": "https://example.com/webhooks/payd",
  "order_id": "",
  "success": true,
  "status": "success"
}
```

`result_code` is `0` for success and `1` for failure. Tell the two formats apart by their keys: the current one has `reference`, the older one has `transaction_reference`. Accept both and treat them as the same event.

## Verify the signature

The `X-Payd-Signature` header looks like this:

```
X-Payd-Signature: t=1760614923,v1=5d1b3f0c9a7e...
```

- `t` is when Payd signed the webhook, in Unix seconds.
- `v1` is the HMAC-SHA256 of `t`, a full stop, and the exact body bytes, keyed with your whole webhook secret (including `whsec_`), in hex.
- After you rotate the secret, the header carries two `v1` values for 24 hours: one for each secret. Accept the webhook if either matches.

To check a webhook:

1. Read the raw body before any JSON parsing. Parsing and re-encoding changes the bytes.
2. Compute `HMAC-SHA256(secret, t + "." + raw_body)` and compare it with each `v1` value using a constant-time compare.
3. Reject the webhook if `t` is more than 5 minutes old, so an old webhook cannot be replayed.
4. Answer `2xx` quickly, then do the work in the background.

::: code-group

```js [Node.js]
import crypto from 'node:crypto'
import express from 'express'

const app = express()

export function verifyPayd(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false
  const parts = header.split(',').map((part) => part.trim().split('='))
  const t = parts.find(([key]) => key === 't')?.[1]
  const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value)
  if (!t || !signatures.length) return false
  if (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)),
  )
}

// express.raw keeps the exact bytes Payd signed
app.post('/webhooks/payd', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyPayd(req.body, req.get('X-Payd-Signature'), process.env.PAYD_WEBHOOK_SECRET)) {
    return res.sendStatus(400)
  }
  res.sendStatus(200)

  const event = JSON.parse(req.body.toString('utf8'))
  queueEvent(event) // handle it in the background
})

app.listen(3000)
```

```python [Python]
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)


def verify_payd(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
    if not header:
        return False
    parts = [p.strip().split("=", 1) for p in header.split(",")]
    parts = [p for p in parts if len(p) == 2]
    t = next((v for k, v in parts if k == "t"), None)
    signatures = [v for k, v in parts if k == "v1"]
    if not t or not signatures or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in signatures)


@app.post("/webhooks/payd")
def payd_webhook():
    if not verify_payd(request.get_data(), request.headers.get("X-Payd-Signature"), os.environ["PAYD_WEBHOOK_SECRET"]):
        abort(400)
    queue_event(request.get_json())  # handle it in the background
    return "", 200
```

```php [PHP]
<?php
function verify_payd(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
    if (!$header) {
        return false;
    }
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't') $t = $value;
        if ($key === 'v1') $signatures[] = $value;
    }
    if (!$t || !$signatures || 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;
}

$rawBody = file_get_contents('php://input');
if (!verify_payd($rawBody, $_SERVER['HTTP_X_PAYD_SIGNATURE'] ?? null, getenv('PAYD_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
http_response_code(200);
queue_event(json_decode($rawBody, true)); // handle it in the background
```

:::

### Get your webhook secret

<div class="endpoint"><span class="method get">GET</span><code>https://api.payd.money/api/v2/webhooks/secret</code></div>

```bash
curl https://api.payd.money/api/v2/webhooks/secret \
  -u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD"
```

```json
{
  "secret": "whsec_4kq9X2v7...",
  "created_at": "2026-10-16T11:42:03Z",
  "previous_secret_expires_at": ""
}
```

There is one secret per Payd account. Payd creates it the first time you ask for it or the first time it sends you a webhook. Keep it with your other secrets, never in client code.

### Rotate the secret

<div class="endpoint"><span class="method post">POST</span><code>https://api.payd.money/api/v2/webhooks/secret/rotate</code></div>

```bash
curl -X POST https://api.payd.money/api/v2/webhooks/secret/rotate \
  -u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{}'
```

The response has the new secret. The old one keeps signing alongside it until `previous_secret_expires_at`, 24 hours later, so you have time to deploy the new one. You can rotate again only after that time: before it, Payd answers `400` `invalid_argument` with the time the previous secret stops signing.

## Handle webhooks safely

- Check the signature on every webhook. Answer `400` when it does not match.
- Answer `2xx` within 20 seconds. Do slow work after you answer.
- Make your handler safe to run twice. Store each `reference` and `event` pair you have handled, and skip repeats.
- Webhooks can arrive more than once and out of order. A card payment's `payin.settled` can arrive while you are still handling its `payin.succeeded`.
- For high-value orders, you can also confirm with the [status endpoint](./transaction-status.md) before you ship.

## Delivery and retries

- Payd waits up to 20 seconds for your answer. Any `2xx` counts as delivered.
- If your endpoint times out, cannot be reached or answers `5xx`, Payd tries twice more within a few seconds.
- If it still has no `2xx`, including after a `4xx`, Payd tries again 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the first failure, then stops.
- Each retry has a new `t` and signature.
- Run a job that checks the [status](./transaction-status.md) of any payment still `pending` or `processing` after an hour, in case your endpoint was down for longer.

## Test your endpoint

While you build, point `callback_url` at a request inspector such as [webhook.site](https://webhook.site), or expose your local server with a tunnel, and send a small real payment. Check your signature code against the header you receive.
