Developers
Webhooks
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.
evt_ and 24 characters. The same on every retry: act on each id once.livemode: false.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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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"
}
}WP- and 8 characters. The payer's SMS carries it too.MOBILE_MONEY, CARD, BANK, ABROAD or WELLTH_BALANCE.PAID or still OPEN.The signature
Wellth-Signature: t=1793607300,v1=feae610c47b9d3b49bd80f86f1f6977a0cead158e0b1f1981d4a771fca2950c8tis when Wellth sent this try, in Unix seconds.v1is 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
tmore 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
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);
}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);
});// 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
<?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
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 "", 200A 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.
whsec_EXAMPLEonlyEXAMPLEonlyEXAMPLE000{"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"}}t=1793607300,v1=feae610c47b9d3b49bd80f86f1f6977a0cead158e0b1f1981d4a771fca2950c8Answering, 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
idyou have acted on; answer 200 to a repeat and do nothing. - Book money once per payment: key on
wellthRef. For bills, onwellthRefandbillIdtogether, since one payment can pay several bills. - Then tell Wellth you counted it: send the learner's new balance with the
wellthRefin 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: 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.