FinLens Developer Documentation
Everything you need to let your users connect their inbox, and to receive consented, typed financial insights in your app — without ever touching mailbox tokens or raw email.
Overview
FinLens has three moving parts from your perspective:
- Your backend holds partner API keys, mints short-lived
connect_tokens, exchangespublic_tokens for durable grants, and receives webhooks. - Your frontend (Web / React Native / Flutter SDK) opens the hosted Connect flow and reads insights with a short-lived, read-only
client_token. - FinLens runs the hosted consent flow, syncs and parses mail server-side, and exposes insights via API and webhooks.
The token dance is deliberately Plaid-shaped: nothing durable ever passes through your frontend, and nothing Google-issued ever reaches you at all.
your backend FinLens user's browser
──────────── ─────── ──────────────
POST /connect/tokens ──▶ connect_token (5 min, single-use)
│
▼
hosted Connect flow ◀──────────── SDK opens system browser
(Google OAuth + Layer-2 consent)
│
▼
your frontend ◀─────────── public_token (one-shot)
│
▼
POST /connect/exchange ──▶ { grant_id, persona_id } ← durable, revocable, yours
Environments
| Environment | API base | Purpose |
|---|---|---|
sandbox | https://sandbox.api.finlens.in | Synthetic mailbox corpus through the real pipeline — no Google involvement, no real mail. Full webhook + deletion support. |
production | https://api.finlens.in | Live traffic. Requires completed partner review (Going live). |
Keys are environment-scoped; sandbox keys cannot touch production and vice versa. SDKs take env: "sandbox" | "production" at init.
Quickstart
From zero to your first insight.created webhook, in sandbox:
Install the SDKs
npm install @finlens/node # your backend
npm install @finlens/web # web frontend
# or: @finlens/react-native · flutter pub add finlens_flutter
Mint a connect token (backend)
import { FinLensAdmin } from "@finlens/node";
const fl = new FinLensAdmin({ apiKeyId, apiSecret }); // HMAC signing built in
const { connectToken } = await fl.connect.createToken({
endUserRef: "user_8231", // your stable reference for this user
products: ["insights"],
});
Open Connect (frontend)
import { FinLens } from "@finlens/web";
const fl = FinLens.init({ env: "sandbox" });
fl.createConnect({
connectToken,
onSuccess: ({ publicToken }) => sendToBackend(publicToken),
onExit: ({ code }) => showRetry(code),
}).open();
Exchange for a grant (backend)
const { grantId, personaId } = await fl.connect.exchange({ publicToken });
// persist both against your user; personaId is your user's FinLens identity
Receive insights
// webhook (recommended)
const events = fl.webhooks.verify(req.headers, req.rawBody);
// or poll / render client-side with a client token
const { clientToken } = await fl.personas.clientToken(personaId);
Authentication
Partner API (server-to-server)
Requests are signed with your key pair: X-FinLens-Key-Id plus an HMAC-SHA256 signature in X-FinLens-Signature. Requests outside a 5-minute clock-skew window, or with a reused nonce, are rejected. @finlens/node does all of this for you.
The signature covers this canonical string — newline separated, in exactly this order:
METHOD
PATH
X-FinLens-Timestamp
X-FinLens-Nonce
SHA256(body)
Method and path are covered, so a captured signature cannot be re-aimed at a different endpoint that happens to accept the same body. The query string is deliberately not signed, so a proxy appending tracking parameters will not break authentication.
- Keys are partner-scoped and environment-scoped; rotate or revoke independently in the dashboard.
- A leaked key has bounded blast radius: per-partner rate classes, anomaly throttling, and a kill switch are always active.
Client tokens (browser / mobile)
Frontends never hold API keys. Your backend mints a client_token per user session — a 15-minute JWT scoped to a single persona with read-only claims (insights:read, sync:read, insights:transition). Refresh it from your backend when it expires.
The Connect flow
Connect is a hosted flow on a FinLens-controlled, verified domain. SDKs open it in the system browser (popup on desktop web, Chrome Custom Tabs on Android, ASWebAuthenticationSession on iOS) — never a WebView, which mailbox providers block for OAuth. The user sees: a pre-consent explainer (what will be read, with the institution list one tap away) → provider OAuth → an explicit screen consenting to share insights with your named app.
connect_token: 5-minute TTL, single use, bound to your app + yourendUserRef+ requested products.public_token: one-shot, only good for the exchange call; safe to pass through the frontend.grant_id/persona_id: durable identifiers your backend stores.persona_idis unique to your integration — the same human on another partner app has a different, unlinkable persona.- Re-linking (after revocation or token expiry at the provider) reuses the exact same flow with a fresh
connect_token.
Exit codes
| Code | Meaning | Recommended handling |
|---|---|---|
user_denied | User closed or declined the flow | Offer to retry later; don't nag |
user_denied_scope | Completed login but unchecked mailbox access — no grant was stored | Explain why access is needed; retry with fresh token |
expired | connect_token older than 5 minutes or already used | Mint a fresh token and reopen |
popup_blocked | Desktop popup was blocked | Fall back to full-page redirect (SDK supports both) |
net | Network failure mid-flow | Retry with fresh token |
Insights
An insight is a typed object: what was detected, how severe, what it costs the user per year, and the evidence behind it — ready to render.
{
"id": "ins_9f3k2m",
"catalog_key": "cc.interest_paid",
"severity": "high",
"money_impact_yearly": 7080,
"currency": "INR",
"confidence": 0.97,
"framing_tier": "observe",
"state": "new",
"valid_until": "2026-08-17",
"copy": {
"title": "You're paying card interest & late fees",
"message": "₹1,770 in the last 3 months across 1 card."
},
"evidence": [
{ "summary": "3 late fees on HDFC ****4523 in Apr–Jun totalling ₹1,770" }
]
}
Fields
| Field | Notes |
|---|---|
catalog_key | Stable key from the insight catalog (e.g. sub.zombie, ins.lapse_risk). New keys are additive; keys are never repurposed. |
severity | info | low | medium | high — banded per catalog entry. |
money_impact_yearly | Annualized rupee impact, when computable. |
framing_tier | observe (facts) or educate (facts + generic math). Compliance-reviewed copy; there is no "recommend product X" tier. |
state | Lifecycle: new → viewed → acted | dismissed → expired, plus suppressed (user muted this key). |
evidence | Display-safe summaries only — pre-rendered "because…" strings. Raw email is never present, on any tier. |
Reading & transitioning
const client = fl.client({ clientToken });
const { insights } = await client.insights.list({ state: "new" });
await client.insights.transition(id, "viewed"); // viewed | acted | dismissed
Report state transitions faithfully — they feed the user's savings meter and suppression logic (a dismissed insight stops resurfacing), and they're your engagement evidence at partner review.
Scan & parse
A pay-for-what-you-need funnel: first scan the mailbox to see which financial documents exist (one flat charge), then parse only the ones you want (per document). You never pay to parse documents you don't need.
Step 1 — Discovery scan
Returns a typed catalog of available documents. The catalog is derived data only — institution, document type, approximate period, and an opaque docId. It deliberately never contains the raw email subject, the attachment filename, or the sender's address.
const { catalog, chargedPaise } = await fl.documents.discover(personaId);
// catalog: [
// { docId: "doc_ab12", institution: "HDFC Bank", documentType: "bank_statement", period: "Mar 2026", status: "available" },
// { docId: "doc_cd34", institution: "Star Health", documentType: "premium_notice", period: "2026", status: "available" }, … ]
Locked documents (statement PDF passwords)
Indian institutions password-protect statement PDFs (combinations of PAN, name, date of birth). Documents we can't open are listed with status: "locked". Supply the user's unlock material once — it's stored envelope-encrypted in FinLens's Tier-0 vault (or single-use with ephemeral: true), used to derive each institution's known password pattern in memory, and never logged, stored on documents, or returned by any API. A document that stays locked is never billed.
await fl.personas.provideUnlockMaterial(personaId, {
pan: "ABCDE1234F", fullName: "Asha Kumar", dob: "1992-02-04",
});
// re-scan → locked docs become "available" → parse them
Step 2 — Selective parse
Pass the docIds you want. We full-parse only those and generate insights for them. Only successfully parsed documents are billed — a document we can't read (or unlock) is free.
const picked = catalog.filter((d) => d.documentType === "bank_statement").map((d) => d.docId);
const { parsedDocs, chargedPaise, insightsCreated } = await fl.documents.parse(personaId, picked);
Wallet & billing
Billing is prepaid. You top up a wallet (via Razorpay), and metered usage is deducted from it in real time. Amounts are always in paise (₹1 = 100 paise).
Meters & base rates
| Meter | Unit | Base rate |
|---|---|---|
discovery_scan | per mailbox scan | ₹5 flat |
document_parse | per successfully parsed document | ₹5 |
user_month | per linked user / month (managed model) | banded (from ₹8) |
Enterprise deals get custom per-partner rates — set once, effective for new requests going forward, never re-pricing past charges. Custom rates cannot go below the ₹1.50 floor.
Top up
// 1 · create a Razorpay order (₹500 minimum)
const order = await fl.wallet.topUp(500_00); // paise
// 2 · open Razorpay Checkout with { order.orderId, order.keyId, order.amountPaise }
// 3 · on payment.captured, FinLens credits your wallet automatically (webhook)
const { balancePaise, rates } = await fl.wallet.balance();
If a request can't be covered, the API returns 402 insufficient_balance — top up and retry. All credits and deductions are idempotent, so Razorpay retries and metering retries never double-count.
Sync progress
Backfill runs newest-first, so value lands fast; deep history continues in the background. Stream progress to your UI instead of showing a spinner:
client.sync.subscribe((s) => {
// s = { status, backfill_pct, docs_parsed, insights_found, locked_documents }
render(`Reading statements… ${s.insights_found} leaks found so far`);
});
status | Meaning |
|---|---|
backfilling | Initial historical sync in progress |
live | Steady state — new mail processed as it arrives |
paused_quota | Provider quota pressure; deep scan resumes automatically |
requires_relink | Mailbox access revoked at the provider — open Connect to re-link |
paused_user | User paused syncing from the portal |
If locked_documents > 0, some statements need a password the user hasn't provided; the SDK exposes a hosted fix-it flow (client.sync.resolveLocked()) — password entry always happens on the FinLens surface, never in your app.
Webhooks
Signed events, delivered with retries and exponential backoff for 24 hours, then parked in a dead-letter queue you can replay. Endpoints must be public HTTPS; FinLens publishes its egress IPs.
| Event | When | You should |
|---|---|---|
insight.created | A new insight was generated for a persona | Notify / badge the user; fetch and render |
insight.updated | Severity/impact re-ranked as history deepened | Refresh your cached copy |
sync.completed | Backfill finished | Clear progress UI |
grant.requires_relink | Mailbox access revoked/expired at the provider | Show a re-link chip (SDK provides one) |
consent.revoked | User revoked your app's access | Stop showing FinLens data; delete your cached copies |
deletion.completed | A deletion you requested has verified receipts | Confirm to the user |
app.post("/finlens/webhooks", (req, res) => {
// throws on bad signature or stale timestamp
const events = fl.webhooks.verify(req.headers, req.rawBody);
for (const e of events) enqueue(e); // ack fast, process async
res.sendStatus(200);
});
Signatures use rotating secrets (X-FinLens-Signature, timestamped). Deliveries are idempotent — dedupe on event.id. Delivery timing is jittered per partner by design; don't use arrival time to correlate users.
Data tiers
| Tier | What you receive | Availability |
|---|---|---|
| A — Insights (default) | Insight objects, display-safe evidence, aggregate meters (total savings found, category breakdowns) | Standard onboarding |
| B — Normalized | + canonical accounts, transactions, holdings for your consented users | Enhanced review: demonstrated user-facing feature, explicit enumerated consent language, annual re-attestation |
| Never | Raw or verbatim email content, subjects, sender addresses, attachments, mailbox identifiers, statement passwords | Not representable in the API schema — there is nothing to request |
REST endpoints
The SDKs wrap these; use REST directly from any other stack. All requests are HMAC-signed (Authentication).
| Endpoint | Purpose |
|---|---|
POST/connect/tokens | Mint a connect_token (5-min TTL, single use) for one end user |
POST/connect/exchange | Exchange a public_token → { grant_id, persona_id } |
POST/personas/{persona_id}/client_token | Mint a 15-minute read-only JWT for your frontend |
GET/personas/{persona_id}/insights | List insights (?state=new, cursor-paginated) |
POST/insights/{insight_id}/transition | Transition lifecycle state (viewed | acted | dismissed) |
GET/personas/{persona_id}/sync | Sync status; SSE stream with Accept: text/event-stream |
POST/personas/{persona_id}/discovery-scan | Charge discovery_scan; return the typed document catalog |
POST/personas/{persona_id}/parse | Parse selected docIds; charge document_parse per parsed doc |
POST/personas/{persona_id}/unlock-material | Vault the user's PAN/name/DOB to open locked statement PDFs (ephemeral supported) |
GET/partner/wallet | Balance, current rates, recent ledger |
POST/partner/wallet/topup | Create a Razorpay order for a top-up (₹500 min) |
POST/webhooks/razorpay | Razorpay payment.captured → credit wallet (signature-verified) |
POST/personas/{persona_id}/deletion | User-initiated deletion via your UI (verified, receipted) |
POST/webhooks/replay | Re-fetch your events since a timestamp ({ since }) |
Conventions: JSON bodies; errors as { error: { code, message, request_id } } with stable machine codes. Wallet operations are idempotent server-side, keyed on the payment or meter reference.
Not yet available: cursor pagination (next_cursor) and a client-supplied Idempotency-Key header on POSTs. Both were described here before they were built; they are on the roadmap and this note will go when they ship.
SDK matrix
| Package | Platform | Connect mechanism | Notes |
|---|---|---|---|
@finlens/web | Browser | Popup + postMessage (strict origin checks); full-redirect fallback | Core < 12 kB gzip; optional lazy-loaded UI kit (<InsightFeed/>, <SavingsMeter/>, <ConnectButton/>) with theme tokens |
@finlens/react-native | iOS / Android | Custom Tabs / ASWebAuthenticationSession; verified app-link return, state-bound | Survives process death during OAuth; lifecycle events via emitter |
finlens_flutter | Flutter | Same native sessions via platform channels | API mirrors the TS names 1:1 |
@finlens/node | Server | — | HMAC signing, webhook verification, full partner API |
All frontend SDKs are intentionally thin — no secrets, no tokens stored on device, no parsing logic. Releases are rare and semver-disciplined, so you're not chasing store updates.
Sandbox
The sandbox replays a synthetic corpus of representative Indian financial email — bank and card statements (including password-locked PDFs), depository eCAS files, insurance premium notices, mandate alerts, subscription receipts — through the real ingestion, parsing, and insight pipeline. Nothing is mocked except the mailbox itself.
- Fixture personas cover the catalog: a card-revolver, a subscription hoarder, an over-insured household, a regular-plan mutual-fund investor, and a clean-slate user (zero insights — test your empty state).
- Backfill completes in seconds; a
sandbox.triggerAPI can inject "new mail" to fire incremental syncs and webhooks on demand. - Deletion, revocation, re-link, and locked-document flows are all fully simulatable.
Versioning & deprecation
- API versions are date-pinned per partner via the
FinLens-Versionheader; your integration never changes behavior under you. - SDKs follow semver with public-surface diffs gated in CI; breaking changes get a major version and a migration guide.
- Deprecations carry a 12-month window with dashboard + email notice.
- The category taxonomy and
catalog_keyset are additive-only.
Going live
Integrate in sandbox
Most teams complete the full loop — Connect, insights, webhooks, revocation, deletion — in about a day.
Partner review
A short review of your user-facing feature: insights must be shown to the user prominently, consent presentation unmodified, and the Limited-Use flow-down annex signed. Tier B additionally requires a demo of the feature that needs normalized data.
Limited live
Production keys with a capped number of connected users while you validate real-world behavior.
General availability
Cap lifted. Your app is listed on the public trust register with its data tier.
Start now: request sandbox access.