Developers

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.

1 · Get an API key

  1. Sign up with your phone number — you'll get a one-time code (delivered over Telegram) to confirm it, then your first key (prefix evk_) is issued and shown once.
  2. Onboarding programmatically? Two calls: 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.
  3. Need another key? My AccountGenerate API key, or POST /v1/merchant/api-keys.
  4. Send it on every request in the X-API-Key header. Keep keys server-side only.

2 · Base URL

ETHIOVERIFY_BASE_URL=https://your-host/v1   # self-hosted; locally: http://localhost:8000/v1
ETHIOVERIFY_API_KEY=evk_your_key_here

3 · Your first verification

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": "…"
}
Gate your business logic on outcome == "verified" and your own risk tolerance (e.g. reject HIGH/CRITICAL). A CRITICAL score already flips the outcome to rejected_fraud automatically.

4 · Provider-specific fields

ProviderRequired fields
telebirrreference (10-char transaction number)
cbereference (FT…) + account_suffix — the last 8 digits of either account
boareference + account_suffix — the last 5 digits of the account (or receipt_url)
dashen / awash / zemenreference, 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.

5 · Webhooks (optional)

Configure once with POST /v1/webhook; we push events to your backend as they happen:

EventFires when
transaction_verifieda verification succeeds
verification_faileda lookup fails (not found / provider error)
fraud_detectedrisk level is HIGH or CRITICAL
duplicate_transactiona receipt number is seen on more than one transaction
provider_downa 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

Authentication

MethodHeaderFor
API keyX-API-Key: evk_…server-to-server integrations
JWT bearerAuthorization: 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.

Endpoints

POST/verify

Verify one payment. Body: reference (required), provider, expected_amount, account_suffix, receipt_url. Returns the full result synchronously (see Getting started).

POST/bulk-verify

Body: {"items": [ …up to 100 verify requests… ]}. Items run concurrently (10 at a time); returns an array of results in the same order.

GET/transaction/{id}

A stored canonical transaction by internal id.

GET/history?limit=50&offset=0

Your verification attempts: outcome, risk score/level, settlement match, IP, device, latency, timestamp. Admin and auditor roles see all merchants.

GET/stats

Aggregates: totals, verified/failed, fraud-flagged, per-provider counts, average latency.

POST/auth/otp/request

Send a phone-verification code (no auth): {"phone": "09…"}. Required once before signup; the code is delivered over Telegram and expires in ~10 min.

POST/merchant

Self-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.

POST/auth/login

Exchange {"phone","password"} for a 60-min JWT bearer token (for web sessions; server-to-server integrations use the API key instead).

POST/merchant/api-keys

Issue an additional key. The full key appears only in this response.

POST/merchant/settlement-accounts · GET/merchant/settlement-accounts

Register / list receiving accounts: {"provider": "telebirr", "account_number": "2519…", "account_name": "…"}. Once registered, every result includes settlement_match and mismatches raise a fraud signal.

POST/webhook

Set your callback: {"url": "https://…", "events": ["transaction_verified", "fraud_detected"]}. Omit events to subscribe to all.

GET/providers · /providers/health

Provider catalog with reference formats; live health probes each provider's receipt endpoint concurrently.

GET/health

Liveness probe — no auth.

Outcomes

outcomeMeaning
verifiedProvider record confirms the payment
not_foundNo receipt exists for that reference
rejected_fraudReceipt exists but risk scored CRITICAL
parse_errorReceipt retrieved but its layout changed — report it to us
provider_errorProvider unreachable; safe to retry

Errors

All errors share one envelope: {"error": "…", "code": "…", "request_id": "…", "context": {}}

HTTPcode
401authentication_failed
403forbidden (role lacks scope)
404receipt_not_found · provider_not_found
422validation_error · receipt_parse_error · provider_detection_failed
429rate_limited — default 120 requests/min per account, raisable per merchant
502 / 503provider_unavailable · circuit_open (provider temporarily disabled after repeated failures — retry after ~30 s)

Roles & scopes

RoleCan
merchantverify, read own history/stats, manage keys, webhook, settlement accounts
developerverify + read own history/stats
auditorread-only across the platform, incl. audit trail
admineverything, incl. the operator panel

Python SDK — ethiopayverify

pip 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).

Plain REST — Node.js

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 */ }

Plain REST — PHP

$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 */ }

Webhook receiver — Node.js

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
  });
The signature is HMAC-SHA256 over the raw request body with your webhook secret (returned at signup / POST /v1/webhook). Always compare with a constant-time function.

Command line

# 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