Webhooks

Receive real-time payment events from RohoPay via HMAC-verified POST requests.

Overview

When a payment status changes, RohoPay sends a POST request to the callback_url you provided. This is the authoritative notification — more reliable than query parameters in redirect URLs.

Webhook Events

EventTrigger
deposit.successfulA mobile money collection or card payment was confirmed
deposit.failedA mobile money collection or card payment was rejected, expired, or timed out
withdraw.successfulA disbursement or wallet withdrawal succeeded
withdraw.failedA disbursement or withdrawal failed (funds not sent)

Payload

response.jsonJSON
{
  "event": "deposit.successful",
  "transaction_id": "01j2k3m4n5p6q7r8s9t0uvwx",
  "reference": "ref_ab12cd34ef56ab78",
  "status": "successful",
  "amount": 50000,
  "currency": "UGX",
  "type": "collection",
  "environment": "live",
  "provider": "rohopay",
  "timestamp": "2026-07-15T08:31:47Z"
}
FieldUse it for
referenceMatch the payment to your record (it is the reference returned by collect / disburse)
transaction_idRohoPay's internal id
typecollection (deposit) or disbursement (payout)
statusThe outcome — the field to branch on
⚠️

Branch on type + status, and act only on successful or failed. The event name is derived from the status (anything that is not successful maps to a *.failed name), so ignore any webhook whose status is not a final state. Do not fulfil, reject, or refund on event alone.

See the Integration Guide for a complete, idempotent receiver.

Signature Verification

RohoPay signs every outgoing webhook with HMAC-SHA256. The signature is in the X-RohoPay-Signature header:

output.txtText
X-RohoPay-Signature: sha256=abc123def456...

Your webhook secret is visible in Dashboard → Webhooks. Set it as an environment variable on your server:

terminal.shBash
ROHOPAY_WEBHOOK_SECRET=your-secret-from-dashboard

Verification Code

TypeScript (Next.js App Router)TypeScript
import crypto from "crypto";

export async function POST(req: Request) {
  const sig = req.headers.get("x-rohopay-signature") ?? "";
  const rawBody = await req.text();

  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.ROHOPAY_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  // timingSafeEqual throws if lengths differ — guard first.
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const event = JSON.parse(rawBody);

  // Only final states are actionable; ignore anything else.
  if (event.type === "collection" && event.status === "successful") {
    // Idempotent: credit only if your record is still PENDING.
    await creditAccount(event.reference);
  }

  if (event.type === "disbursement" && event.status === "failed") {
    // Payout failed — refund your user (once).
    await refundWithdrawal(event.reference);
  }

  return new Response("OK", { status: 200 });
}
TypeScript (Express)TypeScript
const crypto = require("crypto");
const express = require("express");

app.post("/webhook/rohopay", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-rohopay-signature"];
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.ROHOPAY_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");

  const a = Buffer.from(sig || "");
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("Invalid signature");
  }

  const { type, reference, status } = JSON.parse(req.body);

  // Branch on type + status; ignore non-final statuses.
  if (type === "collection" && status === "successful") {
    // Credit customer (once, keyed on `reference`)
  } else if (type === "disbursement" && status === "successful") {
    // Mark payout sent
  } else if (type === "disbursement" && status === "failed") {
    // Payout failed — refund your user
  }

  res.json({ received: true });
});
Python (FastAPI)Python
import hashlib, hmac, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.post("/webhook/rohopay")
async def rohopay_webhook(request: Request):
    sig = request.headers.get("x-rohopay-signature", "")
    body = await request.body()

    expected = "sha256=" + hmac.new(
        os.environ["ROHOPAY_WEBHOOK_SECRET"].encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(sig, expected):
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = await request.json()

    # Branch on type + status; ignore non-final statuses.
    if event["type"] == "collection" and event["status"] == "successful":
        await credit_account(event["reference"])  # idempotent, keyed on reference

    return {"ok": True}
PHPPHP
$sig      = $_SERVER["HTTP_X_ROHOPAY_SIGNATURE"] ?? "";
$rawBody  = file_get_contents("php://input");
$expected = "sha256=" . hash_hmac("sha256", $rawBody, $_ENV["ROHOPAY_WEBHOOK_SECRET"]);

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit("Unauthorized");
}

$event = json_decode($rawBody, true);

// Branch on type + status; ignore non-final statuses.
if ($event["type"] === "collection" && $event["status"] === "successful") {
    creditAccount($event["reference"]);          // idempotent, keyed on reference
} elseif ($event["type"] === "disbursement" && $event["status"] === "failed") {
    refundWithdrawal($event["reference"]);
}

http_response_code(200);
echo json_encode(["received" => true]);

Delivery Requirements

  • Return HTTP 200–299 within 10 seconds
  • If your endpoint times out or returns a non-2xx code, RohoPay retries — 3 attempts in total (backoff 1s, 4s, 9s). After that the webhook is not re-sent
  • Because delivery is best effort, run a small reconciliation job that polls GET /api/v1/transactions/{reference} for records still pending after a few minutes (see the Integration Guide)
  • Process heavy logic asynchronously: return 200 first, then process in a background job
  • A payout that the provider rejects immediately is reported in the disburse response itself (status: "failed") and does not trigger a webhook

View Webhook Config in Dashboard

Go to Dashboard → Webhooks to:

  • Copy your webhook secret
  • See the signature header name (X-RohoPay-Signature)
  • Test your webhook endpoint

Testing Locally

Use a tunnel to expose your local server during development:

terminal.shBash
# ngrok
ngrok http 3000

# Cloudflare
cloudflared tunnel --url http://localhost:3000

Use the tunnel URL as your callback_url in test-mode requests.

⚠️

Always verify the signature before processing the event. Never skip verification — even in development mode. An unverified webhook handler is a security vulnerability.