If you sell online in Ethiopia, you know the flow. The customer pays by bank transfer or Telebirr, sends a screenshot, and someone on your team squints at it and decides whether to ship.
That decision is a guess. Screenshots are easy to edit and easy to reuse.
TL;DR: you can check the payment against the bank's own record with one free API call. No API key, no signup, no contract with the bank. Here's how, in Node or Python, plus the two checks most people forget.
First, which "Telebirr API" do you mean?
Search "telebirr api" or "cbe api" and you'll mostly find payment collection: APIs where you start a payment and wait for a callback. Those need a merchant agreement with the bank.
This post is about the other half: payment verification. The customer already paid the normal way, and you want to confirm the receipt they sent. That needs no agreement with anyone, because Ethiopian banks and wallets publish every receipt at a public URL.
How it works
Every bank publishes receipts differently: an HTML page for Telebirr, a link for CBE, a JSON endpoint for Bank of Abyssinia, a PDF for CBE Birr. cheki.et, a free and open-source checker, fetches the right one, reads it, sanity-checks it (positive amount, matching reference, not failed or pending) and gives you the same JSON shape for every bank.
10 banks and wallets are live: CBE, Telebirr, BOA, M-Pesa, Dashen, Awash, CBE Birr, Siinqee, eBirr and ZamZam. M-Pesa, ZamZam and CBE Birr are marked beta, as they're confirmed on few or no real receipts so far.
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 one rule to remember: 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+
Before marking an order paid, ask two questions: did the bank confirm it, and is it 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 money sent to someone else passes isVerified. So does a real receipt reused from last week. The bank knows nothing about your orders, so “confirmed” isn't the same as “paid to you, for this order”. That part lives 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 from Ethiopian networks. The hosted API runs in Frankfurt, so for those two it returns a 502 with a fallbackUrl. A person at a counter can just tap that link on their phone. An automated checkout can't.
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.
Why cheki is free, and why that matters
The banks publish every receipt for free, yet checking them is often sold back to businesses as API keys, monthly plans and per-check quotas. cheki started from a simple idea: you shouldn't pay to read a page your own bank already made public.
So it's MIT licensed: the web app, the API, every bank's parser and the TypeScript, Python, Go, PHP and Dart SDKs all live in one repo at github.com/1RB/cheki. Read exactly how a receipt is checked, self-host it, or send a fix.
For a tool like this, open source is practical. Banks change their receipt pages without warning, and developers like you are usually the first to notice. Whoever spots a broken parser can patch it, and new banks get added the same way, via the add-a-bank guide.
cheki isn't affiliated with any bank or Ethio Telecom.
A screenshot is a claim. verified: true is the proof.
Top comments (0)