Skip to content

Developers

Webhooks

Wellth posts an event to your system the moment money lands or a payment request ends. Your system books it, and its balance is right without anyone typing a receipt number.

Setting it up

  • On Connect, the owner saves your webhook address: https:// and on the public internet (in development, http:// and local addresses work too).
  • Your signing secret (whsec_…) came with your first API key. Lost it? Make a new one on Connect: the old one stops at once.
  • Events are made only while an address is set. A payment that lands while there is none is never sent later, so set the address before you hand out numbers or links.

What Wellth sends

A POST to your address with a JSON body, and these headers: Content-Type: application/json, Wellth-Signature (below) and User-Agent: Wellth-Webhooks/1.

idstringevt_ and 24 characters. The same on every retry: act on each id once.
typestringWhich event, from the list below.
livemodebooleanfalse while your organisation's newest key is a test key. In production, answer 200 and ignore livemode: false.
createdstringWhen the event was made (ISO time), which is when the money landed or the request ended.
dataobjectThe event's fields. Money is a string of whole shillings, never including the payer's fee.

Events

Every event type Wellth sends today. A payer is shown by first name and last initial as Wellth knows them (initials in these examples).

learner.payment_succeeded

Someone paid by a payment number: a learner's, a payer's, a rider's, or a site's counter number (externalRef starts counter:). One event per payment, with the whole amount.

Your system: Book the payment against the learner, then send the new balance with PUT /learners/:externalRef, listing this wellthRef in includesWellthRefs.

learner.payment_succeeded body
{
  "id": "evt_3f9a1c7e5b2d8f4a6c0e9b1d",
  "type": "learner.payment_succeeded",
  "livemode": false,
  "created": "2026-11-02T08:15:00Z",
  "data": {
    "externalRef": "oca_stu_ckx123",
    "payNumber": "2047118530",
    "wellthRef": "WP-8F3K2Q7A",
    "amountMinor": "150000",
    "currency": "UGX",
    "paidAt": "2026-11-02T08:14:51Z",
    "method": "MOBILE_MONEY",
    "payer": "J. N.",
    "termLabel": "Term 3, 2026"
  }
}

bill.payment_succeeded

A payment went to a bill: by the payer's number (Wellth pays the open bills oldest first) or by a family share of that bill. One event per bill the payment touched.

Your system: Book the part on the bill. status says whether the bill is now PAID or still OPEN.

bill.payment_succeeded body
{
  "id": "evt_3f9a1c7e5b2d8f4a6c0e9b1d",
  "type": "bill.payment_succeeded",
  "livemode": false,
  "created": "2026-11-02T08:15:00Z",
  "data": {
    "billId": "cm3x8k2p40001ab12cd34ef56",
    "externalRef": "inv_20931",
    "payerRef": "pt_4471",
    "payNumber": "4027186354",
    "wellthRef": "WP-2M7HX9QD",
    "amountMinor": "30000",
    "currency": "UGX",
    "paidAt": "2026-11-02T08:14:51Z",
    "method": "ABROAD",
    "payer": "S. A.",
    "status": "PAID"
  }
}

collection.payment_succeeded

Someone paid a collection (a one-off pay link or pay code). A family share of a bill is a collection too: its externalRef starts bill: and a bill.payment_succeeded comes with it.

Your system: Book the payment against your externalRef. With an amount set, the collection is PAID once paidMinor reaches it.

collection.payment_succeeded body
{
  "id": "evt_3f9a1c7e5b2d8f4a6c0e9b1d",
  "type": "collection.payment_succeeded",
  "livemode": false,
  "created": "2026-11-02T08:15:00Z",
  "data": {
    "collectionId": "col_k2m9x4q7w1z8c5v3b6n0",
    "externalRef": "oca_stu_ckx123:term_2026_3",
    "wellthRef": "WP-5TR8WK3E",
    "amountMinor": "350000",
    "currency": "UGX",
    "paidAt": "2026-11-02T08:14:51Z",
    "method": "CARD",
    "payer": "P. K."
  }
}

collection.cancelled

The collection stopped: you cancelled it, you sent the same externalRef again with a different amount (the old one is cancelled and a new one made), or a family share was stopped.

Your system: Stop showing its pay link and pay code. A payment already on its way may still land and is reported as usual.

collection.cancelled body
{
  "id": "evt_3f9a1c7e5b2d8f4a6c0e9b1d",
  "type": "collection.cancelled",
  "livemode": false,
  "created": "2026-11-02T08:15:00Z",
  "data": {
    "collectionId": "col_k2m9x4q7w1z8c5v3b6n0",
    "externalRef": "oca_stu_ckx123:term_2026_3"
  }
}

collection.expired

The collection passed its expiresAt (60 days by default) while still open.

Your system: Make a new collection if money is still owed.

collection.expired body
{
  "id": "evt_3f9a1c7e5b2d8f4a6c0e9b1d",
  "type": "collection.expired",
  "livemode": false,
  "created": "2026-11-02T08:15:00Z",
  "data": {
    "collectionId": "col_7hq2n5x8k4w1m9z3c6v0",
    "externalRef": "oca_stu_ckx123:term_2026_2"
  }
}
wellthRefstringWellth's receipt for the payment, WP- and 8 characters. The payer's SMS carries it too.
amountMinorstringWhat you receive for this payment (for a bill event, this bill's part). Never the payer's fee.
methodstringMOBILE_MONEY, CARD, BANK, ABROAD or WELLTH_BALANCE.
paidAtstringWhen it landed.
termLabelstring or nulllearner.payment_succeeded: what the payer was shown it was for.
statusstringbill.payment_succeeded: the bill is now PAID or still OPEN.

The signature

Wellth-Signature header
Wellth-Signature: t=1793607300,v1=feae610c47b9d3b49bd80f86f1f6977a0cead158e0b1f1981d4a771fca2950c8
  • t is when Wellth sent this try, in Unix seconds. v1 is the hex HMAC-SHA256 of <t>.<raw body> (the time, a full stop, then the body exactly as received), keyed with your signing secret.
  • Work out the HMAC over the raw bytes you received, before parsing JSON. Parsing and re-serialising changes the bytes and the signature fails.
  • Compare in constant time, and refuse a t more than 300 seconds from your clock (keep it synced). That stops replays.
  • Each retry is signed again with a new t; the body is the same bytes every time.

Node.js

verifyWellthSignature.js
const crypto = require("node:crypto");

// rawBody: the request body exactly as received (a Buffer or string), before any JSON parsing.
// header: the Wellth-Signature header. secret: your whsec_... signing secret.
function verifyWellthSignature(rawBody, header, secret, nowSec = Math.floor(Date.now() / 1000)) {
  const parts = {};
  for (const item of String(header || "").split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(nowSec - t) > 300) return false; // more than 5 minutes off: refuse

  const expected = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(parts.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Express
const express = require("express");
const app = express();

// express.raw keeps the body as bytes: the signature is over the raw body, not re-serialised JSON.
app.post("/wellth/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifyWellthSignature(req.body, req.get("Wellth-Signature"), process.env.WELLTH_WEBHOOK_SECRET)) {
    return res.status(401).send("Bad signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  if (process.env.NODE_ENV === "production" && !event.livemode) return res.sendStatus(200); // a test event

  // Act once per event id (store it; a repeat is answered 200 and ignored).
  await handleWellthEvent(event);
  res.sendStatus(200);
});
Next.js route handler
// app/api/wellth/webhook/route.ts
export async function POST(req: Request) {
  const raw = await req.text(); // the raw body, before JSON.parse
  if (!verifyWellthSignature(raw, req.headers.get("wellth-signature"), process.env.WELLTH_WEBHOOK_SECRET!)) {
    return new Response("Bad signature", { status: 401 });
  }
  const event = JSON.parse(raw);
  if (process.env.NODE_ENV === "production" && !event.livemode) return new Response(null, { status: 200 });
  await handleWellthEvent(event); // idempotent on event.id
  return new Response(null, { status: 200 });
}

PHP

wellth-webhook.php
<?php
// $rawBody: file_get_contents('php://input'), before json_decode.
function wellth_verify_signature(string $rawBody, string $header, string $secret, ?int $now = null): bool
{
    $parts = [];
    foreach (explode(',', $header) as $item) {
        $kv = explode('=', trim($item), 2);
        if (count($kv) === 2) {
            $parts[$kv[0]] = $kv[1];
        }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
        return false;
    }
    $t = (int) $parts['t'];
    if (abs(($now ?? time()) - $t) > 300) {
        return false; // more than 5 minutes off: refuse
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_WELLTH_SIGNATURE'] ?? '';
if (!wellth_verify_signature($raw, $header, getenv('WELLTH_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Bad signature');
}
$event = json_decode($raw, true);
// Act once per $event['id'], then answer 200.
http_response_code(200);

Python

wellth_webhook.py
import hashlib
import hmac
import time


def verify_wellth_signature(raw_body: bytes, header: str, secret: str, now=None) -> bool:
    """raw_body: the request body as bytes, before json.loads (Flask: request.get_data())."""
    parts = {}
    for item in (header or "").split(","):
        key, sep, value = item.strip().partition("=")
        if sep:
            parts[key] = value
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(t)) > 300:
        return False  # more than 5 minutes off: refuse
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


# Flask
# @app.post("/wellth/webhook")
# def wellth_webhook():
#     if not verify_wellth_signature(request.get_data(), request.headers.get("Wellth-Signature", ""), os.environ["WELLTH_WEBHOOK_SECRET"]):
#         return "Bad signature", 401
#     event = request.get_json()
#     # Act once per event["id"], then answer 200.
#     return "", 200

A test vector

Check your code before Wellth sends anything: with this secret and body, and your clock set to 2026-11-02T08:15:00Z, your check must accept this header. The secret is an example, not a real one.

secret
whsec_EXAMPLEonlyEXAMPLEonlyEXAMPLE000
raw body (one line, exactly these bytes)
{"id":"evt_3f9a1c7e5b2d8f4a6c0e9b1d","type":"learner.payment_succeeded","livemode":false,"created":"2026-11-02T08:15:00.000Z","data":{"externalRef":"oca_stu_ckx123","payNumber":"2047118530","wellthRef":"WP-8F3K2Q7A","amountMinor":"150000","currency":"UGX","paidAt":"2026-11-02T08:14:51.000Z","method":"MOBILE_MONEY","payer":"J. N.","termLabel":"Term 3, 2026"}}
Wellth-Signature
t=1793607300,v1=feae610c47b9d3b49bd80f86f1f6977a0cead158e0b1f1981d4a771fca2950c8

Answering, and retries

  • Answer 2xx within 10 seconds. Book the event, answer, and do slow work afterwards.
  • Wellth sends due events every two minutes, so an event usually reaches you within two minutes of the payment. Each organisation's events go one at a time, oldest first, but a retried event can arrive after a newer one: don't depend on the order.
  • 5xx, 408, 429, no answer within 10 seconds, or no connection: retried after 1 min, 5 min, 30 min, 2 h, 12 h, 24 h (about 39 hours in all), then FAILED.
  • Any other 4xx (401 bad signature, 404 unknown reference, 400): you refused it. FAILED at once, never retried. Answer 4xx only when trying again can't help.
  • Wellth follows no redirects. A 3xx is retried like an error, so save the final address.
  • A FAILED event shows on Connect with its last answer. The owner presses Try again now once your system is fixed: it goes out at once, on a fresh schedule.

Acting once

  • The same event can reach you twice (your answer was lost, or came after the 10 seconds and the event was retried). Store each id you have acted on; answer 200 to a repeat and do nothing.
  • Book money once per payment: key on wellthRef. For bills, on wellthRef and billId together, since one payment can pay several bills.
  • Then tell Wellth you counted it: send the learner's new balance with the wellthRef in includesWellthRefs.

Trying your receiver

Post a correctly signed test event to your own receiver, with your signing secret in WELLTH_WEBHOOK_SECRET. Node 18 or later, no packages.

send-test-event.js
// send-test-event.js: node send-test-event.js http://localhost:4000/wellth/webhook
const crypto = require("node:crypto");

function signWellthPayload(body, secret, nowSec = Math.floor(Date.now() / 1000)) {
  const v1 = crypto.createHmac("sha256", secret).update(nowSec + "." + body).digest("hex");
  return "t=" + nowSec + ",v1=" + v1;
}

const body = JSON.stringify({
  id: "evt_" + crypto.randomBytes(12).toString("hex"),
  type: "learner.payment_succeeded",
  livemode: false,
  created: new Date().toISOString(),
  data: {
    externalRef: "oca_stu_ckx123", payNumber: "2047118530", wellthRef: "WP-8F3K2Q7A",
    amountMinor: "150000", currency: "UGX", paidAt: new Date().toISOString(),
    method: "MOBILE_MONEY", payer: "J. N.", termLabel: "Term 3, 2026",
  },
});

const secret = process.env.WELLTH_WEBHOOK_SECRET; // the same whsec_... your receiver checks with
fetch(process.argv[2], {
  method: "POST",
  headers: { "Content-Type": "application/json", "Wellth-Signature": signWellthPayload(body, secret) },
  body,
}).then((r) => console.log("Your receiver answered", r.status));

For real events from a Wellth test environment, see Testing.