Verify Ethiopian payments from your own backend with one REST API. Synchronous by design: you POST a reference, you get the verdict in the response.
evk_) is issued and shown once.POST /v1/auth/otp/request {"phone"} then POST /v1/merchant with {"business_name","phone","otp_code","password"} (email optional). Everything after signup uses only the API key — no OTP.POST /v1/merchant/api-keys.X-API-Key header. Keep keys server-side only.ETHIOVERIFY_BASE_URL=https://your-host/v1 # self-hosted; locally: http://localhost:8000/v1
ETHIOVERIFY_API_KEY=evk_your_key_here
One call, synchronous answer. Typical latency is 1–10 s (we're fetching the provider's live receipt), so use a client timeout of at least 60 s.
curl -X POST "$ETHIOVERIFY_BASE_URL/verify" \
-H "X-API-Key: $ETHIOVERIFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reference": "DG28H170UI", "expected_amount": 1500.00}'
The response tells you everything at once — verdict, normalized transaction, fraud assessment, settlement match and how many times this receipt has been verified before:
{
"outcome": "verified",
"transaction": {
"provider": "telebirr", "verified": true, "status": "SUCCESS",
"reference": "DG28H170UI", "receipt_number": "DG28H170UI",
"amount": 1500.0, "currency": "ETB",
"sender": {"name": "Abebe Kebede", "account": "2519****1234"},
"receiver": {"name": "Almaz Mart", "account": "2519****9876"},
"timestamp": "2026-06-28T15:04:12", "metadata": {}
},
"fraud": {"risk_score": 0, "risk_level": "LOW", "signals": ["…"]},
"settlement_match": true,
"verification_count": 1,
"latency_ms": 1412.7,
"request_id": "…"
}
outcome == "verified" and your own risk tolerance (e.g. reject HIGH/CRITICAL). A CRITICAL score already flips the outcome to rejected_fraud automatically.| Provider | Required fields |
|---|---|
telebirr | reference (10-char transaction number) |
cbe | reference (FT…) + account_suffix — the last 8 digits of either account |
boa | reference + account_suffix — the last 5 digits of the account (or receipt_url) |
dashen / awash / zemen | reference, or better receipt_url (the link the bank's app shares) |
Omit provider and the smart router detects it from the reference format. Pass provider explicitly when you already know the bank — BOA and CBE references can both start with FT.
Configure once with POST /v1/webhook; we push events to your backend as they happen:
| Event | Fires when |
|---|---|
transaction_verified | a verification succeeds |
verification_failed | a lookup fails (not found / provider error) |
fraud_detected | risk level is HIGH or CRITICAL |
duplicate_transaction | a receipt number is seen on more than one transaction |
provider_down | a provider health probe fails |
Deliveries are signed: X-EthioVerify-Signature is the HMAC-SHA256 of the raw body using the webhook secret from signup. Failed deliveries retry up to 5 times with exponential backoff (30 s → 10 min).
Base URL https://your-host/v1 · Live interactive docs at /docs (Swagger) · Machine-readable spec at /openapi.json
| Method | Header | For |
|---|---|---|
| API key | X-API-Key: evk_… | server-to-server integrations |
| JWT bearer | Authorization: Bearer … (from POST /v1/auth/login) | web sessions, 60-min expiry |
Every response carries X-Request-ID for tracing; rate-limited endpoints add X-RateLimit-Remaining. Send X-Correlation-ID to stitch our logs to yours.
/verifyVerify one payment. Body: reference (required), provider, expected_amount, account_suffix, receipt_url. Returns the full result synchronously (see Getting started).
/bulk-verifyBody: {"items": [ …up to 100 verify requests… ]}. Items run concurrently (10 at a time); returns an array of results in the same order.
/transaction/{id}A stored canonical transaction by internal id.
/history?limit=50&offset=0Your verification attempts: outcome, risk score/level, settlement match, IP, device, latency, timestamp. Admin and auditor roles see all merchants.
/statsAggregates: totals, verified/failed, fraud-flagged, per-provider counts, average latency.
/auth/otp/requestSend a phone-verification code (no auth): {"phone": "09…"}. Required once before signup; the code is delivered over Telegram and expires in ~10 min.
/merchantSelf-service signup (no auth). Body: business_name, phone, otp_code (from /auth/otp/request), password; email optional. Returns merchant_id, first API key (once) and your webhook secret.
/auth/loginExchange {"phone","password"} for a 60-min JWT bearer token (for web sessions; server-to-server integrations use the API key instead).
/merchant/api-keysIssue an additional key. The full key appears only in this response.
/merchant/settlement-accounts · GET/merchant/settlement-accountsRegister / list receiving accounts: {"provider": "telebirr", "account_number": "2519…", "account_name": "…"}. Once registered, every result includes settlement_match and mismatches raise a fraud signal.
/webhookSet your callback: {"url": "https://…", "events": ["transaction_verified", "fraud_detected"]}. Omit events to subscribe to all.
/providers · /providers/healthProvider catalog with reference formats; live health probes each provider's receipt endpoint concurrently.
/healthLiveness probe — no auth.
| outcome | Meaning |
|---|---|
verified | Provider record confirms the payment |
not_found | No receipt exists for that reference |
rejected_fraud | Receipt exists but risk scored CRITICAL |
parse_error | Receipt retrieved but its layout changed — report it to us |
provider_error | Provider unreachable; safe to retry |
All errors share one envelope: {"error": "…", "code": "…", "request_id": "…", "context": {}}
| HTTP | code |
|---|---|
| 401 | authentication_failed |
| 403 | forbidden (role lacks scope) |
| 404 | receipt_not_found · provider_not_found |
| 422 | validation_error · receipt_parse_error · provider_detection_failed |
| 429 | rate_limited — default 120 requests/min per account, raisable per merchant |
| 502 / 503 | provider_unavailable · circuit_open (provider temporarily disabled after repeated failures — retry after ~30 s) |
| Role | Can |
|---|---|
merchant | verify, read own history/stats, manage keys, webhook, settlement accounts |
developer | verify + read own history/stats |
auditor | read-only across the platform, incl. audit trail |
admin | everything, incl. the operator panel |
ethiopayverifypip install ethiopayverify
# Synchronous from ethiopayverify import VerifyClient client = VerifyClient(api_key="evk_...", base_url="https://your-host/v1") result = client.verify(provider="telebirr", reference="DG28H170UI", expected_amount=1500.00) if result.verified and result.fraud.risk_level in ("LOW", "MEDIUM"): ship_the_order(result.transaction.reference) # CBE needs the account suffix result = client.verify(provider="cbe", reference="FT25211G11JQ", account_suffix="21827223") # Bulk results = client.bulk_verify([ {"reference": "DG28H170UI"}, {"reference": "FT25211G11JQ", "provider": "cbe", "account_suffix": "21827223"}, ])
# Async (httpx)
from ethiopayverify import AsyncVerifyClient
async with AsyncVerifyClient(api_key="evk_...", base_url="https://your-host/v1") as client:
result = await client.verify(reference="DG28H170UI")
Errors raise typed exceptions: AuthenticationError (401), RateLimitError (429), ApiError (everything else, with .code and .request_id).
const res = await fetch(process.env.ETHIOVERIFY_BASE_URL + "/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.ETHIOVERIFY_API_KEY,
},
body: JSON.stringify({ reference: "DG28H170UI", expected_amount: 1500 }),
signal: AbortSignal.timeout(60000),
});
const result = await res.json();
if (result.outcome === "verified") { /* fulfil the order */ }
$ch = curl_init(getenv("ETHIOVERIFY_BASE_URL") . "/verify");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: " . getenv("ETHIOVERIFY_API_KEY"),
],
CURLOPT_POSTFIELDS => json_encode([
"reference" => "DG28H170UI",
"expected_amount" => 1500.00,
]),
]);
$result = json_decode(curl_exec($ch), true);
if (($result["outcome"] ?? "") === "verified") { /* fulfil the order */ }
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/ethioverify",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body; // Buffer — keep it raw
const signature = req.header("X-EthioVerify-Signature");
const expected = crypto
.createHmac("sha256", process.env.ETHIOVERIFY_WEBHOOK_SECRET)
.update(raw)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(raw.toString("utf8"));
if (event === "fraud_detected") alertTheTeam(data);
res.sendStatus(204); // ack fast, work async
});
POST /v1/webhook). Always compare with a constant-time function.# the ethioverify CLI ships with the platform ethioverify verify DG28H170UI ethioverify verify FT25211G11JQ --provider cbe --account-suffix 21827223 ethioverify status # probe all provider endpoints ethioverify history ethioverify stats