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.

https://payment.offsetsoftware.net

Keys for the interactive examples

Entered keys are held in memory for this page only — they are never stored in your browser or sent anywhere except this API.

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.

POST /verify x-veripay-key

Body

FieldTypeNotes
reference requiredstringReference number, receipt token, or full receipt URL
suffix optionalstring8 digits for CBE, 5 digits for Bank of Abyssinia
phoneNumber optionalstringCBE Birr only, in 251XXXXXXXXX form

How the reference is matched

ProviderReferenceExtra fields
Dashen Bank16 characters, first 3 are digitsnone permitted
CBE (legacy)12 characters starting FTsuffix, 8 digits
CBE (legacy link)apps.cbe.com.et receipt URLsuffix read from the URL
CBE (new)receipt token, or mbreciept.cbe.com.et URLnone permitted
Bank of Abyssinia12 characters starting FTsuffix, 5 digits
CBE Birr10 alphanumeric charactersphoneNumber + x-api-key
Telebirr10 alphanumeric charactersnone 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.

POST /verify-cbe x-veripay-key

Body

FieldTypeNotes
reference requiredstringLegacy FT… reference, receipt token, or receipt URL
accountSuffixstringRequired 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

POST /verify-telebirr x-veripay-key

Body

FieldTypeNotes
reference requiredstring10-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

POST /verify-dashen x-veripay-key

Body

FieldTypeNotes
reference requiredstring16-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

POST /verify-abyssinia x-veripay-key

Body

FieldTypeNotes
reference requiredstringTransaction reference, for example FT23062669JJ
suffix requiredstringExactly 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.

POST /verify-cbebirr x-veripay-key x-api-key

Body

FieldTypeNotes
receiptNumber requiredstringReceipt number from the transaction
phoneNumber requiredstring251 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

POST /verify-mpesa x-veripay-key

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

FieldTypeNotes
reference requiredstringM-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.

POST /verify-image x-veripay-key ADMIN key

Form fields

FieldTypeNotes
file requiredfileThe receipt screenshot
suffixstringNeeded when auto-verifying a CBE receipt
autoVerifyquery?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

GET /health

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.

EndpointShapeAmount lives at
/verify-cbeflatresult.amount
/verify-dashenflatresult.transactionAmount
/verify-mpesaflatresult.amount
/verify-cbebirrflat, no success on successresult.amount
/verify-telebirrwrappedresult.data.settledAmount
/verify-abyssiniawrapped twiceresult.data.amount
/verifymatches whichever provider answeredvaries
/verify-imageverified / type / detailsresult.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

CodeMeaningWhat to do
200Request handledCheck the body — a verification can still have failed
400Bad payloadMissing field, wrong suffix length, or a field the provider does not accept
401No API keyAdd x-veripay-key
403Bad API key / forbiddenKey unknown, or endpoint requires an administrator key (image)
404Receipt not foundReference is wrong, or the receipt is not yet published
422Found but unreadableReceipt could not be parsed; safe to retry once
500Server errorRetry with backoff; check logs if it persists
502Provider unreachableBank 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.