Integration Guide
The right way to integrate RohoPay end to end: deposits, payouts, webhooks, and reconciliation β with the mistakes to avoid.
This guide walks through a complete, production-grade integration: taking deposits (collections), sending payouts (disbursements), reacting to webhooks, and reconciling anything a webhook missed. It reflects how the API actually behaves, including the pitfalls real integrations have hit.
The rule that everything below follows: RohoPay is the source of truth for
whether money moved. Your database only changes state when a payment reaches
a terminal status (successful or failed). The response to collect /
disburse only ever means "request accepted".
0. Before you write code
| You need | Where |
|---|---|
API key (live_β¦ / test_β¦) | Dashboard β Projects β API Keys |
| Webhook signing secret | Dashboard β Webhooks |
| A public HTTPS endpoint for webhooks | Yours β must resolve to a public IP |
| Funded live wallet (payouts only) | Dashboard β Wallet |
ROHOPAY_API_KEY=live_xxx # server-side only, never in the browser
ROHOPAY_WEBHOOK_SECRET=xxx # from Dashboard β Webhooks
ROHOPAY_CALLBACK_URL=https://your-app.com/api/webhooks/rohopay
Keep the key and secret on your server. Never ship them in client-side bundles or commit them to git.
callback_url is checked for safety: it must be http(s) and resolve to a
public address. localhost, private ranges and link-local addresses are
silently refused, so no webhook is delivered. Use a tunnel (ngrok /
cloudflared) when developing locally.
1. Ground rules for every request
Amounts are whole currency units. UGX has no minor unit β send 50000
for UGX 50,000. Do not multiply by 100. (An integration that did this
sent 100Γ the intended amount to the customer's phone.)
Phones are 256XXXXXXXXX β country code, no leading 0, no +.
Normalise before sending (see Phone Formats).
currency is required β UGX, KES, TZS or RWF. There is no
default.
Idempotency-Key is required on collect and disburse. Missing it
returns 400 MISSING_IDEMPOTENCY_KEY. See
Choosing an idempotency key.
Limits: minimum UGX 500 for mobile money (UGX 2,000 for cards), maximum UGX 100,000,000 per transaction.
Every JSON response uses one envelope:
{ "success": true, "data": { "...": "..." }, "message": "Payment request initiated" }
{ "success": false, "error": { "code": "INVALID_PHONE", "message": "β¦" } }
2. Know the three names for a payment
The same payment appears under different field names depending on where you
read it. Store reference β it is the one that works everywhere.
| Where | Field for the payment's public reference | Field for RohoPay's internal id |
|---|---|---|
collect / disburse response | reference | transaction_id |
| Webhook payload | reference | transaction_id |
GET /api/v1/transactions/{ref} response | internal_reference | id |
GET /api/v1/transactions/{ref} looks up by that same value (the reference
you stored). Provider references are never exposed.
3. A small server-side client
import crypto from "crypto";
const BASE = "https://api.rohopay.com";
const KEY = process.env.ROHOPAY_API_KEY!;
const SECRET = process.env.ROHOPAY_WEBHOOK_SECRET!;
async function call(path: string, init: RequestInit & { idempotencyKey?: string } = {}) {
const { idempotencyKey, ...rest } = init;
const res = await fetch(`${BASE}${path}`, {
...rest,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
});
const json = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, data: json.data, error: json.error ?? json };
}
type Pay = {
phone: string; // 256XXXXXXXXX
amount: number; // whole units β no Γ100
currency: string; // UGX | KES | TZS | RWF
description: string;
callback_url: string;
idempotencyKey: string;
};
export const collect = ({ idempotencyKey, ...body }: Pay) =>
call("/api/v1/collect", {
method: "POST",
idempotencyKey,
body: JSON.stringify({ ...body, payment_method: "mobile_money" }),
});
export const disburse = ({ idempotencyKey, ...body }: Pay) =>
call("/api/v1/disburse", { method: "POST", idempotencyKey, body: JSON.stringify(body) });
export const getTransaction = (reference: string) =>
call(`/api/v1/transactions/${encodeURIComponent(reference)}`);
/** Verify against the RAW body β never a re-serialised object. */
export function verifySignature(rawBody: string, header: string | null): boolean {
if (!SECRET || !header) return false;
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export function normalizeUgandaPhone(phone: string): string {
let p = phone.trim().replace(/[\s-]/g, "");
if (p.startsWith("+")) p = p.slice(1);
if (p.startsWith("0")) p = "256" + p.slice(1);
return p.startsWith("256") ? p : "256" + p;
}
4. Deposits (collections)
Create your own record first with status INITIATING/PENDING
(order, deposit requestβ¦). Its id seeds the idempotency key.
Call collect. The customer gets a mobile-money prompt on their phone.
Require a reference in the response. If it is missing, fail the
request β without it you can never match the webhook back to your record,
and the deposit would sit pending forever.
Save reference on your record and tell the customer "approve the
prompt on your phone". Do not credit anything yet.
Credit on the webhook (or on reconciliation polling) when
status === "successful".
// POST /api/deposits/mobile-money
const deposit = await db.deposit.create({ data: { userId, amount, status: "PENDING" } });
const res = await collect({
phone: normalizeUgandaPhone(phoneNumber),
amount, // whole UGX
currency: "UGX",
description: `Deposit ${deposit.id}`,
callback_url: process.env.ROHOPAY_CALLBACK_URL!,
idempotencyKey: `collect:${deposit.id}`, // stable per business event
});
if (!res.ok || !res.data?.reference) {
await db.deposit.update({ where: { id: deposit.id }, data: { status: "FAILED" } });
return Response.json({ error: res.error?.message ?? "Could not start payment" }, { status: 502 });
}
await db.deposit.update({
where: { id: deposit.id },
data: { rohopayReference: res.data.reference },
});
return Response.json({ status: "PENDING", message: "Approve the prompt on your phone." });
5. Payouts (disbursements)
Payouts move real money and need a live key and funded live wallet
(test_ keys return 400 DISBURSEMENT_BLOCKED).
Reserve the funds in your own ledger first (deduct the user's balance when they request the withdrawal, or when you approve it). This prevents double-spending while the payout is in flight.
Call disburse with a stable idempotency key tied to that withdrawal.
Check the response status β not just the HTTP code. A payout the
provider rejects immediately comes back as HTTP 200 with
data.status === "failed" and an error_reason. RohoPay has already
refunded your wallet, so refund your user right away; a webhook will
not follow.
If status is pending, save reference, mark the withdrawal
PROCESSING, and let the webhook finalise it.
Send one payout at a time per wallet. While a payout is pending, a
second disburse for the same wallet returns 409 INFLIGHT_TRANSACTION.
Approve withdrawals sequentially (or queue them) and wait for the previous
one's webhook.
What the recipient actually receives
Your live wallet is debited exactly amount. The transfer fee comes out of
what is sent to the recipient, not on top of your debit:
{
"status": "pending",
"amount": 20000, // requested
"wallet_debit": 20000, // what your wallet was charged
"recipient_amount": 19100 // what the phone receives (amount β fee)
}
Both figures are returned when the payout is created. If the recipient must
receive an exact figure, add the fee to amount when you call disburse, and
show recipient_amount from the response to your user.
6. The webhook receiver
RohoPay POSTs to the callback_url you supplied when the payment changes
state. The body is small and stable:
{
"event": "deposit.successful",
"transaction_id": "01j2k3m4n5p6q7r8s9t0uvwx",
"reference": "ref_abc123",
"status": "successful",
"amount": 50000,
"currency": "UGX",
"type": "collection",
"environment": "live",
"provider": "rohopay",
"timestamp": "2026-07-15T08:31:47Z"
}
Headers: Content-Type: application/json, User-Agent: RohoPay-Webhook/1.0,
X-RohoPay-Signature: sha256=<hex>.
Rules of a correct receiver:
Verify the signature on the raw body before parsing. Reject with 401
on mismatch.
Decide on type + status, not on event. Act only when status is
successful or failed; ignore anything else (e.g. pending). The
event name is derived from the status and is not a reliable trigger on
its own.
Find your record by reference. Unknown reference β log it and still
return 200 (retrying will never help).
Apply the change exactly once, inside a database transaction, and only
from a non-final state (PENDING/PROCESSING). Webhooks can repeat; the
state guard makes a duplicate a no-op.
Return 2xx fast. Do slow work (emails, notifications) after
responding or in a background job.
// POST /api/webhooks/rohopay
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes
if (!verifySignature(raw, req.headers.get("x-rohopay-signature"))) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const { type, status, reference } = JSON.parse(raw);
if (!reference || (status !== "successful" && status !== "failed")) {
return Response.json({ received: true }); // not actionable
}
if (type === "collection") {
const dep = await db.deposit.findFirst({ where: { rohopayReference: reference } });
if (dep && dep.status === "PENDING") { // exactly-once guard
await db.$transaction(async (tx) => {
if (status === "successful") {
await tx.deposit.update({ where: { id: dep.id }, data: { status: "APPROVED" } });
await tx.wallet.update({ where: { userId: dep.userId }, data: { balance: { increment: dep.amount } } });
} else {
await tx.deposit.update({ where: { id: dep.id }, data: { status: "REJECTED" } });
}
});
}
}
if (type === "disbursement") {
const wd = await db.withdrawal.findFirst({ where: { rohopayReference: reference } });
if (wd && (wd.status === "PENDING" || wd.status === "PROCESSING")) {
await db.$transaction(async (tx) => {
if (status === "successful") {
await tx.withdrawal.update({ where: { id: wd.id }, data: { status: "COMPLETED" } });
} else { // failed β give the money back
await tx.withdrawal.update({ where: { id: wd.id }, data: { status: "REJECTED" } });
await tx.wallet.update({ where: { userId: wd.userId }, data: { balance: { increment: wd.amount } } });
}
});
}
}
return Response.json({ received: true });
}
Delivery is best effort: up to 3 attempts with 1s, 4s, 9s backoff
and a 10-second timeout each. After that the webhook is not re-sent. That is
why step 7 is not optional.
7. Reconcile anything a webhook missed
Run a small job (every minute or two) over your records that are still
PENDING/PROCESSING after a few minutes, and poll RohoPay for the truth:
const stuck = await db.deposit.findMany({
where: { status: "PENDING", createdAt: { lt: new Date(Date.now() - 5 * 60_000) } },
});
for (const dep of stuck) {
const { data } = await getTransaction(dep.rohopayReference);
if (data?.status === "successful" || data?.status === "failed") {
await applyTerminalStatus(dep, data.status); // same function the webhook uses
}
}
Use the same state-transition function as the webhook so the two paths can never disagree. Also compare against Dashboard β Reconciliation when investigating a mismatch.
Choosing an idempotency key
RohoPay stores the response for each Idempotency-Key for 24 hours and
replays it for the same key β including error responses. So:
| Situation | What to do |
|---|---|
| Request timed out / connection dropped, no response received | Retry with the same key β you get the original result, never a duplicate |
You received a definitive error (4xx/5xx) | That error is cached for the key. To try again, treat it as a new attempt: new record, new key |
User retries after a failed payment | New business event β new record, new key |
Derive keys from your own record id (collect:<depositId>,
disburse:<withdrawalId>), not randomUUID() on every call. A random key per
call defeats the protection: a network-level retry would create a second
payout.
Error codes you will meet
| HTTP | Code | Meaning / what to do |
|---|---|---|
| 400 | MISSING_IDEMPOTENCY_KEY | Add the Idempotency-Key header (this one returns a flat {error, message} body) |
| 400 | INVALID_PHONE | Not 256/254/255/250β¦ β normalise |
| 400 | AMOUNT_TOO_LOW / AMOUNT_TOO_HIGH | Outside limits |
| 400 | INVALID_CALLBACK_URL | Must start with http:// or https:// |
| 400 | DISBURSEMENT_BLOCKED | Payouts need a live key |
| 401 | INVALID_API_KEY | Missing, wrong, or inactive key |
| 402 | INSUFFICIENT_BALANCE | Live wallet too low for this payout |
| 409 | INFLIGHT_TRANSACTION | A payout is already pending β wait for it, then retry as a new attempt |
| 409 | CONCURRENCY_CONFLICT | Wallet changed mid-request β retry as a new attempt |
| 429 | RATE_LIMITED / DAILY_LIMIT_EXCEEDED / TEST_LIMIT_EXCEEDED | Slow down / limit reached |
| 503 | DISBURSEMENT_TEMPORARILY_UNAVAILABLE | No provider can take it right now β retry shortly as a new attempt |
Every error response above is cached against its Idempotency-Key, so
"retry" always means a new key. Only a request that never received a
response is retried with the same key.
Go-live checklist
- Live key + webhook secret are server-side env vars only
- Amounts are whole units; phones are
256XXXXXXXXX - Idempotency keys are derived from your record ids
- Deposits are credited only on
successful(webhook or polling) - Missing
referencein a create response fails the request - Payout
data.status === "failed"is handled (refund immediately) - Payouts are sent one at a time per wallet
- Webhook verifies the raw-body signature and is idempotent
- A reconciliation job polls stuck
PENDING/PROCESSINGrecords - Tested end to end with
test_keys, then one small live transaction
