Documentation
API reference
Base URL: https://verify.qbirr.com
Stable since v1. Backwards-compatible changes are added regularly; breaking changes get a new path prefix.
Authentication
Every request needs your API key in the X-API-Key header. The key looks like abc12345.LONGRANDOMSECRET — send the whole string.
POST /api/v1/verify
Verifies one transaction. Returns verified: true only if the receipt is real, the amount matches, and the receiver name matches.
curl -X POST https://verify.qbirr.com/api/v1/verify \
-H "X-API-Key: $QBIRR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "cbe",
"ref": "FT23001234ABC",
"amount": 500,
"receiver_name": "YOUR FULL NAME",
"receiver_account": "1000123456789"
}'
import { Qbirr } from '@qbirr/sdk';
const qb = new Qbirr({ apiKey: process.env.QBIRR_API_KEY });
const r = await qb.verify({
provider: 'cbe',
ref: 'FT23001234ABC',
amount: 500,
receiverName: 'YOUR FULL NAME',
receiverAccount: '1000123456789',
});
console.log(r.verified ? '✓ ' + r.payer : '✗ ' + r.error);
from qbirr import Qbirr
qb = Qbirr(api_key=os.environ["QBIRR_API_KEY"])
r = qb.verify(
provider="cbe",
ref="FT23001234ABC",
amount=500,
receiver_name="YOUR FULL NAME",
receiver_account="1000123456789",
)
print(r.verified, r.payer)
use Qbirr\Qbirr;
$qb = new Qbirr($_ENV['QBIRR_API_KEY']);
$r = $qb->verify([
'provider' => 'cbe',
'ref' => 'FT23001234ABC',
'amount' => 500,
'receiver_name' => 'YOUR FULL NAME',
'receiver_account' => '1000123456789',
]);
echo $r['verified'] ? "✓ {$r['payer']}" : "✗ {$r['error']}";
qb := qbirr.New(os.Getenv("QBIRR_API_KEY"))
r, err := qb.Verify(ctx, qbirr.VerifyRequest{
Provider: qbirr.ProviderCBE,
Ref: "FT23001234ABC",
Amount: 500,
ReceiverName: "YOUR FULL NAME",
ReceiverAccount: "1000123456789",
})
if err != nil { panic(err) }
fmt.Println(r.Verified, r.Payer)
Request fields
| Field | Type | Notes |
|---|---|---|
| provider | string | cbe · telebirr · abyssinia · dashen · mpesa · awash · ebirr · zamzam |
| ref | string | The reference, or the whole SMS you received, or the receipt link. All three work — we extract the reference ourselves. For awash the SMS URL or its token; for ebirr the URL or "tenant/token" |
| amount | number | Expected amount in ETB. Overpayment OK; underpayment up to 10 ETB tolerated |
| receiver_name | string | Optional — but send it. Without an identity we can only confirm the reference exists and the amount matches, not who received the money. We check the receipt against this and report receiver_source accordingly |
| receiver_account | string | Required for cbe and abyssinia when you have not saved the account in your dashboard |
Response
{
"verified": true,
"payer": "JANE DOE",
"amount": 500,
"paid_amount": 500,
"error": "",
"retryable": false,
"code": null,
"receiver_source": "settings"
}
HTTP is 200 whether the payment verified or not — always read verified, never the status code.
The three fields that stop you rejecting honest payments
| Field | What it means |
|---|---|
| retryable | If this is true, do not reject the order. It means we could not reach the bank, so verified: false means not checked yet — not invalid. Retry, or fall back to manual review. Only false is a verdict on the payment. |
| code | A stable, machine-readable reason. Branch on this instead of parsing error — see the table below. |
| paid_amount | The amount the receipt actually shows. On a short payment, compare it to amount to see what arrived — 0 when we could not read it. |
| receiver_source | Which receiver identity was actually checked. "settings" — your saved account in the dashboard (strongest). "request" — the receiver_name/receiver_account you sent. "none" — nothing was checked. A verified: true with "none" confirms the reference exists and the amount matches, but says nothing about who received the money. |
Reason codes
| code | retryable | Meaning |
|---|---|---|
| RECEIPT_NOT_FOUND | false | The bank answered and has no such reference. |
| AMOUNT_MISMATCH | false | The receipt exists but the amount does not match. Read paid_amount. |
| RECEIVER_MISMATCH | false | The receipt is made out to someone else. |
| RECEIPT_EXPIRED | false | Older than the age limit, if your account has one configured. |
| RECEIPT_UNREADABLE | false | We could not read the receipt the bank returned. |
| REFERENCE_INVALID | false | The reference/link/token is not a shape we can look up. Read error — it says exactly what to paste. |
| RECEIVER_ACCOUNT_REQUIRED | false | This provider needs your account number to look the reference up. |
| UNSUPPORTED_INPUT | false | That input form is not supported for this provider. |
| VERIFICATION_FAILED | false | Unclassified. Still not verified, but no better reason. |
| DUPLICATE_REFERENCE | false | This payment was already verified for your account, in whatever form it was submitted. |
| UPSTREAM_UNAVAILABLE | true | Not a verdict. We could not reach the bank. Retry — do not reject the order. |
The request-level failures (MISSING_API_KEY, INVALID_API_KEY, ACCOUNT_SUSPENDED, NO_PLAN, PLAN_EXPIRED, QUOTA_EXCEEDED, INVALID_REQUEST, REQUEST_TOO_LARGE) also carry a code and come with a non-200 status.
Sandbox (test without a bank)
Any ref starting with TEST- returns a canned result without contacting a bank. It does not consume your quota, and the response carries "sandbox": true so you can never mistake a fixture for a real verdict. Use it to build and test your integration before a single real payment arrives.
# always verifies
curl -X POST https://qbirr.com/api/v1/verify -H "X-API-Key: $QBIRR_API_KEY" \
-d '{"provider":"cbe","ref":"TEST-OK","amount":500}'
# and one for each failure you should handle
TEST-EXPIRED -> RECEIPT_EXPIRED
TEST-WRONG-AMOUNT -> AMOUNT_MISMATCH
TEST-NOT-FOUND -> RECEIPT_NOT_FOUND
TEST-WRONG-RECEIVER -> RECEIVER_MISMATCH
TEST-DUPLICATE -> DUPLICATE_REFERENCE
GET /api/v1/usage
Returns the current calendar month's quota status.
curl https://verify.qbirr.com/api/v1/usage -H "X-API-Key: $QBIRR_API_KEY"
{
"used": 124,
"limit": 1000,
"remaining": 876,
"plan": "Starter",
"period_start": "2026-05-01T00:00:00Z",
"period_end": "2026-06-01T00:00:00Z"
}
HTTP status codes
| Code | Meaning |
|---|---|
| 200 | Processed. Check verified — could be true or false |
| 400 | Malformed request |
| 401 | Missing or invalid API key |
| 402 | No active plan or plan expired |
| 403 | Account suspended |
| 429 | Quota or per-IP rate limit (60 req/min) |
| 413 | Body or upload too large |
Provider notes
CBE cbe
Commercial Bank of Ethiopia.
Requires receiver_account.
Telebirr telebirr
Ethio Telecom mobile money.
Geo-blocked to Ethiopian IPs — handled automatically by our relay.
Awash Bank awash
Private bank.
Paste the SMS URL or just the URL token as ref.
Dashen Bank dashen
Super App.
M-Pesa mpesa
Safaricom Ethiopia.
Geo-blocked to Ethiopian IPs — handled automatically by our relay.
Bank of Abyssinia abyssinia
Private bank.
Requires receiver_account.
eBirr ebirr
COOPay · KAAFI · Nib · Wegagen · Ahadu.
Paste the URL or "tenant/token" as ref.
ZamZam Bank zamzam
Private bank.
CBE Birr cbebirr
CBE mobile wallet.
Paste the whole SMS, the receipt link …/aureceipt?TID=…&PH=…, or just the Txn ID.
Official SDKs
Node.js
Node 18+ · zero deps
npm install git+https://git.qbirr.com/admin/sdk-node.git
Python
Python 3.8+ · requests
pip install git+https://git.qbirr.com/admin/sdk-python.git
PHP
PHP 7.4+ · curl + json
composer require git+https://git.qbirr.com/admin/sdk-php.git
Go
Go 1.21+ · stdlib only
go get git.qbirr.com/admin/sdk-go@v1.0.0
For Ruby, .NET, Java, Rust, or anything else — see the cURL example above; it's the same HTTP shape everywhere.
Best practices
- Verify server-side. Never put your API key in browser/mobile code.
- Treat
retryable: trueas "no answer", not "no payment". If we could not reach the bank, retry — or fall back to manual review. Never reject an order on aUPSTREAM_UNAVAILABLE. - Store the ref on the order. qbirr enforces uniqueness, but checking on your side too prevents accidental double-verifies.
- Beware retrying a call that already succeeded. If a request times out after we processed it, the retry comes back as
verified: falsewith codeDUPLICATE_REFERENCE— because the reference is already used for your account. Check forDUPLICATE_REFERENCEbefore treating that as a failed payment, and look up the payment in your dashboard. Per-request idempotency keys are not yet available. - Don't auto-refund on
verified: false— false negatives are possible if a provider's receipt page is briefly down. - Cache the verified result in your order record. Don't re-call
/verifyfor the same payment. - Read
receiver_sourcewhile you're integrating. If it says"none", you have no identity check at all — sendreceiver_name, or save your account under Pay-by-link & QR.