Webhooks Reference
Receive real-time payment notifications from RohoPay and verify their signatures.
How it works
RohoPay sends you a webhook (an HTTP POST) every time a transaction you
initiated changes status — e.g. a customer pays, or a payout settles. You supply
a callback_url when you create the transaction; RohoPay signs the body with
HMAC-SHA256 and posts it there. Verify the signature with the secret shown in
your dashboard, then return any 2xx to acknowledge.
RohoPay also receives internal, server-to-server status callbacks on its own payment rails. Those endpoints aren't yours to call and aren't part of the public API surface — this page covers only the webhooks RohoPay sends to you.
1. Set your callback_url
Add callback_url to the request that creates the transaction. RohoPay fires
the webhook there when the status changes.
- For collections: see
POST /api/v1/collect - For disbursements: see
POST /api/v1/disburse
{
"phone": "256712345678",
"amount": 5000,
"currency": "UGX",
"description": "Order #1024",
"callback_url": "https://your-app.com/webhooks/rohopay"
}
Use an HTTPS URL you control. Webhooks retry with backoff, so make the endpoint idempotent (see step 3).
2. The webhook payload
RohoPay POSTs a JSON body like this:
{
"event": "deposit.successful",
"transaction_id": "01j2k3m4n5p6q7r8s9t0uvwx",
"reference": "RHP-2024-ABC123",
"status": "successful",
"amount": 50000,
"currency": "UGX",
"type": "collection",
"environment": "live",
"provider": "rohopay",
"timestamp": "2024-07-15T08:31:47Z"
}
| Event | Trigger |
|---|---|
deposit.successful | Mobile money or card collection confirmed (funds credited) |
deposit.failed | Collection rejected, expired, or timed out (no wallet change) |
withdraw.successful | Disbursement / withdrawal delivered |
withdraw.failed | Disbursement / withdrawal failed (wallet refunded) |
3. Verify the signature
Every webhook carries the signature in the X-RohoPay-Signature header:
X-RohoPay-Signature: sha256=abc123def456...
The signature is sha256= + HMAC-SHA256 of the raw request body, keyed with
your webhook secret.
Where is my secret? Open Dashboard → Webhooks — the "Signing Secret"
card shows it. Programmatically, call GET /dashboard/webhook-config (your
session cookie authenticates the request). One secret covers all of your
projects' outgoing webhooks.
Verify it before you trust or act on the payload:
import crypto from "crypto";
function verify(body: Buffer, sigHeader: string, secret: string): boolean {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
const a = Buffer.from(sigHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Verify the raw body bytes, not a re-serialized JSON object — even a single whitespace difference breaks the HMAC.
Full receiver examples
Read the raw body (required for a correct HMAC).
Compare the header against sha256=HMAC(secret, rawBody) in constant
time. Reject with 401 on mismatch.
Handle the event idempotently — key off transaction_id, since RohoPay
may retry. Return any 2xx to stop retries.
const express = require("express");
const crypto = require("crypto");
const app = express();
// Keep the raw body so the HMAC matches byte-for-byte.
app.post("/webhooks/rohopay",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.ROHOPAY_WEBHOOK_SECRET; // from Dashboard → Webhooks
const sig = req.get("X-RohoPay-Signature") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(req.body) // raw Buffer
.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 event = JSON.parse(req.body.toString());
console.log("Webhook:", event.event, event.transaction_id, event.status);
// Idempotent — RohoPay may retry, so guard on event.transaction_id.
switch (event.event) {
case "deposit.successful": /* credit your customer */ break;
case "withdraw.failed": /* notify / refund */ break;
}
res.json({ received: true }); // any 2xx stops retries
}
);
app.listen(3000);
<?php
$payload = file_get_contents("php://input"); // raw body
$sig = $_SERVER["HTTP_X_ROHOPAY_SIGNATURE"] ?? "";
$secret = getenv("ROHOPAY_WEBHOOK_SECRET"); // from Dashboard → Webhooks
$expected = "sha256=" . hash_hmac("sha256", $payload, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit("Invalid signature");
}
$event = json_decode($payload, true);
// Idempotent — guard on $event["transaction_id"] (RohoPay may retry).
switch ($event["event"]) {
case "deposit.successful": /* credit your customer */ break;
case "withdraw.failed": /* notify / refund */ break;
}
http_response_code(200);
echo json_encode(["received" => true]);
Delivery behaviour
- Trigger: status change only (
pending→successful/failed) - Method:
POSTwithContent-Type: application/json - Retries: up to 3 attempts with
1s, 4s, 9sbackoff - Timeout: 10 seconds per attempt
- Acknowledge: return any
2xxto stop retries; anything else is retried - Idempotency: webhooks may repeat — dedupe on
transaction_id
Get webhook config (API)
GET /dashboard/webhook-config
Cookie: session={your_session}
{
"success": true,
"data": {
"webhook_secret": "your-secret",
"signature_header": "X-RohoPay-Signature",
"signature_format": "sha256={hex_digest}",
"events": [
{ "event": "deposit.successful", "description": "Collection confirmed" },
{ "event": "deposit.failed", "description": "Collection failed" },
{ "event": "withdraw.successful", "description": "Payout sent" },
{ "event": "withdraw.failed", "description": "Payout failed" }
]
}
}
