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_..."Create a payment link
Create a hosted checkout link a customer can pay from any device.
POST /v1/merchants/{merchant_id}/payment-links
{
"amount": 50000, // 500.00 in minor units
"currency": "ETB", // must be a currency you accept
"description": "Order #1042",
"reference": "INV-1042", // YOUR order/invoice id — echoed back in every
// webhook and the /verify endpoint, so you can
// reconcile against your own books
"redirect_url": "https://shop.com/order/1042/done" // optional; after the
// payment is confirmed the customer is returned here
// with ?reference=INV-1042&status=paid&collection_id=...
// appended. Must be https.
}
// → { "id": "pl_...", "reference": "INV-1042", "status": "OPEN" }
// Share https://peoplerails.com/pay/pl_...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}/payoutsWebhooks
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.resolvedThe 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>"(thetfrom the header, a literal dot, then the body) — not the body alone — and compared againstv1as 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.