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

# Validate and fulfil

> How to know, for certain, that an order is paid, and ship it exactly once.

Whichever way the customer paid, **your server decides an order is
paid**, never the browser. This page is the checklist.

## The rule

> [!TIP]
>
> Fulfil an order only when **a server-side `GET /v1/ugwo/{uid}`**
> returns:
>
> * `status` is `ugwo_successful`
> * `total_amount_paid` ≥ `amount`
> * `amount`, `currency` and `ref` match the order in your database

Trigger that check from the `ugwo.successful` webhook, and from a
periodic reconciliation job as a safety net.

## Why not trust the browser?

`onSuccess` in the [embed](https://docs.kwugwo.africa/checkout-embed.md), a `returnUrl` redirect, or
your own frontend polling all run on the customer's device. They can be
closed before they fire, replayed, or forged. Use them to update the
screen ("Payment received, confirming…"), not to release goods.

## Step by step

**1. Verify the webhook signature**

Compute `HMAC-SHA256(secret, raw_body)` and compare it in constant
time with `X-Kwugwo-Signature`. Reject mismatches with `401`. See
[Verifying the signature](https://docs.kwugwo.africa/webhook-deliveries.md#verifying-the-signature).

**2. Acknowledge and de-duplicate**

Store the envelope `uid` (`evt.…`); if you've seen it, return `200`
and stop. Otherwise queue the work and return `200` quickly.

**3. Find your order**

Look it up by `data.ref` (your order ID) or by `data.uid` (the ugwo
ID you stored when you created it). Unknown? Log it and stop.

**4. Re-fetch the ugwo**

Call `GET /v1/ugwo/{uid}`. The webhook `data` is a snapshot from
when the event fired; the API is the source of truth.

**5. Compare against the order**

Check status, amounts, currency and ref as in [the rule](#the-rule).
Any mismatch: don't fulfil, flag the order for review.

**6. Fulfil exactly once**

Move the order from `pending` to `paid` in a single conditional
update (e.g. `UPDATE … WHERE id = ? AND status = 'pending'`). If no
row changed, it was already fulfilled.

## Example handler

```js Node (Express) theme={"system"}
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/kwugwo", express.raw({ type: "application/json" }), async (req, res) => {
  // 1. Verify
  const expected = crypto.createHmac("sha256", process.env.KWUGWO_WEBHOOK_SECRET)
    .update(req.body).digest("hex");
  const given = req.get("X-Kwugwo-Signature") ?? "";
  if (given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);

  // 2. De-duplicate, then acknowledge
  if (!(await db.webhookEvents.insertIfNew(event.uid))) return res.sendStatus(200);
  res.sendStatus(200);

  if (event.event === "ugwo.successful") await confirmPayment(event.data.uid);
});

async function confirmPayment(ugwoUid) {
  // 3–4. Find the order and re-fetch the ugwo
  const order = await db.orders.findByUgwoUid(ugwoUid);
  if (!order) return log.warn("Unknown ugwo", ugwoUid);

  const ugwo = await fetch(`${process.env.KWUGWO_API}/v1/ugwo/${ugwoUid}`, {
    headers: { Authorization: `Bearer ${process.env.KWUGWO_SECRET_KEY}` },
  }).then(r => r.json());

  // 5. Compare
  const ok =
    ugwo.status === "ugwo_successful" &&
    ugwo.total_amount_paid >= ugwo.amount &&
    ugwo.amount === order.totalKobo &&
    ugwo.currency === order.currency &&
    ugwo.ref === order.id;

  if (!ok) return db.orders.flagForReview(order.id, ugwo);

  // 6. Fulfil once
  const changed = await db.orders.markPaidIfPending(order.id, {
    ugwoUid: ugwo.uid,
    activityUid: ugwo.lastest_ugwo_activity?.uid, // note the spelling; keep it for refunds
  });
  if (changed) await fulfil(order.id);
}
```

```python Python (Flask) theme={"system"}
import hmac, hashlib, json, os, requests
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhooks/kwugwo")
def kwugwo_webhook():
    raw = request.get_data()
    expected = hmac.new(os.environ["KWUGWO_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Kwugwo-Signature", "")):
        return "", 401

    event = json.loads(raw)
    if not db.webhook_events.insert_if_new(event["uid"]):
        return "", 200

    if event["event"] == "ugwo.successful":
        queue.enqueue(confirm_payment, event["data"]["uid"])
    return "", 200


def confirm_payment(ugwo_uid):
    order = db.orders.find_by_ugwo_uid(ugwo_uid)
    if order is None:
        return

    ugwo = requests.get(
        f"{os.environ['KWUGWO_API']}/v1/ugwo/{ugwo_uid}",
        headers={"Authorization": f"Bearer {os.environ['KWUGWO_SECRET_KEY']}"},
    ).json()

    ok = (
        ugwo["status"] == "ugwo_successful"
        and ugwo["total_amount_paid"] >= ugwo["amount"]
        and ugwo["amount"] == order.total_kobo
        and ugwo["currency"] == order.currency
        and ugwo["ref"] == order.id
    )
    if not ok:
        return db.orders.flag_for_review(order.id, ugwo)

    activity = ugwo.get("lastest_ugwo_activity") or {}  # note the spelling
    if db.orders.mark_paid_if_pending(order.id, ugwo["uid"], activity.get("uid")):
        fulfil(order.id)
```

## Edge cases

> **The customer paid less than the amount**
>
> If the PSP reports collecting less than `amount` (for example a
> short bank transfer), the activity is `successful` but the ugwo goes
> back to `requires_ugwo` and `total_amount_paid` shows what arrived.
> No `ugwo.successful` is sent. A new attempt asks for the **full**
> amount again, so refund the short payment or settle the difference
> with the customer directly.

> **The customer paid twice**
>
> A customer can pay into an old virtual account after the order is
> already paid. You receive `ugwo.activity.double_charge_detected`
> and the activity has `is_double_charge: true`. The ugwo is
> unchanged. [Refund](https://docs.kwugwo.africa/refunds/create.md) that activity.

> **The webhook never arrived**
>
> Run a reconciliation job every few minutes: for orders still
> `pending` after, say, 10 minutes, call `GET /v1/ugwo/{uid}` and
> apply the same checks. Webhooks are retried
> [three times, 30 minutes apart](https://docs.kwugwo.africa/webhook-deliveries.md#retries), so
> don't rely on them alone.

> **The ugwo is partially\_refunded or refunded**
>
> It was paid, then (partly) refunded. If you haven't fulfilled yet,
> treat it as needing review rather than shipping.

> **A payment was reversed**
>
> A PSP can reverse a payment it had confirmed. You receive
> `ugwo.activity.updated` with `data.status: "reversed"`. Hold or claw
> back the order according to your policy.

## Store these IDs on your order

| Field | Why |
| - | - |
| `ugwo.uid` | Re-fetch the payment and match webhooks. |
| `ugwo.ref` | Your order ID, echoed everywhere. |
| Successful activity `uid` | [Refunds](https://docs.kwugwo.africa/refunds/create.md) are made against the activity. |
| `activity.psp_external_id` | Find the transaction in your PSP dashboard. |


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