Verify payments API
A single REST endpoint that confirms Ethiopian transactions against the institution's own record. Send a transaction reference or a full receipt URL - with a bank code where it can't be auto-detected - to receive a normalized verification object that's identical across CBE, Telebirr, M-Pesa, and every other supported provider.
Verified transactions are cached: repeat lookups are served instantly from the database, and the confirmation object tracks how many times a transaction has been independently verified.
Authentication
/v1/verify works without any credentials - anonymous calls are permitted for evaluation, subject to a stricter rate limit (see Rate limits). Every other endpoint (saved accounts, token management) requires a bearer token.
Create an account at POST /auth/register, or exchange existing credentials at POST /auth/login - both return a personal access token in the response body. Send it on every subsequent request:
Passing a token to /v1/verify is optional but removes the anonymous rate limit entirely, and attributes the verification to your account in /auth/tokens's usage history.
Verifies a single transaction. Accepts either a query string (GET) or a JSON body (POST) - same parameters either way. Supply either url or reference; the rest are optional enrichments.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | conditional | Full bank receipt URL. Bank is auto-detected from the hostname unless bank is also given. Required unless reference is supplied. |
| reference | string | conditional | Transaction / FT number. Required unless url is supplied. Auto-detectable only for cbe (FT-prefixed), telebirr, and zemen - every other bank needs the bank parameter alongside it. |
| bank | string | conditional | Institution code - cbe, cbemobile, cbebirr, boa, mpesa, awash, dashen, telebirr, zemen, wegagen, nib, siinqee, abay, kaafiebirr, zamzam. See Supported banks. |
| phone | string | conditional | Required when bank=cbebirr and reference is used (ignored for a url lookup). |
| suffix | string | optional | Appended to reference to complete the full transaction ID. Only used for cbe and boa; ignored otherwise. |
| settlement_account | string | optional | Account number or phone the payment should have landed in. Adds a settlement_match object to the response. |
| expected_amount | number | optional | If set, the result flags any mismatch against the settled amount in amount_match. |
Try it
The verification object
Every successful call returns the same top-level shape, regardless of the underlying institution. The data object holds bank-specific fields.
| Field | Type | Description |
|---|---|---|
| valid | boolean | true only when the transaction exists and has settled. |
| bank | string | Echo of the institution code that was checked. |
| status | enum | verified · invalid · pending. |
| cached | boolean | true if served from database cache rather than fetched live. |
| data | object | Bank-specific transaction fields. See Data fields below. |
| confirmation | object | Present on cached responses. Tracks how many times the transaction has been independently verified. |
| settlement_match | object | Present when settlement_account was provided. Indicates whether the receiver matches. |
| amount_match | object | Present when expected_amount was provided. Compares expected vs. settled amounts. |
Response format
Verified transaction
{ "valid": true, "status": "verified", "bank": "boa", "cached": false, "data": { "sender_name": "MUBAREK SEID JUHAR", "sender_account": "1******77", "receiver_name": "ELIAS WELDU REZAKUM", "receiver_account": "1**********08", "receiver_bank": "Commercial Bank of Ethiopia", "sent_amount": "10.00 ETB", "service_charge": "0.06 ETB", "tax_vat": "0.01 ETB", "total_paid": "10.07 ETB", "transfer_date": "14/06/26 12:46", "transaction_id": "FT26166XLKCN", "transaction_type": "Other Bank Transfer", "reason": "P2P Transaction" } }
Pending (bank temporarily unreachable)
When the bank API times out, a soft pending state is returned instead of a hard 502 error. No credits are deducted; clients should retry.
{ "valid": false, "status": "pending", "bank": "cbe", "cached": false, "data": { "error": "Bank service is temporarily unreachable. Please try again in a moment." } }
Invalid transaction
Not-found or malformed transactions are still a 200 OK - the bank was reachable, it just doesn't recognize the reference. status is invalid and valid is false.
{ "valid": false, "status": "invalid", "bank": "boa", "cached": false, "data": { "transaction_id": "", "error": "Invalid reference number" } }
Cached response
When a transaction was previously verified and stored, the data object is identical but cached is true and a confirmation object is added.
{ "valid": true, "status": "verified", "bank": "boa", "cached": true, "data": { ... }, "confirmation": { "confirmed_count": 4, "first_confirmed_at": "2026-06-01T09:12:00+03:00", "last_confirmed_at": "2026-06-15T14:33:00+03:00" } }
Settlement match
When settlement_account is provided, the response includes a settlement_match object. Match types: exact · suffix · masked_pattern · phone_normalised · unmatched.
{ "valid": true, "data": { ... }, "settlement_match": { "matched": true, "match_type": "phone_normalised", "match_confidence": "high" } }
X-Verify-Cache header
Every response carries an X-Verify-Cache header indicating whether the result came from the live upstream or the local database cache.
X-Verify-Cache: HIT // served from database cache X-Verify-Cache: MISS // fetched live from the bank
Data fields
All fields live inside data. Not every field is present for every bank - omitted fields are absent from the object rather than null.
| Field | Type | Banks | Description |
|---|---|---|---|
| sender_name | string | All | Full name of the payer / debit account holder. |
| sender_account | string | All | Masked or partial account number of the sender. |
| receiver_name | string | All | Full name of the beneficiary / credit account holder. |
| receiver_account | string | All | Masked or partial account / phone of the receiver. |
| receiver_bank | string | awash, boa, cbe | Destination bank name (inter-bank transfers). |
| sent_amount | string | All | Amount credited to the receiver, with currency suffix. |
| service_charge | string | All | Bank service / commission fee. |
| tax_vat | string | All | 15% VAT on the service charge. |
| total_paid | string | All | Total debited from sender including fees. |
| transfer_date | string | All | Transaction timestamp. Format varies by bank. |
| transaction_id | string | All | Unique transaction reference used for caching and deduplication. |
| transaction_type | string | boa, cbemobile, dashen, awash | Type of transaction (transfer, topup, etc.). |
| reason | string | cbe, awash, boa, telebirr, mpesa | Payment reason or narrative. |
| channel | string | telebirr, cbebirr | Payment channel used. |
| invoice_no | string | zemen | Invoice number. |
| receipt_number | string | cbebirr | Receipt number. |
| order_id | string | cbebirr | Order ID. |
| receipt_no | string | mpesa | Receipt number. |
| service_type | string | dashen | Service type label. |
| narrative | string | dashen | Free-text narrative. |
| transfer_ref | string | dashen | Secondary transfer reference. |
| error | string | All (when invalid) | Human-readable reason when valid is false. |
| confirmation.confirmed_count | integer | All cached txns | Number of times this transaction has been verified. |
| confirmation.first_confirmed_at | datetime | All cached txns | ISO 8601 timestamp of the first verification. |
| confirmation.last_confirmed_at | datetime | All cached txns | ISO 8601 timestamp of the most recent verification. |
| settlement_match.matched | boolean | With settlement_account | true if the receiver account matches. |
| settlement_match.match_type | string | With settlement_account | exact · suffix · masked_pattern · phone_normalised · unmatched. |
| settlement_match.match_confidence | string | With settlement_account | high or none. |
Supported institutions
Pass the bank code explicitly, or omit it and let url/reference auto-detect it where possible. Methods: reference = transaction reference, link = full receipt URL.
| Institution | Code | Methods |
|---|---|---|
|
CBE
Commercial Bank of Ethiopia
|
cbe | reference · link |
|
CBE-M
CBE Mobile Receipt
|
cbemobile | link · reference |
|
CB
CBE Birr
|
cbebirr | reference (+ phone) · link |
|
BoA
Bank of Abyssinia
|
boa | reference · link |
|
TB
Telebirr
|
telebirr | reference · link |
|
MP
M-Pesa Ethiopia
|
mpesa | reference · link |
|
DS
Dashen Super App
|
dashen | link only |
|
AW
Awash Bank
|
awash | link only |
|
WG
Wegagen Bank
|
wegagen | link only |
|
ZB
Zemen Bank
|
zemen | reference · link |
|
NIB
NIB International Bank
|
nib | reference |
|
SB
Siinqee Bank
|
siinqee | reference |
|
AB
Abay Bank
|
abay | reference |
|
KE
Kaafi Ebirr
|
kaafiebirr | reference |
|
ZZ
Zamzam Bank
|
zamzam | reference · link |
Rate limits
Only /v1/verify is rate-limited - every other endpoint is unlimited.
| Tier | Limit | Scope |
|---|---|---|
| Anonymous (no bearer token) | 10 requests / hour | Per IP address |
| Authenticated (any valid token) | No limit | - |
Cached lookups still count against the anonymous limit. When exceeded, the API returns 429 Too Many Requests with a JSON body explaining the limit - no separate Retry-After header is set, so read error in the response rather than relying on headers.
Request examples
Errors
Most verification outcomes - including invalid or not-found transactions - are still a 200 OK with valid: false; check status and data.error rather than the HTTP code. These are the actual non-200 responses across the API:
| HTTP | Meaning | Description |
|---|---|---|
| 401 | Unauthenticated | Missing or invalid bearer token on an endpoint that requires one - everything except /v1/verify, /auth/register, and /auth/login. |
| 404 | Not found | Token ID or saved-account ID in the URL doesn't belong to the caller. |
| 422 | Validation error | On /v1/verify: neither url nor reference given, bank couldn't be auto-detected, or cbebirr is missing phone. On /auth/*: standard Laravel field validation (e.g. email already taken, password confirmation mismatch, wrong login credentials). |
| 429 | Rate limited | /v1/verify only. Anonymous callers exceeded 10 requests/hour - see Rate limits. |
| 502 | Parse error | /v1/verify only. The bank API returned something unparseable. Non-retriable - a different reference/URL is needed. |
PDF receipts (CBE, CBE Birr, Dashen, Zemen, M-Pesa) are parsed with smalot/pdfparser - a pure-PHP library with no system dependencies.
Register & login
Both endpoints are public and return a personal access token to use as a bearer token on every other endpoint.
Register
{ "name": "Abebe Kebede", "email": "abebe@example.com", "password": "secret123", "password_confirmation": "secret123" }
{ "user": { "id": 1, "name": "Abebe Kebede", "email": "abebe@example.com" }, "token": "LNjMOFktw2rGV5F8u7uAxxxxxxxxxxxxxxxxxxxx" }
Log in
Body: email, password. Returns the same { user, token } shape as register, or 422 with "The provided credentials are incorrect."
Current user
Returns { id, name, email } for the authenticated token.
Log out
Revokes the token used on the request. Returns { "message": "Logged out." }.
Personal access tokens
Issue additional tokens (e.g. one per device or integration) without re-authenticating. All three routes require a bearer token.
| Method | Path | Description |
|---|---|---|
| GET | /auth/tokens | List this user's tokens - id, name, last_used_at, created_at, expires_at (no plaintext token value). |
| POST | /auth/tokens | Body: { "name": "my-device" }. Returns 201 with the plaintext token - shown only this once. |
| DELETE | /auth/tokens/{id} | Revokes one token by id. 404 if it doesn't belong to the caller. |
Saved accounts
A user's own address book of beneficiary accounts - handy for pre-filling settlement_account on repeat payments. All routes require a bearer token and are scoped to the caller; there's no cross-user access.
| Method | Path | Description |
|---|---|---|
| GET | /accounts | List saved accounts, sorted by name. |
| POST | /accounts | Body: name, account_number, provider (all required). Returns 201. |
| PUT | /accounts/{id} | Same fields, all optional - only supplied ones are updated. |
| DELETE | /accounts/{id} | Removes the saved account. 404 if it doesn't exist for this caller. |
[ { "id": 3, "name": "Rent", "account_number": "1000123456789", "provider": "cbe", "created_at": "2026-06-01T09:12:00.000000Z" } ]