Peoplerails API

A server-to-server REST API for businesses to collect and disburse payments. Amounts are integer minor units (e.g. cents/santim). All requests are JSON over HTTPS.

Authentication

Authenticate with your secret API key (shown once at sign-up) as a bearer token. Keep it server-side.

curl https://api.peoplerails.com/v1/me \
  -H "Authorization: Bearer sk_live_..."

Guest checkout (no account)

Customers without a Peoplerails account pay an agent's bank account and submit their bank reference; the agent verifies it and the funds settle to you. This all happens on the hosted page — no integration needed beyond creating the link.

You receive a payment.succeeded webhook when an agent confirms the payment.

Payment methods

The rails the platform supports — mobile wallets (kind: "mobile_money", e.g. telebirr) and banks (kind: "bank", e.g. CBE). Any authenticated caller can list them — use a method's id wherever an endpoint takes payment_method_id (payouts, checkout routing). Methods with customer_reference: true expect the payer's own transaction number (telebirr), entered after they pay.

Each method carries a live available flag computed from current agent capacity, plus an unavailable_reason when it's false. A rail can go temporarily unavailable — telebirr when every online agent is at their daily send limit, or any rail when no agent is on duty. It's a point-in-time signal: re-fetch before offering a method (e.g. at checkout render), don't cache it for long. Only offer methods where available: true.

The list is ordered by position (the admin sets it) — render methods in the order returned. Each also carries logo_url (a brand logo to show before the name, empty if none) and account_placeholder (an example account number for that bank, handy as the placeholder on your account-number input).

GET /v1/payment-methods          // any authed caller (incl. API key)
// -> {
//   "methods": [                    // in display order (position asc)
//     { "id": "pm_9039f594d937", "name": "telebirr", "kind": "mobile_money",
//       "customer_reference": true, "position": 0,
//       "account_placeholder": "", "logo_url": "https://…/telebirr.png",
//       "active": true, "available": true, "unavailable_reason": "" },
//     { "id": "pm_5b1c0a77e210", "name": "CBE", "kind": "bank",
//       "customer_reference": false, "position": 1,
//       "account_placeholder": "1000123456789", "logo_url": "https://…/cbe.png",
//       "active": true, "available": false,
//       "unavailable_reason": "no agent is available right now" }
//   ]
// }

Payouts & refunds

Disburse cash to a recipient via an agent, who sends the money from their own wallet/bank app and confirms it. Pick the rail with payment_method_id (see Payment methods): a mobile-money method sends to recipient_phone; a bank method sends to recipient_account. Larger amounts that exceed the telebirr per-transaction/daily limit can be routed to a bank instead.

Supported banks: a bank payout must go to a supported bank. List them with GET /v1/banks — each has a stable id and its canonical name — and pick from it (or match your bank method to one). An unrecognised bank may fall back to a manual step.

GET /v1/banks                    // public
// -> { "banks": [
//   { "id": "cbe", "name": "Commercial Bank of Ethiopia" },
//   { "id": "abyssinia", "name": "Bank of Abyssinia" },
//   { "id": "awash", "name": "Awash Bank" }
// ] }

Supported rails

Mobile wallets (pay-in & pay-out): telebirr.

Banks (pay-out to an account; pay-in where configured) — 34 supported:

Commercial Bank of EthiopiaAwash BankBank of AbyssiniaDashen BankAbay BankAddis Bank S.C.Ahadu BankAmhara BankBerhan BankBunna BankCooperative Bank of OromiaGlobal Bank EthiopiaEnat BankGadaa BankGoh Betoch BankHibret BankHijira BankLion International BankNib International BankOromia BankRammis BankSidama BankSiinqee BankSiket BankTsedey BankTsehay BankWegagen BankZamZam BankZemen BankVisionFund MicrofinanceOmo BankShabelle BankNisir MicrofinanceKebronhill Microfinance S.C

This list is served live from GET /v1/banks (the source of truth) and updates as banks are added.

For resilience, provide both a phone and a bank account on the payout. If the primary rail can't deliver — the agent declines, or it expires without ever being picked up — the platform automatically re-routes to the other rail, so a rail outage or limit doesn't delay the payout.

// Need a method id? See "Payment methods" above (GET /v1/payment-methods).

// Mobile money (telebirr): send to a phone. Omit payment_method_id to
// default to telebirr.
POST /v1/merchants/{merchant_id}/payouts
{ "amount": 20000, "currency": "ETB", "reference": "PO-77",
  "recipient_phone": "0911223344", "recipient_name": "Abebe",
  "payment_method_id": "pm_telebirr" }   // optional for telebirr
// -> 201 { "request_id": "...", "status": "PENDING", "node_id": "..." }

// Bank (e.g. CBE): send to an account number. recipient_account is
// required for a bank method (recipient_phone is required for mobile).
POST /v1/merchants/{merchant_id}/payouts
{ "amount": 500000, "currency": "ETB", "reference": "PO-78",
  "payment_method_id": "pm_cbe",
  "recipient_account": "1000123456789", "recipient_name": "Abebe" }
// -> 201 { "request_id": "...", "status": "PENDING", "node_id": "..." }

// RECOMMENDED — pair a mobile-money method with a bank for automatic
// fallback. The primary rail (mobile) is tried first; if it can't be
// delivered (the agent declines, or it expires without ever being picked
// up), the platform AUTOMATICALLY re-routes to the fallback rail — so the
// payout isn't left waiting on a manual step. fallback_method_id names the
// exact bank to use (so the right one is picked even with several banks).
POST /v1/merchants/{merchant_id}/payouts
{ "amount": 20000, "currency": "ETB", "reference": "PO-79",
  "payment_method_id": "pm_telebirr",   // primary (mobile money)
  "recipient_phone": "0911223344",
  "fallback_method_id": "pm_cbe",        // fallback rail (a bank)
  "recipient_account": "1000123456789",  // used by the bank fallback
  "recipient_name": "Abebe" }
// The fallback must be a DIFFERENT kind than the primary (a bank fallback
// for a mobile primary, or vice versa), with its destination present.

// CASH OUT your own balance: intent:"cashout" withdraws your own held
// balance to your own number/account. Billed the lower cash-out fee (default
// 1%, vs the 6% payout fee), charged ON TOP: you receive the full amount and
// your wallet is debited amount + fee.
POST /v1/merchants/{merchant_id}/payouts
{ "amount": 100000, "currency": "ETB", "reference": "CASHOUT-1",
  "recipient_phone": "0911223344",   // your own telebirr number
  "intent": "cashout" }

// Refund a payment (full or partial)
POST /v1/merchants/{merchant_id}/payments/{tx_id}/refund
{ "amount": 50000 }

// Track it: payout.pending fires on creation, then payout.completed
// (or payout.failed) once the agent confirms delivery.

// Verify any disbursement by your own reference (returns "payouts"):
GET /v1/merchants/{merchant_id}/verify?reference=PO-77
// List all your payouts:
GET /v1/merchants/{merchant_id}/payouts

Webhooks

Register an endpoint to receive signed events. Each delivery carries an X-Peoplerails-Signature header of the form t=<unix>,v1=<hex> where v1 is HMAC_SHA256(secret, "<t>.<rawBody>") — verify against the raw request body, and reject deliveries whose t is outside a tolerance window (e.g. 5 min) to stop replays. An X-Peoplerails-Event header names the type. Delivery is at-least-once (retried with backoff up to ~1h over 6 attempts), so dedupe on the event id — the same event may arrive more than once. Money moves two ways, so events are grouped as payment.* (money in) and payout.* (money out).

POST /v1/merchants/{merchant_id}/webhooks
{ "url": "https://you.com/hooks", "events": ["payment.succeeded"] }

// Events
//   payment.pending     an incoming payment was claimed, awaiting an agent
//   payment.succeeded   funds settled to your wallet
//   payment.failed      the incoming payment could not be verified
//   payout.pending      a disbursement was queued to an agent
//   payout.completed    the agent delivered the cash
//   payout.failed       the disbursement could not be completed
//   settlement.completed | refund.succeeded
//   dispute.opened | dispute.resolved

The body is wrapped as { id, type, created_at, data }. For an incoming payment, data carries everything you need to reconcile:

{
  "type": "payment.succeeded",
  "data": {
    "reference": "INV-1042",        // YOUR reference from the link
    "amount": 50000, "currency": "ETB",
    "status": "SETTLED",
    "payer_phone": "0912345678",    // who paid
    "node_id": "node_...",
    "node_account": "0911000001",   // the agent account they paid into
    "method": "Telebirr",
    "bank_reference": "PRQX7K2F",   // the on-transfer matching code
    "tx_id": "gcol_pl_..."
  }
}

Verifying the signature

Compute HMAC_SHA256(secret, "<t>.<rawBody>") and compare it to the v1 value from the header, using a constant-time comparison. The single most important rule: hash the raw request body exactly as received — before any JSON parsing. If you parse the JSON and re-serialize it, the bytes change (key order, spacing) and the signature will never match.

Node.js (Express)

const crypto = require("crypto");

// Mount the route with the RAW body — do NOT use express.json() here:
// app.post("/hooks", express.raw({ type: "application/json" }), handler)

function verifyPeoplerails(rawBody, sigHeader, secret) {
  const p = Object.fromEntries(
    sigHeader.split(",").map((kv) => kv.split("="))   // { t, v1 }
  );

  // Replay protection: reject deliveries older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;

  const signed = p.t + "." + rawBody;                 // rawBody = exact bytes we sent
  const expected = crypto
    .createHmac("sha256", secret)                     // secret = your whsec_... value
    .update(signed)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(p.v1)
  );
}

// On success: json.parse the raw body, then DEDUPE on the event id
// (delivery is at-least-once).

PHP

$raw = file_get_contents('php://input');    // raw body, BEFORE json_decode()
$sig = $_SERVER['HTTP_X_PEOPLERAILS_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $sig), $p); // -> $p['t'], $p['v1']

// Replay protection: 5-minute window.
if (abs(time() - (int)$p['t']) > 300) { http_response_code(401); exit; }

$expected = hash_hmac('sha256', $p['t'] . '.' . $raw, $secret); // $secret = whsec_...
if (!hash_equals($expected, $p['v1'])) { http_response_code(401); exit; }

// signature ok -> json_decode($raw) and dedupe on the event id.

Return any 2xx once you've accepted the event. Any non-2xx (or a timeout) is treated as a failed delivery and retried with backoff.

Getting a 401 / invalid signature? Check, in order:

  • You're hashing the raw body, not a re-serialized copy (the #1 cause). Framework JSON middleware often consumes the raw bytes — capture them first.
  • You're using the correct whsec_… secret for this endpoint (shown once when you created it). If it was lost or rotated, rotate it and store the new one.
  • You signed "<t>.<body>" (the t from the header, a literal dot, then the body) — not the body alone — and compared against v1 as lower-case hex.

Verify a payment

The server-side counterpart to webhooks: look a payment up by your own reference and get its current status — handy for polling or back-office reconciliation.

GET /v1/merchants/{merchant_id}/verify?reference=INV-1042

// → {
//   "reference": "INV-1042", "count": 1,
//   "payments": [{
//     "status": "SETTLED", "amount": 50000, "currency": "ETB",
//     "payer_phone": "0912345678", "node_account": "0911000001",
//     "tx_id": "gcol_pl_...", "collection_id": "col_..."
//   }]
// }

Examples & playground

A ready-to-run kit with curl, Python, and Node quick-starts, plus an interactive playground.py that walks valid and invalid payment and payout cases so you can see exactly how the API answers each — no money moves on the validation cases.

Download the examples (.zip). Signed-in merchants also get a one-click download with a pre-filled .env on the API keys page.

cp .env.example .env        # fill in MERCHANT_ID and API_KEY
python3 playground.py        # interactive menu
python3 playground.py all    # run the whole suite (skips money-moving cases)

Need help? Contact us.