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",
  "id": "01j2k3m4n5p6q7r8s9t0uvwx",
  "internal_reference": "RHP-2024-ABC123",
  "provider_reference": "9876543210",
  "type": "collection",
  "status": "successful",
  "payment_method": "mobile_money",
  "phone_number": "256700123456",
  "amount": 50000,
  "currency": "UGX",
  "commission_amount": 500,
  "net_amount": 49500,
  "environment": "live",
  "created_at": "2024-07-15T08:30:00Z",
  "updated_at": "2024-07-15T08:31:47Z"
}

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");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response("Unauthorized", { status: 401 });
  }

  const event = JSON.parse(rawBody);

  if (event.event === "deposit.successful") {
    // Fulfil order, credit customer account, etc.
    await creditAccount(event.internal_reference, event.net_amount);
  }

  if (event.event === "withdraw.failed") {
    // Notify customer that payout failed
    await notifyCustomer(event.internal_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");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send("Invalid signature");
  }

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

  switch (event) {
    case "deposit.successful":
      // Credit customer
      break;
    case "withdraw.successful":
      // Mark payout sent
      break;
    case "withdraw.failed":
      // Notify customer — funds returned automatically
      break;
  }

  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()

    if event["event"] == "deposit.successful":
        await credit_account(event["internal_reference"], event["net_amount"])

    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);

switch ($event["event"]) {
    case "deposit.successful":
        creditAccount($event["internal_reference"], $event["net_amount"]);
        break;
    case "withdraw.failed":
        notifyCustomer($event["internal_reference"]);
        break;
}

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 with exponential backoff
  • Process heavy logic asynchronously: return 200 first, then process in a background job

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.