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 (e.g. telebirr). 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.

GET /v1/payment-methods          // any authed caller (incl. API key)
// -> {
//   "methods": [
//     { "id": "pm_9039f594d937", "name": "telebirr",
//       "kind": "mobile_money", "customer_reference": true, "active": true }
//   ]
// }

Payouts & refunds

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

// Disburse cash to a recipient's phone (e.g. telebirr). An agent is
// assigned, sends the money, and confirms it — same rails as deposits.
POST /v1/merchants/{merchant_id}/payouts
{ "amount": 20000, "currency": "ETB", "reference": "PO-77",
  "recipient_phone": "0911223344", "recipient_name": "Abebe",
  "payment_method_id": "pm_..." }   // optional: restrict to one rail; omit = any agent
// -> 201 { "request_id": "...", "status": "PENDING", "node_id": "..." }

// 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.