Webhooks
We notify your server when a Mobile Money payment is confirmed so you can fulfill without polling.
Set up your endpoint
- Deploy an HTTPS URL that accepts
POSTwith a JSON body (and verifies the signature — see below). - Paste the full public URL in Connect → Webhook (include
https://; use the same host you ship in production, e.g.wwwvs bare domain). - Copy the signing secret shown when you save. Store it on your server as
WAAGUAN_WEBHOOK_SECRET(must match exactly). - Click Send test. We POST a signed
charge.testevent. Your handler should return2xx.
“Connected” vs “working”
- Connected in Connect only means a webhook URL is saved in Waaguan — not that delivery succeeded.
- Send test is the real check: our servers
POSTto your URL. Success looks like status200.fetch failedmeans we could not reach your host (wrong URL, DNS, SSL, downtime, or timeout).
Opening the URL in a browser
Browsers use GET. Your webhook route only needs to handle POST for Waaguan. A bare GET often returns 405 or looks “broken” in Chrome — that does not mean the webhook is dead.
- Optional: respond to
GETwith a small health JSON like{ "ok": true }so browser checks look friendly. - A
POSTwithout our signature should return401 Invalid signature— that usually means the route is live and secret checking is on.
Events
charge.success— payment confirmed; order ispaidcharge.test— sent from the Connect “Send test” button
The event name is also sent in the X-Waaguan-Event header.
Payload
charge.success body
{
"event": "charge.success",
"data": {
"id": "3f2a1b4c-5d6e-7f80-91a2-b3c4d5e6f708",
"reference": "wa-6281126272",
"amount": 50,
"amount_received": 50,
"currency": "GHS",
"status": "paid",
"paid_at": "2026-07-19T12:04:11.000Z",
"customer_email": "customer@email.com",
"customer_name": "AMA MENSAH",
"metadata": { "order_id": "1001" }
}
}Headers
Content-Type: application/jsonX-Waaguan-Signature— hex HMAC-SHA256 of the raw request body using your webhook secretX-Waaguan-Event— e.g.charge.success
Verify the signature
Compute HMAC-SHA256(secret, rawBody) as hex and compare it to X-Waaguan-Signature using a constant-time comparison. Always use the raw bytes/string of the body — do not re-serialize JSON before verifying. The secret must be the exact value from Connect (WAAGUAN_WEBHOOK_SECRET).
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWaaguanSignature(rawBody, signature, secret) {
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature || "", "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// Express example
app.post("/webhooks/waaguan", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("X-Waaguan-Signature");
const event = req.get("X-Waaguan-Event");
const rawBody = req.body.toString("utf8");
if (!verifyWaaguanSignature(rawBody, signature, process.env.WAAGUAN_WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const payload = JSON.parse(rawBody);
if (event === "charge.success" && payload.data.status === "paid") {
// Fulfill — then always return 2xx quickly
}
res.sendStatus(200);
});Troubleshooting
- fetch failed — Waaguan could not open a TCP/TLS connection to your URL. Check deploy, HTTPS cert, firewall, and that the path exists on the public host.
- 401 on Send test — your code rejected the signature. Confirm the secret matches Connect, you use the raw body, and no proxy rewrites JSON before verification.
- www vs non-www — save the URL you actually serve (redirects can break signature or delivery).
- Prefer returning
2xxwithin a few seconds; do heavy work asynchronously.
Webhook best practices
- Return
2xxquickly; do heavy work asynchronously. - Make handlers idempotent — the same
referencemay be delivered more than once. - Reject requests with invalid signatures with
401. - Only accept HTTPS URLs (we block unsafe / private destinations).