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 needWhere
API key (live_… / test_…)Dashboard β†’ Projects β†’ API Keys
Webhook signing secretDashboard β†’ Webhooks
A public HTTPS endpoint for webhooksYours β€” must resolve to a public IP
Funded live wallet (payouts only)Dashboard β†’ Wallet
terminal.shBash
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:

response.jsonJSON
{ "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.

WhereField for the payment's public referenceField for RohoPay's internal id
collect / disburse responsereferencetransaction_id
Webhook payloadreferencetransaction_id
GET /api/v1/transactions/{ref} responseinternal_referenceid

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

lib/rohopay.tsTypeScript
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".

index.tsTypeScript
// 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:

response.jsonJSON
{
  "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:

response.jsonJSON
{
  "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.

index.tsTypeScript
// 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:

index.tsTypeScript
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:

SituationWhat to do
Request timed out / connection dropped, no response receivedRetry 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 paymentNew 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

HTTPCodeMeaning / what to do
400MISSING_IDEMPOTENCY_KEYAdd the Idempotency-Key header (this one returns a flat {error, message} body)
400INVALID_PHONENot 256/254/255/250… β€” normalise
400AMOUNT_TOO_LOW / AMOUNT_TOO_HIGHOutside limits
400INVALID_CALLBACK_URLMust start with http:// or https://
400DISBURSEMENT_BLOCKEDPayouts need a live key
401INVALID_API_KEYMissing, wrong, or inactive key
402INSUFFICIENT_BALANCELive wallet too low for this payout
409INFLIGHT_TRANSACTIONA payout is already pending β€” wait for it, then retry as a new attempt
409CONCURRENCY_CONFLICTWallet changed mid-request β€” retry as a new attempt
429RATE_LIMITED / DAILY_LIMIT_EXCEEDED / TEST_LIMIT_EXCEEDEDSlow down / limit reached
503DISBURSEMENT_TEMPORARILY_UNAVAILABLENo 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 reference in 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/PROCESSING records
  • Tested end to end with test_ keys, then one small live transaction