If you sell anything online in Ethiopia, you know the flow: the customer pays by bank transfer or Telebirr, then sends a screenshot. Someone on your team looks at it and decides whether to ship. Screenshots are easy to edit and easy to reuse, so that decision is a guess.
I maintain cheki.et, a free, MIT-licensed receipt checker, and this post is the developer version: how to confirm a payment against the bank's own record from your backend, in a few lines of Node or Python.
One thing first, because it's the most common confusion I see. Searching "telebirr api" or "cbe api" mostly turns up payment collection: merchant APIs where you start a payment and get a callback. Those need a merchant agreement with the bank. What I'm describing is the other half. The customer has already paid, the normal way, and you want to check the receipt they sent you. That needs no contract with anyone, because Ethiopian banks and wallets publish every receipt at a public URL.
How it works
Each bank has its own receipt page: transactioninfo.ethiotelecom.et/receipt/{number} for Telebirr, mbreciept.cbe.com.et/{receiptId} for CBE, a JSON endpoint for Bank of Abyssinia, a PDF for CBE Birr, and so on. cheki fetches the right one, parses it, runs a few sanity checks (positive amount, the reference you asked for, no failed or pending status), and returns the same JSON for every bank. There are 10 banks and wallets live: CBE, Telebirr, BOA, M-Pesa, Dashen, Awash, CBE Birr, Siinqee, eBirr and ZamZam. Three of those (M-Pesa, ZamZam, CBE Birr) are marked beta because they've been checked against few or no real receipts.
One request
curl -X POST https://cheki.et/api/verify \ -H "Content-Type: application/json" \ -d '{"bank": "dashen", "reference": "B22WDTI00SAMPLE1"}' No signup and no API key. The body takes bank plus whatever that bank needs:
| Field | When |
|---|---|
reference | Almost always: a receipt link, Receipt ID, transaction number or FT Ref |
accountNumber | BOA (last 5 digits) and older CBE FT receipts (last 8 digits) |
phoneNumber | CBE Birr (the payer's phone) |
qrData | A scanned QR payload (BOA, CBE Birr, Telebirr) |
That reference is made up, so you'll get a 422 back. A confirmed receipt looks like this (names and numbers are fake):
{ "success": true, "verified": true, "bank": "Commercial Bank of Ethiopia", "bankCode": "cbe", "reference": "FT26SAMPLE01", "senderName": "ABEBE KEBEDE", "receiverName": "TIGIST ALEMU", "receiverAccount": "1****6171", "amount": 1250.5, "currency": "ETB", "date": "2026-10-01", "sourceUrl": "https://apps.cbe.com.et:100/?id=FT26SAMPLE0100006171", "cached": false } The rule that matters: only verified: true means the bank confirmed it. success: true only means the request was handled. A BOA QR that BOA couldn't confirm comes back with success: true, verified: false, decodedOnly: true, and a QR can be copied. Don't ship on that.
The error codes are what you'd expect: 400 bad input, 404 unknown bank, 413 body too large, 422 the bank has no such receipt, 429 rate limited (with Retry-After), 502 the bank couldn't be reached. A 502 for a geo-blocked bank includes a fallbackUrl, which is the bank's own receipt page.
Node: cheki-verify
npm install cheki-verify # 1.6.0, no dependencies, Node 20+ Here's the check I'd run before marking an order paid. Confirm it with the bank, then make sure it's actually this order's payment:
import { Cheki, isVerified, ChekiAPIError } from "cheki-verify"; const cheki = new Cheki(); const seen = new Set(); // use a UNIQUE column in your database instead async function checkPayment(order, bank, reference) { let r; try { r = await cheki.verify(bank, reference); } catch (e) { if (e instanceof ChekiAPIError) { // 422: the bank has no such receipt. 502 + fallbackUrl: bank not reachable from the server. return { ok: false, why: e.serverMessage ?? e.message, openAtBank: e.fallbackUrl }; } throw e; // network error or timeout: try again later, don't accept } if (!isVerified(r)) return { ok: false, why: "not confirmed by the bank" }; if (r.amount !== order.amount) return { ok: false, why: `amount is ${r.amount}` }; if (!r.receiverAccount?.endsWith(order.accountLast4)) return { ok: false, why: "paid to another account" }; const key = `${r.bankCode}:${r.reference}`; if (seen.has(key)) return { ok: false, why: "receipt already used" }; seen.add(key); return { ok: true, payer: r.senderName, date: r.date }; } console.log(await checkPayment({ amount: 1250.5, accountLast4: "6171" }, "dashen", "B22WDTI00SAMPLE1")); The last two checks are the ones people skip. A real receipt for a transfer to someone else's account passes isVerified. So does a real receipt the same customer already used last week. The bank can't know about your orders, so those checks have to live in your code.
In the TypeScript SDK, any non-2xx response throws a ChekiAPIError (with statusCode, fallbackUrl and checkedUrl), and a 429 throws ChekiRateLimitError. It retries at most once, and only where that's safe.
Python: cheki
pip install cheki # 1.6.0, needs only requests, Python 3.10+ The Python client returns a result for API errors instead of raising, so it reads as one if:
from cheki import ChekiClient with ChekiClient() as cheki: r = cheki.verify("cbe", "https://mbreciept.cbe.com.et/fHCxExampleRcpt01") if r.is_verified: print(r.sender_name, "paid", r.amount, r.currency, "to", r.receiver_account, "on", r.date) elif r.fallback_url: print("Bank not reachable from the server. Open:", r.fallback_url) else: print(f"Not verified ({r.http_status}): {r.error}") It also installs a small CLI, which is handy in a terminal: cheki-verify telebirr DET0SAMPLE --json. It exits 0 only when the bank confirmed the receipt.
End-of-day reconciliation
POST /api/verify/batch takes up to 20 receipts:
curl -X POST https://cheki.et/api/verify/batch \ -H "Content-Type: application/json" \ -d '{"receipts": [ {"bank": "dashen", "reference": "B22WDTI00SAMPLE1"}, {"bank": "boa", "reference": "FT26SAMPLE07", "accountNumber": "12345"} ]}' You get back total, verified, failed, cached and one result per receipt. Both SDKs wrap it as verifyBatch / verify_batch.
Limits and what's kept
- Server side only. The API sends no CORS headers, so call it from your backend, not the browser.
- Rate limits per IP: 30 checks a minute (each batch item counts), and 5 batch requests a minute. The SDKs export
RECOMMENDED_BATCH_CHUNK(15) for long lists. - Storage: no accounts and no database. A receipt the bank confirmed stays in the server's memory for up to six hours, so a repeat check returns
cached: truewithout asking the bank again. Failed checks are never kept.
The full reference is in the API docs, and the developer page has the Go, PHP and Dart SDKs as well.
Telebirr and M-Pesa: self-host it
Telebirr's and M-Pesa's receipt pages only answer requests from Ethiopian networks. The hosted cheki.et API runs in Frankfurt, so for those two it returns a 502 with fallbackUrl. That's fine for a person at a counter, who can open the link on their phone, but not for an automated checkout.
The fix is to run your own copy on a server in Ethiopia:
git clone https://github.com/1RB/cheki.git cd cheki docker compose up -d # serves on http://localhost:3000 Then point the SDK at it:
const cheki = new Cheki({ baseUrl: "http://your-server:3000" }); cheki = ChekiClient(base_url="http://your-server:3000") Every option is an environment variable, and all are optional. For example, CHEKI_CACHE=off turns off the in-memory cache if you'd rather keep nothing. There's a longer walkthrough in the self-hosting guide.
Other options
cheki isn't the only way to do this. check.et, verify.et and qbirr offer hosted APIs with keys and dashboards, and there are open-source libraries if you'd rather parse receipts inside your own app, such as tx-verify for Python and telebirr-receipt for Node. They all read the same public receipt pages. Pick based on which banks you need, whether you want an account and a dashboard, and whether you can run something on an Ethiopian network.
Help wanted
The weakest spots are the beta banks. If you have a real M-Pesa, ZamZam or CBE Birr receipt that cheki gets wrong, an issue on GitHub with the names and accounts redacted helps a lot. The repo has npm run probe -- <receipt-url> --redact for exactly that.
cheki.et is MIT licensed and isn't affiliated with any bank or Ethio Telecom.
Ray (@1RB on GitHub)
