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, exchanges public_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

EnvironmentAPI basePurpose
sandboxhttps://sandbox.api.finlens.inSynthetic mailbox corpus through the real pipeline — no Google involvement, no real mail. Full webhook + deletion support.
productionhttps://api.finlens.inLive 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);
In sandbox, backfill completes in seconds and fixture personas produce deterministic insights — perfect for CI. First production insights typically land within five minutes of consent.

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 + your endUserRef + 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_id is 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

CodeMeaningRecommended handling
user_deniedUser closed or declined the flowOffer to retry later; don't nag
user_denied_scopeCompleted login but unchecked mailbox access — no grant was storedExplain why access is needed; retry with fresh token
expiredconnect_token older than 5 minutes or already usedMint a fresh token and reopen
popup_blockedDesktop popup was blockedFall back to full-page redirect (SDK supports both)
netNetwork failure mid-flowRetry 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

FieldNotes
catalog_keyStable key from the insight catalog (e.g. sub.zombie, ins.lapse_risk). New keys are additive; keys are never repurposed.
severityinfo | low | medium | high — banded per catalog entry.
money_impact_yearlyAnnualized rupee impact, when computable.
framing_tierobserve (facts) or educate (facts + generic math). Compliance-reviewed copy; there is no "recommend product X" tier.
stateLifecycle: new → viewed → acted | dismissed → expired, plus suppressed (user muted this key).
evidenceDisplay-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);
Why derived-and-not-raw: email subjects, filenames and sender addresses are raw Gmail content and never cross to a partner — the API schema has no field for them. "HDFC Bank statement, Mar 2026" is both compliant and cleaner than a raw subject line.

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

MeterUnitBase rate
discovery_scanper mailbox scan₹5 flat
document_parseper successfully parsed document₹5
user_monthper 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`);
});
statusMeaning
backfillingInitial historical sync in progress
liveSteady state — new mail processed as it arrives
paused_quotaProvider quota pressure; deep scan resumes automatically
requires_relinkMailbox access revoked at the provider — open Connect to re-link
paused_userUser 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.

EventWhenYou should
insight.createdA new insight was generated for a personaNotify / badge the user; fetch and render
insight.updatedSeverity/impact re-ranked as history deepenedRefresh your cached copy
sync.completedBackfill finishedClear progress UI
grant.requires_relinkMailbox access revoked/expired at the providerShow a re-link chip (SDK provides one)
consent.revokedUser revoked your app's accessStop showing FinLens data; delete your cached copies
deletion.completedA deletion you requested has verified receiptsConfirm 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

TierWhat you receiveAvailability
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 usersEnhanced review: demonstrated user-facing feature, explicit enumerated consent language, annual re-attestation
NeverRaw or verbatim email content, subjects, sender addresses, attachments, mailbox identifiers, statement passwordsNot representable in the API schema — there is nothing to request
Everything you receive exists to be shown to the user. Silent analytics, ad targeting, credit/lending use, and resale are prohibited by contract and enforced by review — see the Trust page.

REST endpoints

The SDKs wrap these; use REST directly from any other stack. All requests are HMAC-signed (Authentication).

EndpointPurpose
POST/connect/tokensMint a connect_token (5-min TTL, single use) for one end user
POST/connect/exchangeExchange a public_token{ grant_id, persona_id }
POST/personas/{persona_id}/client_tokenMint a 15-minute read-only JWT for your frontend
GET/personas/{persona_id}/insightsList insights (?state=new, cursor-paginated)
POST/insights/{insight_id}/transitionTransition lifecycle state (viewed | acted | dismissed)
GET/personas/{persona_id}/syncSync status; SSE stream with Accept: text/event-stream
POST/personas/{persona_id}/discovery-scanCharge discovery_scan; return the typed document catalog
POST/personas/{persona_id}/parseParse selected docIds; charge document_parse per parsed doc
POST/personas/{persona_id}/unlock-materialVault the user's PAN/name/DOB to open locked statement PDFs (ephemeral supported)
GET/partner/walletBalance, current rates, recent ledger
POST/partner/wallet/topupCreate a Razorpay order for a top-up (₹500 min)
POST/webhooks/razorpayRazorpay payment.captured → credit wallet (signature-verified)
POST/personas/{persona_id}/deletionUser-initiated deletion via your UI (verified, receipted)
POST/webhooks/replayRe-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

PackagePlatformConnect mechanismNotes
@finlens/webBrowserPopup + postMessage (strict origin checks); full-redirect fallbackCore < 12 kB gzip; optional lazy-loaded UI kit (<InsightFeed/>, <SavingsMeter/>, <ConnectButton/>) with theme tokens
@finlens/react-nativeiOS / AndroidCustom Tabs / ASWebAuthenticationSession; verified app-link return, state-boundSurvives process death during OAuth; lifecycle events via emitter
finlens_flutterFlutterSame native sessions via platform channelsAPI mirrors the TS names 1:1
@finlens/nodeServerHMAC 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.trigger API 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-Version header; 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_key set 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.