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

# Webhooks

> Hear about every payment the moment it changes, signed by One Pay.

A webhook is an HTTPS `POST` from One Pay to your server when something
happens: no polling needed.

## Events

| Type | When |
| - | - |
| `payin.succeeded` | The customer paid |
| `payin.failed` | The payin did not go through (`failure` says why) |
| `payin.settled` | The payin's money is now available |
| `payout.succeeded` | The recipient received the money |
| `payout.failed` | The payout failed; the money is back in `available` |
| `payout_recipient.approved` | A bank account you registered can now be paid |
| `payout_recipient.rejected` | The partner refused a bank account you registered |

Each webhook's body is an **event**; `data.object` is the payment as it was
at that moment, exactly as the API returns it:

```json theme={null}
{
  "id": "evt_be3fee59ceab47dfa1cdfa0fe4c4948f",
  "type": "payin.failed",
  "mode": "test",
  "created_at": "2026-10-29T09:51:36.903Z",
  "data": {
    "object": {
      "id": "pay_3d74b8e558814f19ac046b6b151a376b",
      "status": "failed",
      "amount": 5000,
      "currency": "XAF",
      "failure": {
        "code": "insufficient_funds",
        "message": "The customer did not have enough money"
      }
    }
  }
}
```

(`data.object` is shortened here.)

## Add an endpoint

In the dashboard (**API keys & webhooks → Add webhook**), or by API:

```bash theme={null}
curl https://onepay-api-eiy9.onrender.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $ONEPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://kolatrade.cm/onepay/webhooks",
    "events": ["payin.succeeded", "payin.failed"]
  }'
```

Leave out `events` to receive every type. The answer includes the signing
`secret` (`whsec_...`), **shown once**: store it with your API key.

* Live endpoints must be `https` on a public address. Test endpoints may use
  `http://localhost` while you build.
* Up to 10 endpoints per app and mode. Each app's payments go to that app's
  endpoints only.
* `DELETE /v1/webhook_endpoints/{id}` stops deliveries at once, retries
  included.

## Check the signature

Anyone can post to your URL. Before trusting a webhook, check it came from
One Pay. Each one carries:

```http theme={null}
OnePay-Signature: t=1793020800,v1=5f2c…
```

`v1` is the HMAC-SHA256 of `t`, a dot and the **raw** request body, keyed
with your endpoint's secret. Refuse the webhook if the signature differs, or
if `t` is more than 5 minutes from now.

```javascript Node.js theme={null}
import crypto from 'node:crypto';

/** rawBody: the request body exactly as received, before JSON parsing. */
export function isFromOnePay(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').map((part) => part.split('=', 2)),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) {
    return false;
  }
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
```

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


def is_from_onepay(secret: str, raw_body: bytes, header: str) -> bool:
    """raw_body: the request body exactly as received."""
    parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
    try:
        t = int(parts.get("t", ""))
    except ValueError:
        return False
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

```php PHP theme={null}
<?php
// $rawBody = file_get_contents('php://input');
function is_from_onepay(string $secret, string $rawBody, string $header): bool
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        $parts[trim($key)] = trim($value);
    }
    $t = filter_var($parts['t'] ?? '', FILTER_VALIDATE_INT);
    if ($t === false || abs(time() - $t) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
```

> [!WARNING]
>
> Use the raw body. Parsing the JSON and serialising it again changes the
> bytes, and the signature will not match.

Every webhook also has the headers `OnePay-Event-Id`, `OnePay-Event-Type`
and `OnePay-Delivery-Attempt`.

## Answer fast, then work

* Answer with any `2xx` within **10 seconds**. Do slow work (emails,
  invoices) after answering.
* Anything else (an error, a timeout, a redirect) is retried after 1 min,
  5 min, 30 min, 2 h, 6 h, 12 h and 24 h: 8 tries over about two days.
* An event can very rarely arrive **twice**: keep the ids you have handled
  and skip repeats.
* Events can arrive **out of order**: trust `data.object.status`, not the
  order of arrival. When in doubt, read the payment with the API.

## Replays and history

* `GET /v1/events` lists every event, newest first; filter with
  `?type=payin.succeeded`.
* `POST /v1/events/{id}/resend` sends one again to every endpoint that wants
  it: useful after fixing your server.


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