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
| Event | Trigger |
|---|---|
deposit.successful | A mobile money collection or card payment was confirmed |
deposit.failed | A mobile money collection or card payment was rejected, expired, or timed out |
withdraw.successful | A disbursement or wallet withdrawal succeeded |
withdraw.failed | A disbursement or withdrawal failed (funds not sent) |
Payload
{
"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:
X-RohoPay-Signature: sha256=abc123def456...
Your webhook secret is visible in Dashboard → Webhooks. Set it as an environment variable on your server:
ROHOPAY_WEBHOOK_SECRET=your-secret-from-dashboard
Verification Code
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 });
}
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 });
});
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}
$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:
# 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.
