Payment Verification API
Verify Ethiopian and Kenyan payment receipts by reference number or receipt screenshot. Supports the Commercial Bank of Ethiopia, Telebirr, Dashen Bank, Bank of Abyssinia, CBE Birr and M-Pesa.
Overview
Every endpoint accepts JSON over HTTPS and responds with JSON. There are no SDKs to install — a plain HTTP request is all you need. Requests are stateless, so nothing is stored between calls.
Two ways to verify a payment. Either post the reference to
POST /verify and let the API work out which provider it
belongs to, or call the provider-specific endpoint directly when you already know it. The smart router is the
better default; use the dedicated endpoints when you need a provider that the router does not cover, such as
M-Pesa.
Call this API from your server, never from the browser.
Cross-origin requests are permitted, so browser fetch() will appear to work — but
it puts your API key in page source where anyone can read it. Your web page should call your own backend, and
your backend should call this API. The interactive panels on this page are the one exception, and they exist
for manual testing only.
Authentication
There are two independent headers, and confusing them is the most common integration mistake.
x-veripay-key
Your key for this API, required on every verification endpoint. Create one for free from the
dashboard after signing in with Google — the key is shown once, so store it
somewhere safe. Requests missing a key get 401; unknown or revoked keys get
403.
x-veripay-key: vp_live_…
Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining, X-Quota-Limit and
X-Quota-Remaining so you can back off before you are throttled. Exceeding either
limit returns 429.
x-api-key
Used only by CBE Birr, and it is not a key for this API. Whatever you send is forwarded
upstream as your own CBE Birr credential. A CBE Birr call on a secured server therefore carries both headers at
once. Authorization: Bearer <key> is accepted as an alternative spelling.
x-veripay-key: your-key-here x-api-key: your-cbe-birr-credential
GET /health, this documentation page and the verification
widget on the home page are public. The widget is limited per IP address and is meant for
one-off checks rather than integration.
Quick start
Verify a CBE transfer from a Node.js backend. Node 18 and later has fetch built in, so there is nothing to install.
const response = await fetch('https://payment.offsetsoftware.net/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-veripay-key': process.env.VERIPAY_KEY
},
body: JSON.stringify({
reference: 'FT2513001V2G',
suffix: '39003377'
})
});
const result = await response.json();
if (response.ok && result.success) {
console.log(`${result.payer} paid ${result.amount} ETB`);
} else {
console.error(result.error);
}
Keep your key in an environment variable rather than in source control, and always branch on
response.ok as well as the body — a failed verification can arrive with a
4xx status.
Prompt
Generate a detailed integration brief you can paste into Cursor, ChatGPT, Claude, or hand to a backend engineer. Choose the providers you need and the language you build in. The prompt includes endpoints, headers, request and response JSON, normalisation rules, and a checkout checklist. Keep your API key on the server — never in the browser.
Providers to integrate
CBE Birr needs your own x-api-key credential in addition to Veripay.
M-Pesa is not covered by the smart router — the prompt includes POST /verify-mpesa when selected.
Select at least one provider to generate a prompt.
Smart router
Detects the provider from the shape of the reference and any extra fields you send, then delegates. One endpoint covers CBE, Telebirr, Dashen, Bank of Abyssinia and CBE Birr. M-Pesa is not routed here — use its own endpoint.
Body
| Field | Type | Notes |
|---|---|---|
reference required | string | Reference number, receipt token, or full receipt URL |
suffix optional | string | 8 digits for CBE, 5 digits for Bank of Abyssinia |
phoneNumber optional | string | CBE Birr only, in 251XXXXXXXXX form |
How the reference is matched
| Provider | Reference | Extra fields |
|---|---|---|
| Dashen Bank | 16 characters, first 3 are digits | none permitted |
| CBE (legacy) | 12 characters starting FT | suffix, 8 digits |
| CBE (legacy link) | apps.cbe.com.et receipt URL | suffix read from the URL |
| CBE (new) | receipt token, or mbreciept.cbe.com.et URL | none permitted |
| Bank of Abyssinia | 12 characters starting FT | suffix, 5 digits |
| CBE Birr | 10 alphanumeric characters | phoneNumber + x-api-key |
| Telebirr | 10 alphanumeric characters | none permitted |
CBE and Bank of Abyssinia share the same reference format, so the length of suffix
is what separates them. Sending a field a provider does not expect is rejected with
400 rather than silently ignored, which keeps mismatched payloads from being
verified against the wrong bank.
Try it
Commercial Bank of Ethiopia
Accepts three input forms. A legacy FT reference needs
accountSuffix, the last 8 digits of the account. A newer receipt token or a full
mbreciept.cbe.com.et link needs nothing else.
Body
| Field | Type | Notes |
|---|---|---|
reference required | string | Legacy FT… reference, receipt token, or receipt URL |
accountSuffix | string | Required for legacy references only. 8 digits. |
Response
Returned unwrapped — the fields sit at the top level, not under data.
{
"success": true,
"payer": "Abebe Bekele",
"payerAccount": "1****4567",
"receiver": "Kebede Alemu",
"receiverAccount": "1****8901",
"amount": 1500,
"date": "2026-07-20T09:14:00.000Z",
"reference": "FT2513001V2G",
"reason": "Payment for services"
}
A GET form taking the same values as query parameters is available for quick manual
checks.
Try it
Telebirr
Body
| Field | Type | Notes |
|---|---|---|
reference required | string | 10-character receipt number, for example CE2513001X |
Response
Wrapped in data. Amounts are strings including the Birr suffix, exactly as Telebirr prints them.
{
"success": true,
"data": {
"payerName": "Abebe Bekele",
"payerTelebirrNo": "251911***456",
"creditedPartyName": "Kebede Alemu",
"creditedPartyAccountNo": "251922***789",
"bankName": "",
"transactionStatus": "Completed",
"receiptNo": "CE2513001X",
"paymentDate": "20/07/2026 09:14:11",
"settledAmount": "1,500.00 Birr",
"serviceFee": "0.0 Birr",
"serviceFeeVAT": "0.0 Birr",
"totalPaidAmount": "1,500.00 Birr",
"customerNote": "Invoice 4821"
}
}
bankName is populated only when the money went to a bank account rather than a
Telebirr wallet. A 404 means the receipt could not be found;
502 means Telebirr itself was unreachable, which is usually the
geographic restriction.
Try it
Dashen Bank
Body
| Field | Type | Notes |
|---|---|---|
reference required | string | 16-character transaction reference |
Response
Unwrapped, with a full breakdown of charges. Absent charges are omitted rather than returned as zero.
{
"success": true,
"senderName": "Abebe Bekele",
"senderAccountNumber": "1234567890",
"transactionChannel": "Mobile Banking",
"serviceType": "Fund Transfer",
"narrative": "Payment for services",
"receiverName": "Kebede Alemu",
"phoneNo": "251912345678",
"institutionName": "Dashen Bank",
"transactionReference": "3001234567890123",
"transferReference": "TRF123456",
"transactionDate": "2026-07-20T09:14:00.000Z",
"transactionAmount": 1000,
"serviceCharge": 5,
"vat": 0.75,
"exciseTax": 0,
"stampDuty": 0,
"total": 1005.75
}
Try it
Bank of Abyssinia
Body
| Field | Type | Notes |
|---|---|---|
reference required | string | Transaction reference, for example FT23062669JJ |
suffix required | string | Exactly 5 digits — the last 5 of the account |
Response
This response is nested twice. The route wraps a result that already carries its own
success flag, so the payment fields live at
data.amount, not amount. Worth special-casing in your
client.
{
"success": true,
"data": {
"success": true,
"payer": "Abebe Bekele",
"payerAccount": "12345678901",
"receiver": "Kebede Alemu",
"amount": 1500,
"date": "2026-07-20T09:14:00.000Z",
"reference": "FT23062669JJ",
"reason": "Payment for services"
}
}
A reference that cannot be verified returns 404.
Try it
CBE Birr
The only endpoint needing a second header — see Authentication.
Body
| Field | Type | Notes |
|---|---|---|
receiptNumber required | string | Receipt number from the transaction |
phoneNumber required | string | 251 followed by 9 digits, 12 in total |
Response
Unwrapped. Amounts are strings.
{
"customerName": "Abebe Bekele",
"debitAccount": "251911***456",
"creditAccount": "1000***7890",
"receiverName": "Kebede Alemu",
"orderId": "ORD123456",
"transactionStatus": "Completed",
"reference": "CBE123456",
"receiptNumber": "1234567890",
"transactionDate": "2026-07-20 09:14:00",
"amount": "1500.00",
"paidAmount": "1500.00",
"serviceCharge": "0.00",
"vat": "0.00",
"totalPaidAmount": "1500.00",
"paymentReason": "Payment for services",
"paymentChannel": "CBE Birr"
}
Try it
M-Pesa
The field is reference, not receiptNumber.
Older copies of the README document receiptNumber here; that payload is rejected
with a 400. Note that CBE Birr genuinely does use
receiptNumber, so the two endpoints differ.
Body
| Field | Type | Notes |
|---|---|---|
reference required | string | M-Pesa receipt reference |
Response
{
"success": true,
"payerName": "Abebe Bekele",
"payerAccount": "251911***456",
"receiverName": "Kebede Alemu",
"receiverAccount": "251922***789",
"transactionId": "SGH4KJ2L9M",
"receiptNo": "SGH4KJ2L9M",
"paymentDate": "2026-07-20T09:14:00.000Z",
"amount": 1500,
"serviceFee": 0,
"vat": 0
}
Try it
Receipt image
Upload a screenshot and the API identifies whether it is a CBE or Telebirr receipt and reads the reference off
it. Useful when customers send a screenshot instead of typing a reference. Sent as
multipart/form-data, not JSON.
Administrator only. Image verification requires an API key owned by a user with the
ADMIN role. Regular keys get 403. Admins can also run
this from /admin/image while signed in. The server needs
MISTRAL_API_KEY configured.
Form fields
| Field | Type | Notes |
|---|---|---|
file required | file | The receipt screenshot |
suffix | string | Needed when auto-verifying a CBE receipt |
autoVerify | query | ?autoVerify=true to verify immediately after reading |
Response without autoVerify
Reads the receipt and tells you where to send it next.
{
"type": "telebirr",
"reference": "CE2513001X",
"forward_to": "/verify-telebirr"
}
Response with autoVerify
{
"verified": true,
"type": "telebirr",
"reference": "CE2513001X",
"details": { "payerName": "Abebe Bekele", "settledAmount": "1,500.00 Birr" }
}
This endpoint does not follow the success convention used everywhere else. Success
carries verified or type, and failures return a bare
{ "error": "..." }. Check the HTTP status rather than the body. An unreadable
receipt gives 422, and a CBE receipt auto-verified without
suffix gives 400.
Uploading from Node.js
import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append('file', new Blob([await readFile('receipt.jpg')]), 'receipt.jpg');
form.append('suffix', '39003377');
const response = await fetch(
'https://payment.offsetsoftware.net/verify-image?autoVerify=true',
{ method: 'POST', headers: { 'x-veripay-key': process.env.VERIPAY_KEY }, body: form }
);
const result = await response.json();
Do not set Content-Type yourself — the runtime adds the multipart boundary.
Try it
Health check
Open to everyone, no key required. Suitable for uptime monitoring.
{ "status": "ok", "timestamp": "2026-07-20T09:14:00.000Z" }
Try it
Response shapes
Providers do not agree on where the payment fields live. Read this table before writing your parsing code — it
is the difference between result.amount and
result.data.amount.
| Endpoint | Shape | Amount lives at |
|---|---|---|
/verify-cbe | flat | result.amount |
/verify-dashen | flat | result.transactionAmount |
/verify-mpesa | flat | result.amount |
/verify-cbebirr | flat, no success on success | result.amount |
/verify-telebirr | wrapped | result.data.settledAmount |
/verify-abyssinia | wrapped twice | result.data.amount |
/verify | matches whichever provider answered | varies |
/verify-image | verified / type / details | result.details |
The smart router passes provider responses through unchanged, wrapping only those that lack a
success field. So /verify with a CBE reference returns
the flat CBE shape, and with a Telebirr reference the wrapped Telebirr shape. The
client below normalises this for you.
Status codes
| Code | Meaning | What to do |
|---|---|---|
200 | Request handled | Check the body — a verification can still have failed |
400 | Bad payload | Missing field, wrong suffix length, or a field the provider does not accept |
401 | No API key | Add x-veripay-key |
403 | Bad API key / forbidden | Key unknown, or endpoint requires an administrator key (image) |
404 | Receipt not found | Reference is wrong, or the receipt is not yet published |
422 | Found but unreadable | Receipt could not be parsed; safe to retry once |
500 | Server error | Retry with backoff; check logs if it persists |
502 | Provider unreachable | Bank or telco is down or blocking us — retry later |
A 200 does not by itself mean the payment is real. Always confirm
success === true in the body, then check that the amount and receiver match what
you expected before releasing goods.
Node.js client
A small module that adds the header, flattens the differing response shapes, and retries provider outages. Drop
it into your project as veripay.js.
const BASE_URL = 'https://payment.offsetsoftware.net';
const API_KEY = process.env.VERIPAY_KEY;
class VerificationError extends Error {
constructor(message, status, retryable) {
super(message);
this.name = 'VerificationError';
this.status = status;
this.retryable = retryable;
}
}
async function request(path, body, attempt = 1) {
const response = await fetch(BASE_URL + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-veripay-key': API_KEY },
body: JSON.stringify(body),
signal: AbortSignal.timeout(90_000)
});
const payload = await response.json().catch(() => ({}));
// 502 and 500 mean the bank or telco was unreachable, not that the
// reference is bad, so those are worth retrying.
const retryable = response.status === 502 || response.status === 500;
if (!response.ok) {
if (retryable && attempt < 3) {
await new Promise(resolve => setTimeout(resolve, attempt * 2000));
return request(path, body, attempt + 1);
}
throw new VerificationError(payload.error || response.statusText, response.status, retryable);
}
return payload;
}
// Collapses flat, wrapped and double-wrapped responses into one shape.
function normalise(payload) {
let node = payload;
while (node && typeof node.data === 'object' && node.data !== null) node = node.data;
const amount = node.amount ?? node.transactionAmount ?? node.settledAmount ?? node.totalPaidAmount;
return {
verified: payload.success !== false,
reference: node.reference ?? node.receiptNo ?? node.transactionReference,
payer: node.payer ?? node.payerName ?? node.senderName ?? node.customerName,
receiver: node.receiver ?? node.receiverName ?? node.creditedPartyName,
// Telebirr and CBE Birr report amounts as strings such as "1,500.00 Birr".
amount: typeof amount === 'string' ? Number(amount.replace(/[^\d.]/g, '')) : amount,
date: node.date ?? node.paymentDate ?? node.transactionDate,
raw: payload
};
}
export async function verify({ reference, suffix, phoneNumber }) {
return normalise(await request('/verify', { reference, suffix, phoneNumber }));
}
export async function verifyMpesa(reference) {
return normalise(await request('/verify-mpesa', { reference }));
}
export { VerificationError };
normalise walks down through any data wrappers, so it
handles Bank of Abyssinia's double nesting and the flat CBE response with the same code path. The original
response stays on raw for provider-specific fields.
Checkout example
A realistic flow. The customer pays by bank transfer, submits the reference on your site, and your backend confirms it before the order is marked paid.
import express from 'express';
import { verify, VerificationError } from './veripay.js';
const app = express();
app.use(express.json());
app.post('/orders/:id/confirm-payment', async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
if (order.status === 'paid') return res.json({ status: 'paid' });
// Reject a reference already used on another order before spending
// a verification call on it.
const seen = await db.payments.findByReference(req.body.reference);
if (seen) return res.status(409).json({ error: 'This reference was already used' });
let payment;
try {
payment = await verify({
reference: req.body.reference,
suffix: req.body.suffix,
phoneNumber: req.body.phoneNumber
});
} catch (err) {
if (err instanceof VerificationError) {
const status = err.retryable ? 503 : 400;
return res.status(status).json({ error: err.message, retryable: err.retryable });
}
throw err;
}
if (!payment.verified) {
return res.status(400).json({ error: 'Could not verify that reference' });
}
// The receipt is real, but it still has to be the right payment.
if (payment.amount < order.total) {
return res.status(400).json({
error: `Paid ${payment.amount} ETB but the order total is ${order.total} ETB`
});
}
await db.payments.create({ reference: payment.reference, orderId: order.id, amount: payment.amount });
await db.orders.markPaid(order.id);
res.json({ status: 'paid', amount: payment.amount, payer: payment.payer });
});
Store every verified reference and reject repeats. Verification proves a payment happened; it does not prove the payment was for your order. Without a uniqueness check, one genuine receipt can be replayed against any number of orders. Checking the amount and the receiving account matters just as much.
Operational notes
Response times
Verification is a live fetch from the bank or telco, so responses take seconds rather than milliseconds. M-Pesa allows up to 60 seconds internally, and legacy CBE receipts may fall back to a headless browser that adds roughly 25. Allow at least 90 seconds of client timeout, and never block a page render on the call — verify in a background job or on form submission.
Regional restrictions
Telebirr and M-Pesa block requests from foreign data centres. When this API runs outside Ethiopia those two
providers rely on relay proxies hosted inside the country, which makes them slower and occasionally return
502. CBE, Dashen, Bank of Abyssinia and CBE Birr are unaffected.
Retries
Retry 500 and 502 with backoff, since both usually mean a
temporarily unreachable provider. Never retry 400, 401,
403 or 404 — the outcome will not change. Verification is
read-only, so retrying is always safe.
What this API is not
It reads publicly available receipt pages published by the providers. It is not an official integration, it does not move money, and it is not affiliated with any bank or telecom operator. Treat a verification as strong evidence of payment rather than a settlement guarantee, and reconcile against your bank statements for anything high value.