SPECIFICATION & TECHNICAL MANUAL

VERIFIABLE: Self-Hosted Payment Verification Engine & SDK

A private verification service and client SDK for Ethiopian banks and mobile money (Telebirr, Commercial Bank of Ethiopia). When a customer submits a transaction reference or pasted SMS, the service contacts Telebirr or CBE directly at the source, confirms completion, validates the exact integer minor-unit amount against your trusted order database, and atomically records the reference hash to permanently block duplicate replay fraud. Runs entirely on your own infrastructure with zero third-party intermediaries.

Providers
Telebirr, CBE
Latency
~1.1s Direct
Precision
Integer Santim
Replay Defense
SHA-256 Atomic
Runtime
Bun / Node 20+
Database
SQLite (WAL)

01. The Problem & The Mandatory Rule

CORE_SECURITY_RULE

A customer says: "I paid you 1,500 Birr, here is my transaction reference: CE12ABC3XY".

Anyone can type a fake SMS or manipulate screenshot pixels. The pasted message proves nothing. Therefore, the customer-supplied text is used solely to discover the transaction reference number. The verification engine contacts the provider directly to fetch the authoritative receipt.

CRITICAL RULE: Never trust the amount extracted from a customer's pasted SMS to determine order payment. The expected amount must come strictly from your server-side order database in integer minor units (1500.00 ETB = 150000 santim).

02. System Topology

TOPOLOGY

Verifiable consists of two deliverables: a developer-facing SDK integrated into your application backend, and a private verification engine deployed inside your infrastructure.

Customer Browser / Mobile App
       │
       │ [1] Pastes reference or SMS proof
       ▼
Your Application Backend (Next.js / Express / Laravel / Django / Spring)
       │
       │ [2] Private HTTP call with expectedAmount: order.totalMinor
       ▼
Verifiable Engine (Self-hosted on your VPS / Docker)
       │
       ├─► Check 1: Input Extractor (Telebirr 10-char / CBE FT...)
       ├─► Check 2: Direct Provider Lookup (HTTPS to telebirr / CBE)
       ├─► Check 3: Check Completed Status
       ├─► Check 4: Compare Amount & Receiver Profile
       └─► Check 5: Atomic Replay Protection (Locks reference hash)
       │
       ▼
Returns Standardized Outcome:
  • verified             ──► Mark order paid in application database
  • already_used         ──► Reject (Fraud / Replay reuse attempt)
  • amount_mismatch      ──► Reject or request the difference
  • receiver_mismatch    ──► Reject (Payment made to wrong account)
  • not_completed        ──► Keep order pending; ask customer to wait
  • not_found            ──► Reject (Confirmed absent on provider)
  • provider_unavailable ──► Safe retry; network/provider timeout

03. The Five Verification Checks

EXECUTION_ORDER

Every request executes through these steps in strict order. Claiming occurs only at the very end, so a customer who inputs the wrong amount or submits an incomplete payment does not burn a valid receipt.

01.Understand Input (Extraction)

Parses raw reference tokens, provider hints, and account suffixes (8 digits for CBE) from messy SMS text (English & Amharic), URLs, or standalone IDs. Invalid inputs exit immediately with 'invalid_input'.

02.Ask the Provider Directly (Lookup)

Adapter fetches authoritative data from provider server with a strict 10-15s timeout. If provider confirms transaction does not exist, status is 'not_found'. If provider is down, status is 'provider_unavailable' (retryable).

03.Verify Completed Status (Evidence)

Derives completion strictly from provider receipt evidence ('Completed', 'SUCCESS', 'ተጠናቋል'). Never assumes or hardcodes completed status. Pending transactions return 'not_completed'.

04.Match Authoritative Order Parameters

Compares receipt amount against expected amount in integer minor units (150000 santim == 150000 santim). Checks receiver profile against configured merchant account suffix or recipient name.

05.Atomic Replay Protection (Claim)

Executes atomic insertion in the claims registry. If same order retries, returns 'verified' idempotently. If a different order attempts to use the same receipt, returns 'already_used'.

04. Quickstart & Installation

INSTALL
Step 1: Run the Engine
git clone https://github.com/FuadTesfaye/verifiable.git
cd verifiable/deploy
cp verify.env.example /etc/verify.env
docker compose up -d
Step 2: Install SDK in Client Backend
bun add @verifiable/sdk
# or npm:
npm install @verifiable/sdk
Step 3: Test Engine Health
curl http://localhost:3000/v1/health

# Response:
# {"status":"ok","service":"verifiable-engine","version":"0.1.0","database":{"status":"connected"}}

05. TypeScript / Node.js SDK

SDK_CLIENT

The SDK handles serialization, timeouts, and error handling when talking to your private engine.

import { PayVerifyClient, toMinorUnits } from "@verifiable/sdk";

const verifier = new PayVerifyClient({
  baseUrl: process.env.PAY_VERIFY_URL || "http://pay-verify:3000",
  apiKey: process.env.PAY_VERIFY_API_KEY!,
  timeoutMs: 15000,
});

// Inside your checkout verification endpoint:
export async function handleConfirmPayment(orderId: string, customerInput: string) {
  const order = await db.orders.get(orderId);

  const result = await verifier.verifyPayment({
    input: customerInput,
    provider: "telebirr", // optional hint
    expected: {
      amountMinor: toMinorUnits(order.totalAmount, "ETB"), // 1500.00 ETB -> 150000
      currency: "ETB",
      receiverProfile: "main-store",
    },
    context: {
      orderId: order.id,
      idempotencyKey: `payment:${order.id}`,
    },
  });

  switch (result.status) {
    case "verified":
      await markOrderPaid(order.id, result.verificationId);
      return { success: true, message: "Order confirmed" };

    case "already_used":
      await flagFraud(order.id, "Receipt reused across orders");
      return { success: false, error: "This transaction number has already been claimed." };

    case "amount_mismatch":
      return { success: false, error: "Paid amount does not match order total." };

    case "provider_unavailable":
      // Network hiccup or provider maintenance: safe to retry without marking order failed!
      return { success: false, retryable: true, error: "Provider temporarily unavailable. Please retry shortly." };

    case "not_completed":
      return { success: false, error: "Payment is still processing on provider system. Please wait." };

    case "not_found":
      return { success: false, error: "Transaction record does not exist on bank system." };

    default:
      return { success: false, error: "Verification could not be established." };
  }
}
Outcome Status Table
StatusMeaningRecommended Action
verifiedGenuine, completed, matches amount/receiver, first useMark order paid
already_usedReceipt already claimed by another order (replay attack)Reject & flag for fraud review
amount_mismatchProvider receipt amount differs from order expected amountReject or request difference
receiver_mismatchPaid to different receiver name or account suffixReject
not_completedReceipt exists on provider but status is pending or failedAsk customer to wait/retry
not_foundProvider confirms receipt does not existReject; likely fabricated reference
provider_unavailableProvider timed out or connection failedKeep order pending; allow safe retry
invalid_inputCould not extract valid reference from submitted textAsk customer to re-enter

06. Provider Adapters

ADAPTER_CONTRACT

Telebirr Adapter

Endpoint: https://transactioninfo.ethiotelecom.et/receipt/{REFERENCE}
HTML receipt parsed using Cheerio with bilingual label tables (English and Amharic). Extracts settled amount, transaction status, payer name, credited party name, and payment timestamp.

CBE Adapter (Dual Strategy)

1. Primary JSON API: https://mb.cbe.com.et/api/v1/transactions/public/transaction-detail/{REFERENCE}
2. Legacy PDF: https://apps.cbe.com.et:100/?id={REFERENCE}{ACCOUNT_SUFFIX}
Modern Mobile Banking JSON API is queried first. If an 8-digit receiving account suffix is provided, the adapter can fall back to parsing the legacy PDF receipt. Completion status is strictly derived from evidence (SUCCESS, COMPLETED, ተሳክቷል).

Extending with New Providers

New banks (Awash, Dashen, Abyssinia, M-Pesa) implement the standard interface:

export interface ProviderAdapter {
  readonly provider: ProviderId;
  parseInput(input: string, hint?: ProviderId): ParsedPaymentInput | null;
  lookup(input: ParsedPaymentInput, opts?: LookupOptions): Promise<ProviderLookupResult>;
  normalize(result: ProviderLookupResult, input: ParsedPaymentInput): NormalizedReceipt | null;
}

07. Storage & Permanent Replay Protection

REPLAY_DEFENSE

A critical security requirement: Never purge replay protection records when clearing old receipt data.

If an engine deletes claim records after 90 days, an attacker could resubmit an old valid receipt on day 91 and get an order fulfilled for free.

Claim Schema (SQLite in WAL Mode)
CREATE TABLE transaction_claims (
  tenant_id         TEXT NOT NULL,
  provider          TEXT NOT NULL,
  reference_hash    TEXT NOT NULL, -- SHA-256(tenant_id + provider + reference)
  reference_masked  TEXT NOT NULL, -- Masked for logs (e.g. CE****XY)
  order_id          TEXT NOT NULL,
  idempotency_key   TEXT NOT NULL,
  amount_minor      INTEGER NOT NULL,
  currency          TEXT NOT NULL DEFAULT 'ETB',
  verification_id   TEXT NOT NULL,
  receipt_snapshot  TEXT, -- Detailed PII wiped after 90 days
  claimed_at        TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (tenant_id, provider, reference_hash)
);

During the daily retention purge, receipt_snapshot is set to NULL, but the row and reference hash remain indexed permanently. The receipt can never be reused.

08. REST API Reference

HTTP_SPEC
POST /v1/verifications
// Headers:
x-api-key: your-service-key
Content-Type: application/json

// Request Body:
{
  "input": "CE12ABC3XY",
  "provider": "telebirr",
  "expected": {
    "amountMinor": 150000,
    "currency": "ETB",
    "receiverProfile": "main-store"
  },
  "context": {
    "orderId": "ORD-1042",
    "idempotencyKey": "payment:ORD-1042"
  }
}

// Success Response (200 OK):
{
  "status": "verified",
  "verificationId": "ver_0f9a2b4c",
  "provider": "telebirr",
  "reference": "CE12ABC3XY",
  "amountMinor": 150000,
  "currency": "ETB",
  "checks": {
    "providerConfirmed": true,
    "amountMatched": true,
    "receiverMatched": true,
    "duplicateCheckPassed": true
  },
  "verifiedAt": "2026-10-18T20:00:00.000Z"
}
GET /v1/health
// Response (200 OK):
{
  "status": "ok",
  "service": "verifiable-engine",
  "version": "0.1.0",
  "uptimeSeconds": 86400,
  "database": { "status": "connected" },
  "providers": {
    "telebirr": { "enabled": true },
    "cbe": { "enabled": true }
  }
}

09. Production Deployment

DEPLOYMENT
Docker Compose Configuration
services:
  verifiable-engine:
    image: verifiable-engine:latest
    build:
      context: ..
      dockerfile: deploy/Dockerfile
    container_name: verifiable-engine
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - PAY_VERIFY_PORT=3000
      - PAY_VERIFY_HOST=0.0.0.0
      - PAY_VERIFY_API_KEYS=main_store:replace-with-a-32-byte-hex-secret
      - PAY_VERIFY_DB_PATH=/var/lib/verifiable/verify.db
    volumes:
      - verifiable-data:/var/lib/verifiable

volumes:
  verifiable-data:
    driver: local
Systemd Service (/etc/systemd/system/verifiable.service)
[Unit]
Description=Verifiable Payment Engine
After=network.target

[Service]
Type=simple
User=bun
WorkingDirectory=/opt/verifiable
EnvironmentFile=/etc/verify.env
ExecStart=/usr/local/bin/bun run packages/engine/src/server.ts
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

10. Security Checklist

AUDIT_CRITERIA
Threat / RequirementMitigation in Verifiable
Pasted SMS ForgeryText only extracts reference; provider lookup establishes reality
SSRF (Server-Side Request Forgery)Outbound requests restricted to allowlisted provider hostnames
Timing Attack on API KeysConstant-time comparison via crypto.timingSafeEqual
Floating-Point DriftAmounts handled in integer minor units (santim); zero float errors
Replay Reuse AttackPermanent reference hash uniqueness constraint
PII Leakage in LogsAll references, bodies, and names redacted in structured JSON logs