api · 8 min read
Building a Payment Verification System with cheki's Free API
Complete developer guide to integrating Ethiopian bank receipt verification into your app, POS system, or e-commerce checkout with cheki's free REST API.
This guide walks through integrating cheki's free REST API into a real application. No API key, no signup; requests are rate limited per IP. We'll cover single verification, batch verification, error handling, and fraud prevention patterns.
API overview
| Endpoint | Method | Purpose |
|---|---|---|
| /api/verify | POST | Verify a single receipt |
| /api/verify/batch | POST | Verify up to 20 receipts at once |
| /api/banks | GET | List supported banks and status |
| /api/health | GET | Check API and per-bank endpoint latency |
Base URL: https://cheki.et
No authentication
cheki's API requires no API key, no Bearer token, and no signup. Just POST and get JSON. This is different from check.et which requires a business account and Authorization header.
Single verification
The most common use case: verify one receipt at a time.
curl -X POST https://cheki.et/api/verify \
-H "Content-Type: application/json" \
-d '{
"bank": "cbe",
"reference": "FT26140P01YB",
"accountNumber": "1000560536171"
}'Response:
{
"success": true,
"verified": true,
"bank": "Commercial Bank of Ethiopia",
"reference": "FT26140P01YB",
"amount": 20000,
"currency": "ETB",
"senderName": "Mr Mohammed Abdulwasi Reshid",
"senderAccount": "1****1685",
"receiverName": "SAMI ADIL ZEKARIA",
"receiverAccount": "1000560536171",
"date": "5/20/2026 7:29:00 PM",
"sourceUrl": "https://mbreciept.cbe.com.et/fHCxExampleRcpt01"
}URL-based verification
If you have a receipt URL (e.g. from a QR code or shared link), you can skip the bank field. cheki auto-detects the bank from the URL:
curl -X POST https://cheki.et/api/verify \
-H "Content-Type: application/json" \
-d '{
"reference": "https://mbreciept.cbe.com.et/fHCxyV4mg5pRIwEkJO"
}'Batch verification
For end-of-day reconciliation, verify up to 20 receipts in a single request (5 are checked at a time, and each one counts against the per-IP verify limit):
curl -X POST https://cheki.et/api/verify/batch \
-H "Content-Type: application/json" \
-d '{
"receipts": [
{"bank": "cbe", "reference": "https://mbreciept.cbe.com.et/fHCxExampleRcpt01"},
{"bank": "telebirr", "reference": "DET8FJGUJ4"},
{"bank": "boa", "reference": "AB12345678", "accountNumber": "12345"}
]
}'JavaScript integration
class ChekiAPI {
constructor(baseUrl = 'https://cheki.et') {
this.baseUrl = baseUrl;
}
async verify(bank, reference, accountNumber) {
const res = await fetch(`${this.baseUrl}/api/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bank, reference, accountNumber })
});
return res.json();
}
async verifyUrl(url) {
const res = await fetch(`${this.baseUrl}/api/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference: url })
});
return res.json();
}
async batchVerify(receipts) {
const res = await fetch(`${this.baseUrl}/api/verify/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receipts })
});
return res.json();
}
}
// Usage
const cheki = new ChekiAPI();
const result = await cheki.verify('cbe', 'FT26140P01YB', '1000560536171');
if (result.verified && result.amount === expectedAmount) {
console.log('Payment confirmed:', result.amount, result.currency);
}Python integration
import requests
class ChekiAPI:
def __init__(self, base_url='https://cheki.et'):
self.base_url = base_url
def verify(self, bank, reference, account_number=None):
payload = {'bank': bank, 'reference': reference}
if account_number:
payload['accountNumber'] = account_number
r = requests.post(f'{self.base_url}/api/verify', json=payload)
return r.json()
def verify_url(self, url):
r = requests.post(f'{self.base_url}/api/verify',
json={'reference': url})
return r.json()
# Usage
cheki = ChekiAPI()
result = cheki.verify('cbe', 'FT26140P01YB', '1000560536171')
if result.get('verified') and result.get('amount') == expected_amount:
print(f'Payment confirmed: {result["amount"]} {result["currency"]}')Error handling
The API returns structured errors. Handle these cases:
| HTTP Status | Error | Action |
|---|---|---|
| 200 | success: true, verified: true | Payment confirmed |
| 200 | success: true, verified: false, decodedOnly: true | BOA QR decoded but not confirmed by the bank. Not proof of payment |
| 400 | Missing fields or malformed reference | Check request body |
| 404 | Bank not supported (unknown id or still in development) | Check the bank id against /api/banks |
| 422 | Receipt not found, or the bank's response failed verification | Treat as unverified; ask the customer to check the reference |
| 429 | Per-IP rate limit (Retry-After header) | Wait Retry-After seconds, then retry once |
| 502 | Bank unreachable or geo-blocked (fallbackUrl for geo-blocked banks) | Retry later, or open fallbackUrl in a browser |
Fraud prevention checklist
Before releasing goods or services, run these checks:
Check verified
Accept only result.verified === true. verified: false does not always mean the receipt is fake: it can mean the bank has no such receipt, the bank's answer failed cheki's verification gate (no amount, wrong reference, failed or pending status), a BOA QR code was only decoded (decodedOnly), or the content was only parsed from what you sent (parsed, /api/parse). None of these is proof of payment.
Check amount
Compare result.amount with the expected payment amount. Reject if different.
Check receiver
Compare result.receiverAccount with your account number. Reject if different.
Check freshness
Parse result.date and reject if the payment is older than your acceptable window (e.g. 1 hour).
Check duplicates
Store result.reference in your database. Before accepting, check if it already exists.
async function verifyPayment(customerRef, expectedAmount, myAccount) {
const result = await cheki.verify('cbe', customerRef, myAccount);
if (!result.success || !result.verified)
return { ok: false, reason: result.error || 'Not verified by the bank' };
if (result.amount !== expectedAmount)
return { ok: false, reason: `Amount mismatch: ${result.amount} vs ${expectedAmount}` };
if (result.receiverAccount && !result.receiverAccount.endsWith(myAccount.slice(-5)))
return { ok: false, reason: 'Wrong receiving account' };
const paymentDate = new Date(result.date);
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
if (paymentDate < oneHourAgo)
return { ok: false, reason: 'Payment is too old' };
if (await isDuplicate(result.reference))
return { ok: false, reason: 'Duplicate receipt' };
return { ok: true, data: result };
}Self-host for production
For production use, self-host cheki with Docker. This gives you full control, eliminates dependency on cheki.et, and allows you to add authentication, rate limiting, and logging.