Fable/Documentation

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.

AgentResponsibility
CopilotLearns each customer's genuine habits
ShieldScores every transfer through 12 signal layers
GhostHolds risky transfers in a recoverable cooling window
WatchContinuous 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

ActionMeaningWhat the app should do
PASSMatches normal behaviourComplete silently
FLAGUnusual — worth a checkAsk the customer to confirm
BLOCKStrong fraud signalRefuse, 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.

#SignalFires whenMax
1amount_anomalyFar above the customer's own average0.40
2new_recipientFirst transfer to this account0.20
3time_anomalyOutside typical hours, on the device clock0.12
4channel_riskNIBSS channel weights (USSD highest)0.25
5nip_codeNIBSS risk codes; code 34 auto-blocks0.95
6scam_patternNarration matches a scam script0.25
7ml_anomalyIsolation Forest deviation from own history0.15
8device_anomalyUnrecognised device fingerprint0.18
9location_anomalyNew city, or outside home country0.22
10session_freshnessLarge transfer within 30s of login0.12
11behavioral_anomalyPasted account + near-zero hesitation0.15
12timezone_mismatchDevice timezone differs from usual0.08
13failed_verificationRecent failed identity checks0.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:

CustomerBaselineResult
Tunde — trader₦80k–400kPASS 0.24
Ada — salaried₦8k–55kFLAG 0.564
Chioma — student₦1.5k–9kFLAG 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:

KindExampleWhy it holds
Device-boundPasskey / platform biometricPrivate key never leaves the secure element
Out-of-bandCode to the registered addressArrives on a channel the session doesn't control

Tiers

Container held because ofRelease demands
Amount alonepasskey
Amount + device + location anomalyidentity_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 501 with 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

FieldTypeNotes
user_idstringRequired. Unique per customer
transaction.amountnumberRequired
transaction.recipient_accountstringRequired
transaction.recipient_namestringAs resolved by NUBAN lookup
transaction.channelenummobile_app, ussd, pos, internet, atm
deviceobjectFingerprint from the SDK
contextobjectLocation, session, behavioural signals
client_referencestringStable 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

EndpointPurpose
POST /v1/ghost/createOpen a cooling window
GET /v1/ghost/{id}Container state
POST /v1/ghost/{id}/cancelReturn the money — no verification needed
POST /v1/ghost/{id}/confirmRelease — 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

EndpointPurpose
POST /v1/stepup/requirementWhich factor does this decision need?
POST /v1/stepup/passkey/register/beginStart passkey enrolment
POST /v1/stepup/passkey/register/completeFinish enrolment
POST /v1/stepup/passkey/auth/beginStart verification
POST /v1/stepup/passkey/auth/completeFinish verification
POST /v1/stepup/otp/sendSend an out-of-band code
POST /v1/stepup/otp/verifyVerify a code
POST /v1/stepup/identity-checkLiveness — 501, provider contract

Institutions

EndpointPurpose
POST /admin/provisionCreate a tenant, seed it, email credentials
GET /v1/institutions/{id}Detail and customer roster
POST /v1/institutions/resolve-keyAPI 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.

EndpointReturns
GET /v1/dashboard/statsHeadline metrics
GET /v1/dashboard/transactionsLog; ?user= filters to one customer
GET /v1/dashboard/alertsFlagged and blocked
GET /v1/dashboard/intelligenceScam patterns, channel risk, signals
GET /v1/agents/overviewPer-agent stats

Errors

CodeMeaning
400Invalid input
401Bad API key, or step-up required
404Unknown institution or container
409Container already resolved
429Rate limited
501Needs a provider not configured

Shield never fails open: if scoring throws, it returns a conservative FLAG rather than passing the transfer.