# Webhooks

A collection settles asynchronously: you create a payment, the customer approves the debit on their handset, and the outcome lands moments (or minutes) later. Webhooks are the reverse channel that pushes that outcome to you the instant it happens, so you do not have to poll [`GET /payments/{id}/status`](/en/api/direct-payments).

<Mermaid chart={`
sequenceDiagram
    participant K as Kwik Nkap
    participant E as Your endpoint
    Note over K: Payment settles
    K->>K: Sign JSON body HMAC-SHA256 with whsec_
    K->>E: POST with X-KN-Signature
    E->>E: Verify signature
    E-->>K: 2xx OK
    Note over K,E: On non-2xx, retry with exponential backoff up to 5 attempts
`} />

## Why webhooks

When you call [`POST /payments`](/en/api/direct-payments), the response comes back `PENDING`. The real outcome arrives after the customer enters their Mobile Money PIN. You have two ways to learn it:

- **Poll** the status endpoint on a timer until it flips to `SUCCESS` or `FAILED`.
- **Subscribe to a webhook** and let Kwik Nkap POST the result to you the moment the payment settles.

Webhooks are the preferred option. They are faster, they cost you no wasted requests against your rate limit, and they free you from running a polling loop. Use them to fulfil orders, mark invoices paid, send receipts, or update a dashboard the moment money moves.

## Configure an endpoint

Webhook endpoints are registered and managed in the [dashboard](https://app.kwiknkap.com), not through the public API. There is no API resource to create an endpoint.

1. Open the **Developers** section of the dashboard.
2. Add a webhook endpoint: provide your HTTPS URL and select the events you want to receive.
3. Copy the signing secret. It starts with `whsec_` and is **shown only once**. Store it securely.

:::info
Endpoints are environment-scoped. An endpoint registered in **sandbox** only receives events from `kn_sk_test_` traffic, and a **live** endpoint only receives events from `kn_sk_live_` traffic. Register a separate endpoint per environment.
:::

## Events

There are exactly two events.

| Event             | Fires when                                          |
| ----------------- | --------------------------------------------------- |
| `payment.success` | A collection completed and money was received.      |
| `payment.failed`  | A collection or disbursement attempt failed.        |

Subscribe an endpoint to one or both. You will not receive any other event types.

## The delivery payload

Each delivery is a JSON `POST` with a consistent envelope: the `event` name, the `data` object, a `timestamp`, and the `webhook_id` of the delivery.

```json title="payment.success delivery"
{
  "event": "payment.success",
  "data": {
    "id": "knpay_test_9f2c41a7b8e04d6fa1c3e58b7d92f014",
    "amount": 25000,
    "currency": "XAF",
    "direction": "collection",
    "status": "SUCCESS",
    "environment": "SANDBOX",
    "business_id": "biz_8f2c1a",
    "transaction_id": "txn_9d4e7b",
    "payment_method": "mobile_money",
    "metadata": {},
    "created_at": "2026-06-19T10:24:00Z",
    "updated_at": "2026-06-19T10:24:18Z"
  },
  "timestamp": "2026-06-19T10:24:18Z",
  "webhook_id": "whd_3a1f9c2e"
}
```

The `data` object carries these fields.

| Field            | Type    | Description                                                              |
| ---------------- | ------- | ------------------------------------------------------------------------ |
| `id`             | string  | The public payment ID (`knpay_…` live, `knpay_test_…` sandbox).          |
| `amount`         | integer | Amount in whole XAF francs.                                              |
| `currency`       | string  | Always `XAF`.                                                            |
| `direction`      | string  | `collection` (pull from a customer) or `disbursement`.                  |
| `status`         | string  | Terminal status: `SUCCESS` or `FAILED`.                                 |
| `environment`    | string  | `SANDBOX` or `LIVE`.                                                     |
| `business_id`    | string  | Your business identifier.                                               |
| `transaction_id` | string  | Internal transaction reference for reconciliation.                      |
| `payment_method` | string  | The settlement rail, e.g. `MOBILE_MONEY`.                               |
| `metadata`       | object  | Any metadata attached to the payment.                                   |
| `created_at`     | string  | ISO 8601 timestamp when the payment was created.                        |
| `updated_at`     | string  | ISO 8601 timestamp when the payment reached this status.                |

## Headers

Every delivery carries three headers you should read.

| Header             | Description                                                        |
| ------------------ | ----------------------------------------------------------------- |
| `X-KN-Signature`   | Hex HMAC-SHA256 of the raw request body, signed with your secret. |
| `X-KN-Event`       | The event name, e.g. `payment.success`.                           |
| `X-KN-Webhook-ID`  | The unique delivery ID, matching `webhook_id` in the body.        |

## Verify signatures

Anyone who learns your URL could POST fake events to it. To prove a delivery genuinely came from Kwik Nkap, recompute the HMAC-SHA256 of the **raw request body** using your `whsec_` secret and compare it to the `X-KN-Signature` header with a timing-safe comparison.

You must hash the exact bytes you received. Do not parse the JSON and re-serialize it first, or the signature will not match. Capture the raw body before any framework deserializes it.

```ts title="verify-webhook.ts"
import crypto from "node:crypto";

/**
 * Verify a Kwik Nkap webhook delivery.
 *
 * @param rawBody   The raw request body, exactly as received (string or Buffer).
 * @param signature The value of the X-KN-Signature header.
 * @param secret    Your whsec_ signing secret from the dashboard.
 */
export function verifyWebhook(
  rawBody: string | Buffer,
  signature: string,
  secret: string,
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const expectedBuffer = Buffer.from(expected, "utf8");
  const signatureBuffer = Buffer.from(signature, "utf8");

  // Lengths must match before timingSafeEqual, or it throws.
  if (expectedBuffer.length !== signatureBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
}
```

Wire it into an Express handler. Use a raw body parser so you sign the exact received bytes.

```ts title="server.ts"
import express from "express";
import { verifyWebhook } from "./verify-webhook";

const app = express();
const WEBHOOK_SECRET = process.env.KN_WEBHOOK_SECRET!; // whsec_...

app.post(
  "/webhooks/kwiknkap",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-KN-Signature") ?? "";

    if (!verifyWebhook(req.body, signature, WEBHOOK_SECRET)) {
      return res.status(401).send("invalid signature");
    }

    const { event, data } = JSON.parse(req.body.toString("utf8"));

    // Respond fast, then process out of band.
    res.status(200).send("ok");

    if (event === "payment.success") {
      // fulfil the order for data.id
    } else if (event === "payment.failed") {
      // mark data.id failed and notify the customer
    }
  },
);
```

:::warning
Verify the signature on **every** delivery before acting on it. An unverified webhook is just an unauthenticated HTTP request from the internet. Reject anything whose `X-KN-Signature` does not match.
:::

## Delivery and retries

Kwik Nkap delivers each event with a `POST` and expects a fast `2xx` response.

- Any `2xx` status code marks the delivery as successful.
- The request times out after **30 seconds**. Do your real work asynchronously after replying.
- If your endpoint returns a non-`2xx` or times out, Kwik Nkap retries up to **5 attempts** total.
- Retries use exponential backoff (2.5x multiplier) capped at **5 minutes** between attempts.

:::tip
Acknowledge the delivery with `200` immediately, then process the event in a background job. Holding the connection open while you do slow work risks hitting the 30-second timeout and triggering needless retries.
:::

Because retries are possible, design your handler to be **idempotent**. Key your processing on the payment `id` (or `transaction_id`) so that receiving the same `payment.success` twice does not fulfil an order twice.

## Next steps

- [Create a payment](/en/api/direct-payments) to generate the events you will receive.
- [Check payment status](/en/api/direct-payments) as a fallback when a webhook is missed.
- [Test in sandbox](/en/payments/testing) with `kn_sk_test_` keys and a sandbox webhook endpoint.
