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

# Webhooks

> Get the result of every payment, signed

When a payment reaches its final status, we `POST` an event to the `webhook_url` of the payment, or of its service.

| Event | When |
| - | - |
| `mobile_money.collection.completed` | The customer paid. `net_amount` is on the balance of the service |
| `mobile_money.collection.failed` | The payment failed. `failure_code` says why |

## Payload

```json theme={null}
{
  "id": "evt_5b1f0c2a-9d3e-4f7a-8b6c-1e2d3f4a5b6c",
  "type": "mobile_money.collection.completed",
  "created_at": "2026-10-29T10:01:12+00:00",
  "data": {
    "code": "019a9c41-7e2d-4b8f-a1c3-5d6e7f8a9b0c",
    "reference": "order-2026-10-29-0001",
    "service_code": "019a8f2e-4c1b-7d3a-9e55-2b6f0c1d8a47",
    "status": "completed",
    "currency": "XAF",
    "value": "1000.00",
    "amount": "1000.00",
    "fee": "33.00",
    "net_amount": "967.00",
    "metadata": { "user_id": "42" },
    "operator_transaction_id": "MP261029.1001.A12345"
  },
  "mode": "live"
}
```

`data` is the same object as [`GET /collections/{code}`](https://docs.kemypay.com/api-reference/collections/get).

## Headers

| Header | Content |
| - | - |
| `X-KemyCard-Signature` | `t=<timestamp>,v1=<signature>` |
| `X-KemyCard-Timestamp` | The timestamp used in the signature |
| `X-KemyCard-Event` | The event type |
| `X-KemyCard-Delivery-Id` | Unique per delivery attempt |
| `X-KemyCard-Idempotency-Key` | The event `id`: the same on every retry |

## Verify the signature

The signature is an **HMAC-SHA256** of `"<timestamp>.<raw body>"` with the `webhook_secret` of the service. Always verify it, on the **raw** body, before you trust the event.

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

export function verifyKemyPay(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''));
}
```

```php PHP theme={null}
function verifyKemyPay(string $rawBody, string $signatureHeader, string $secret, int $tolerance = 300): bool
{
    parse_str(str_replace(',', '&', $signatureHeader), $parts);
    $timestamp = (int) ($parts['t'] ?? 0);
    if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, (string) ($parts['v1'] ?? ''));
}

// $rawBody = file_get_contents('php://input');
```

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

def verify_kemypay(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = int(parts.get("t", "0"))
    if not timestamp or abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Respond fast, process once

* Answer **`2xx` within 15 seconds**, then do the slow work in the background.
* **Process each event once**: store the event `id` (also in `X-KemyCard-Idempotency-Key`) and ignore it if you see it again.
* Any other response, or a timeout, triggers a retry: immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours. After that, the event is dropped: you can still read the payment with [`GET /collections/{code}`](https://docs.kemypay.com/api-reference/collections/get).

> [!WARNING]
>
> Lost your secret? [Rotate it](https://docs.kemypay.com/api-reference/services/rotate-secret.md): the old one stops working at once, so update your server first.


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