Overview
Fable is fraud infrastructure African banks plug into. It does not replace a bank's existing controls — it decides when those controls should fire.
The problem
Nigerian fraud is not primarily a technical break-in. It is social engineering: the customer is talked into authorising the transfer themselves. Every credential checks out, every rule passes, and the money is gone in the ten seconds NIP takes to clear.
Static rules ("OTP above ₦100,000") fail because they are predictable. A scammer who knows the threshold coaches the victim through it.
The approach
Fable scores a transfer against that specific customer's behaviour, not a global threshold. The same ₦250,000 that is routine for a trader is a hard flag for a student.
| Agent | Responsibility |
|---|---|
| Copilot | Learns each customer's genuine habits |
| Shield | Scores every transfer through 12 signal layers |
| Ghost | Holds risky transfers in a recoverable cooling window |
| Watch | Continuous monitoring between transactions (not built) |
Architecture
Bank's app ──► Fable SDK ──► POST /v1/shield/analyze
│
┌─────────┴─────────┐
Copilot baseline 12 signal layers
│
PASS ──── FLAG ──── BLOCK
│
Ghost containment
│
Institution console
Two runtimes: a Next.js frontend and a FastAPI intelligence layer. The frontend degrades to a local scoring engine when the API is unreachable, and queues those transfers for sync — so a network failure never stops a bank working.
What is real, and what is not
Being precise about this matters more than a longer feature list.
Real: device fingerprinting, GPS/IP geolocation, behavioural biometrics, Paystack NUBAN resolution, WebAuthn passkeys with verified signatures, the 12-layer scoring pipeline, multi-tenant isolation, offline sync.
Not built: Watch (marked "coming soon" rather than faked), and the
liveness/face-check tier, which returns 501 with its provider contract rather
than a fake pass. Making the strongest tier the least trustworthy would defeat
its purpose.
Quickstart
1. Provision an institution
curl -X POST https://api.fablehq.app/admin/provision \
-H "Content-Type: application/json" \
-d '{"institution_name":"Zenith Test Bank","admin_email":"risk@zenithtest.ng"}'
Returns — and emails — four things:
{
"institution_id": "zenith_test_bank",
"api_key": "fbl_live_a87c82a4...",
"temp_password": "rJXsfSHv*oNk",
"demo_url": "https://fablehq.vercel.app/demo/zenith_test_bank"
}
The tenant is seeded with three customers and 90 days of history, so the console opens onto real data rather than an empty state.
2. Score a transfer
curl -X POST https://api.fablehq.app/v1/shield/analyze \
-H "Content-Type: application/json" \
-H "X-API-Key: fbl_live_..." \
-d '{
"user_id": "zenith_test_bank_chioma",
"transaction": {
"amount": 250000,
"recipient_account": "0111222333",
"recipient_name": "ADEOLA MUSA",
"channel": "mobile_app"
}
}'
{
"risk_score": 0.552,
"action": "FLAG",
"signals": [
"amount_anomaly: 20x above your baseline (+0.28)",
"new_recipient: first transfer to this account"
],
"explanation": "This is far larger than Chioma's usual transfers...",
"transaction_id": "txn_a1b2c3d4e5f6"
}
3. Act on the verdict
| Action | Meaning | What the app should do |
|---|---|---|
PASS | Matches normal behaviour | Complete silently |
FLAG | Unusual — worth a check | Ask the customer to confirm |
BLOCK | Strong fraud signal | Refuse, offer Ghost containment |
4. Contain an override
When a customer insists on a blocked transfer, hold it rather than losing it:
curl -X POST https://api.fablehq.app/v1/ghost/create \
-H "Content-Type: application/json" \
-d '{
"user_id": "zenith_test_bank_chioma",
"risk_score": 0.88,
"signals": ["amount_anomaly", "device_anomaly"],
"transaction": { "amount": 250000, "recipient_account": "0111222333" }
}'
Cancel returns the money. Release requires identity verification — see Identity Assurance.
Scoring
Shield combines twelve additive signal layers. FLAG at ≥ 0.5, BLOCK at ≥ 0.8.
| # | Signal | Fires when | Max |
|---|---|---|---|
| 1 | amount_anomaly | Far above the customer's own average | 0.40 |
| 2 | new_recipient | First transfer to this account | 0.20 |
| 3 | time_anomaly | Outside typical hours, on the device clock | 0.12 |
| 4 | channel_risk | NIBSS channel weights (USSD highest) | 0.25 |
| 5 | nip_code | NIBSS risk codes; code 34 auto-blocks | 0.95 |
| 6 | scam_pattern | Narration matches a scam script | 0.25 |
| 7 | ml_anomaly | Isolation Forest deviation from own history | 0.15 |
| 8 | device_anomaly | Unrecognised device fingerprint | 0.18 |
| 9 | location_anomaly | New city, or outside home country | 0.22 |
| 10 | session_freshness | Large transfer within 30s of login | 0.12 |
| 11 | behavioral_anomaly | Pasted account + near-zero hesitation | 0.15 |
| 12 | timezone_mismatch | Device timezone differs from usual | 0.08 |
| 13 | failed_verification | Recent failed identity checks | 0.25 |
Design decisions worth knowing
Narration is deliberately weak. Most customers leave it blank, so building fraud detection on it would be theatre. Its weight is halved. Layers 8–12 carry the load because a customer cannot opt out of them.
The amount scale runs past 10x. It used to cap there, which meant a student sending 45x her baseline scored lower than someone at 8x who also tripped a time anomaly — and passed. It now escalates through 25x and 50x tiers.
Time uses the device clock. Server UTC misplaces a Lagos evening by an hour, which is enough to fire a false time anomaly every night.
Cold start
A customer with fewer than three transactions has no personal baseline. Six layers need something to compare against, so they used to skip silently — a brand-new account could move almost any sum and score around 0.20.
That is exactly the mule and takeover shape, so Shield now falls back to how that institution's customers behave in aggregate and prices the newness itself (+0.15). The population baseline uses the median rather than the mean: a few large business transfers otherwise drag "normal" upward and hide real anomalies.
A new account sending ₦450,000 scores 0.69 → FLAG, where it previously passed.
The same amount, three customers
Verified against live data, identical transfer of ₦250,000:
| Customer | Baseline | Result |
|---|---|---|
| Tunde — trader | ₦80k–400k | PASS 0.24 |
| Ada — salaried | ₦8k–55k | FLAG 0.564 |
| Chioma — student | ₦1.5k–9k | FLAG 0.552 |
Same recipient, same device, same moment. Three verdicts. That difference is the entire argument for per-customer baselines.
Identity Assurance
Shield answers how risky is this?. Assurance answers the question that makes containment actually hold: who must prove they are here, and with what?
The hole this closes
Ghost's release path originally checked only that the caller claimed to be the account holder — and the client supplies that id about itself. Containment was therefore defeated by the exact session compromise that triggered it: an attacker holding the tab could press "release" and the money left, with all twelve behavioural layers having correctly fired.
Releasing costs proof. Cancelling stays free, because returning money is always safe.
Why the factor's location matters more than its type
A PIN prompt inside a compromised session is theatre — the attacker has that prompt too. Only two kinds of factor cost an attacker anything:
| Kind | Example | Why it holds |
|---|---|---|
| Device-bound | Passkey / platform biometric | Private key never leaves the secure element |
| Out-of-band | Code to the registered address | Arrives on a channel the session doesn't control |
Tiers
| Container held because of | Release demands |
|---|---|
| Amount alone | passkey |
| Amount + device + location anomaly | identity_check |
Risk arising from identity signals implies the session may not belong to the customer, so those cases escalate to a factor the session cannot satisfy — rather than asking for one more thing the attacker already has. Release is always at least one tier above the transfer itself.
Guarantees
Challenges and tokens are single-use, expiring, and bound to a user, a purpose and a reference. Proof minted to confirm a small transfer cannot be replayed to release a large one. Verified: wrong reference, wrong purpose, wrong user and replayed tokens are all refused.
Failed attempts are recorded and fed back as scoring signal 13 — three failed biometrics before a large transfer is about the clearest evidence available.
What is real
- Passkeys — genuine WebAuthn, signatures verified with
py_webauthn. Real Face ID, Touch ID, Windows Hello. - Email OTP — real, over configured SMTP. Codes stored hashed, compared in constant time.
- Liveness / face check — deliberately not implemented. Returns
501with the provider contract (Smile ID, Dojah, Prembly). Faking it would make the strongest tier the least trustworthy.
Why this improves rather than replaces
Banks already own PIN, biometric, OTP and BVN-linked face data. What nobody does well is decide when to demand which. Today it is a static rule, which is exactly why scammers coach victims through OTPs — the threshold is predictable.
Fable makes the demand behaviourally driven and unpredictable to the attacker: the same ₦50,000 that clears silently for one customer forces a stronger factor for another on a new device in a new city. The bank keeps its auth stack; Fable decides the moment.
API Reference
Base URL: https://api.fablehq.app · Interactive OpenAPI spec at /docs
Authentication
X-API-Key: fbl_live_...
The key decides which institution a write belongs to. It outranks any institution the client claims — the key is authenticated, the claim is not.
Shield
POST /v1/shield/analyze
| Field | Type | Notes |
|---|---|---|
user_id | string | Required. Unique per customer |
transaction.amount | number | Required |
transaction.recipient_account | string | Required |
transaction.recipient_name | string | As resolved by NUBAN lookup |
transaction.channel | enum | mobile_app, ussd, pos, internet, atm |
device | object | Fingerprint from the SDK |
context | object | Location, session, behavioural signals |
client_reference | string | Stable id; makes replay idempotent |
Returns risk_score, action, signals[], explanation, transaction_id.
POST /v1/shield/feedback
Confirm whether a decision was correct. Feeds the baseline.
Ghost
| Endpoint | Purpose |
|---|---|
POST /v1/ghost/create | Open a cooling window |
GET /v1/ghost/{id} | Container state |
POST /v1/ghost/{id}/cancel | Return the money — no verification needed |
POST /v1/ghost/{id}/confirm | Release — requires a step-up token |
A release without sufficient proof returns 401 naming the level required:
{ "detail": { "error": "step_up_required", "level": "passkey" } }
Step-up
| Endpoint | Purpose |
|---|---|
POST /v1/stepup/requirement | Which factor does this decision need? |
POST /v1/stepup/passkey/register/begin | Start passkey enrolment |
POST /v1/stepup/passkey/register/complete | Finish enrolment |
POST /v1/stepup/passkey/auth/begin | Start verification |
POST /v1/stepup/passkey/auth/complete | Finish verification |
POST /v1/stepup/otp/send | Send an out-of-band code |
POST /v1/stepup/otp/verify | Verify a code |
POST /v1/stepup/identity-check | Liveness — 501, provider contract |
Institutions
| Endpoint | Purpose |
|---|---|
POST /admin/provision | Create a tenant, seed it, email credentials |
GET /v1/institutions/{id} | Detail and customer roster |
POST /v1/institutions/resolve-key | API key to institution |
GET /v1/branding/{id} | Logo, palette, vanity URL |
PATCH /v1/branding/{id} | Update branding |
Dashboard
All reads take ?institution= and are strictly tenant-scoped.
| Endpoint | Returns |
|---|---|
GET /v1/dashboard/stats | Headline metrics |
GET /v1/dashboard/transactions | Log; ?user= filters to one customer |
GET /v1/dashboard/alerts | Flagged and blocked |
GET /v1/dashboard/intelligence | Scam patterns, channel risk, signals |
GET /v1/agents/overview | Per-agent stats |
Errors
| Code | Meaning |
|---|---|
400 | Invalid input |
401 | Bad API key, or step-up required |
404 | Unknown institution or container |
409 | Container already resolved |
429 | Rate limited |
501 | Needs a provider not configured |
Shield never fails open: if scoring throws, it returns a conservative FLAG
rather than passing the transfer.