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",
"transaction_id": "01j2k3m4n5p6q7r8s9t0uvwx",
"reference": "ref_ab12cd34ef56ab78",
"status": "successful",
"amount": 50000,
"currency": "UGX",
"type": "collection",
"environment": "live",
"provider": "rohopay",
"timestamp": "2026-07-15T08:31:47Z"
}
| Field | Use it for |
|---|---|
reference | Match the payment to your record (it is the reference returned by collect / disburse) |
transaction_id | RohoPay's internal id |
type | collection (deposit) or disbursement (payout) |
status | The 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:
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");
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 });
}
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 });
});
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}
$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
disburseresponse 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:
# 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.
