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.
01. The Problem & The Mandatory Rule
CORE_SECURITY_RULEA 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.
02. System Topology
TOPOLOGYVerifiable 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 timeout03. The Five Verification Checks
EXECUTION_ORDEREvery 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.
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'.
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).
Derives completion strictly from provider receipt evidence ('Completed', 'SUCCESS', 'ተጠናቋል'). Never assumes or hardcodes completed status. Pending transactions return 'not_completed'.
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.
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
INSTALLgit clone https://github.com/FuadTesfaye/verifiable.git cd verifiable/deploy cp verify.env.example /etc/verify.env docker compose up -d
bun add @verifiable/sdk # or npm: npm install @verifiable/sdk
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_CLIENTThe 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." };
}
}| Status | Meaning | Recommended Action |
|---|---|---|
| verified | Genuine, completed, matches amount/receiver, first use | Mark order paid |
| already_used | Receipt already claimed by another order (replay attack) | Reject & flag for fraud review |
| amount_mismatch | Provider receipt amount differs from order expected amount | Reject or request difference |
| receiver_mismatch | Paid to different receiver name or account suffix | Reject |
| not_completed | Receipt exists on provider but status is pending or failed | Ask customer to wait/retry |
| not_found | Provider confirms receipt does not exist | Reject; likely fabricated reference |
| provider_unavailable | Provider timed out or connection failed | Keep order pending; allow safe retry |
| invalid_input | Could not extract valid reference from submitted text | Ask customer to re-enter |
06. Provider Adapters
ADAPTER_CONTRACTTelebirr Adapter
CBE Adapter (Dual Strategy)
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_DEFENSEA 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.
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// 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"
}// 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
DEPLOYMENTservices:
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[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 / Requirement | Mitigation in Verifiable |
|---|---|
| Pasted SMS Forgery | Text only extracts reference; provider lookup establishes reality |
| SSRF (Server-Side Request Forgery) | Outbound requests restricted to allowlisted provider hostnames |
| Timing Attack on API Keys | Constant-time comparison via crypto.timingSafeEqual |
| Floating-Point Drift | Amounts handled in integer minor units (santim); zero float errors |
| Replay Reuse Attack | Permanent reference hash uniqueness constraint |
| PII Leakage in Logs | All references, bodies, and names redacted in structured JSON logs |