Webhooks

We notify your server when a Mobile Money payment is confirmed so you can fulfill without polling.

Set up your endpoint

  1. Deploy an HTTPS URL that accepts POST with a JSON body (and verifies the signature — see below).
  2. Paste the full public URL in Connect → Webhook (include https://; use the same host you ship in production, e.g. www vs bare domain).
  3. Copy the signing secret shown when you save. Store it on your server as WAAGUAN_WEBHOOK_SECRET (must match exactly).
  4. Click Send test. We POST a signed charge.test event. Your handler should return 2xx.

“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 POST to your URL. Success looks like status 200. fetch failed means 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 GET with a small health JSON like { "ok": true } so browser checks look friendly.
  • A POST without our signature should return 401 Invalid signature — that usually means the route is live and secret checking is on.

Events

  • charge.success — payment confirmed; order is paid
  • charge.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/json
  • X-Waaguan-Signature — hex HMAC-SHA256 of the raw request body using your webhook secret
  • X-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 2xx within a few seconds; do heavy work asynchronously.

Webhook best practices

  • Return 2xx quickly; do heavy work asynchronously.
  • Make handlers idempotent — the same reference may be delivered more than once.
  • Reject requests with invalid signatures with 401.
  • Only accept HTTPS URLs (we block unsafe / private destinations).