FinLens Talk to us

Developers

The FinLens API

API version 1 · last updated 27 October 2026
Value Garage Private Limited · CIN U66190DL2025PTC453505

Send your customer one link. Once they approve at Google, ask for their documents when you need them: FinLens then runs fixed searches in their Gmail and lists the emails with files that are likely their financial documents, usually within a few minutes; you choose the files you need, and FinLens fetches only those. Nothing is read until you ask. Twelve endpoints and a health check.

1. Overview

FinLens finds the emails in your customer's Gmail whose files are likely their financial documents and hands you the files you choose, with their consent. Your customer approves read-only Gmail access on Google's own screen. When you ask, FinLens gives you an index: one entry per email its searches found, received or sent, with its date and time, its From and To addresses, its subject line, which search found it, and for each file its name, type, size and a reference — and, on a consent given under version 6 of the disclosure or later, the email's text, the sender's name, Gmail's own check of the sender's domain and the conversation it belongs to. You decide what each email is, send back the references you want, and FinLens fetches only those files from Gmail and delivers each exactly as it was attached. FinLens does not open, read or unlock any file, and uses no AI.

  • What is listed. The emails with a file attached that FinLens's seven fixed searches find — proofs of identity, loan papers, property papers, the latest CAS, fixed-deposit advices, card statements, and files your customer sent to banks, lenders and builders — received or sent, with every file each carries (9.1). The searches are Gmail's own search, run by FinLens with the same terms for every customer and every partner; no request changes them, and no search has a cap. Spam, trash, drafts and chats are never read, and images embedded in an email's body — logos, signature images — are not listed. A first index is usually ready within a few minutes of your asking for it. It is written in pages as it is read, and you can pull each page as soon as it is written (9.2).
  • Nothing is read until you ask. Your customer's approval records the consent and reads nothing: FinLens sends consent.granted (11) and the consent waits as awaiting_request (7). Your first POST /v1/sessions/:id/refresh on the consent starts its first check (12.1). A consent lasts up to six months (12).
  • What is not. FinLens keeps no list of institutions to trust, opens no file to recognise a document, and checks no sender itself: an entry's senderCheck is Gmail's own verdict, reported as is. Which emails matter is yours to judge from the index.
  • Base URL: https://api.finlenstech.com. Every route is under /v1/ except the health check.
  • Credentials — an API key id and secret — are issued when your partner agreement is signed. There is no self-serve signup and no sandbox.
  • No SDK is needed. One request is signed with HMAC-SHA256 (Authentication); everything else uses a bearer token.
  • Scan what you fetch. FinLens verifies no sender, so a file may be phishing or malware whatever its name or type says. Scan every file before you open it or pass it on (Partner Terms §3.5).
  • Version 1 can still change before the first production integration, as the Partner Terms say; after that it only grows. Ignore response fields you do not recognise, and treat an unrecognised enum value as one you cannot act on.

What changed in this version

A new consent now lasts six months, and FinLens reads your customer's Gmail only when you ask; before that, FinLens's searches came to choose what the index lists, the index lost its size limit and came in pages, and one simple index replaced the verified-institution list. Each change below is to version 1 in place.

  • A consent lasts six months (27 October 2026), and FinLens reads only when you ask. Two changes, from this date, for every consent your customer approves. Approval reads nothing: FinLens records the consent, its session waits as awaiting_request (7), and a new event, consent.granted, tells you (11). The first index is made only when you ask for it: your first POST /v1/sessions/:id/refresh on the consent starts the consent's own first check, answers 202 with the consent's own id, and is never refused as too soon (12.1); the consent's session then goes scanning, scan_complete and ready, and session.ready follows with kind "initial", as before. Later refreshes are unchanged. An integration that waits for the list after approval must change: Migrating an existing integration, below. And a consent lasts 180 days, where it lasted 365: its expiresAt is 180 days after approval at most, and earlier when your customer chose time-limited access on Google's screen and Google's access ends first (8.4, 12). The standard disclosure is version 8: version 6, word for word, but for two things — "6 months" for "12 months", and that FinLens looks in your customer's Gmail when you ask for their documents, the list usually ready within a few minutes of your asking. Show it for every link from this date. A consent approved before it keeps its 365 days, unless something ends it sooner, and is otherwise unaffected: its first index was made at approval. Nothing else changes: the index, the fetch, later refreshes and the errors are as they were.
  • The index lists what FinLens's searches find (20 October 2026). FinLens no longer lists every email with a file attached: it runs seven fixed Gmail searches — proofs of identity, loan papers, property papers, the latest CAS, fixed-deposit advices, card statements, and files your customer sent to banks, lenders and builders — the same for everyone, and lists every email they find, with no cap (9.1). A first index is usually ready within a few minutes: Gmail is read at about 98 emails a minute (9.8). Entries come search by search, newest first within each, no longer received mail and then sent (9). Every entry carries matchedBy, the searches that found it; an entry of a consent given under version 6 also carries the email's text (bodyText, with bodyTruncated and bodyUnavailable), the sender's display name (sentByName), Gmail's own DKIM, SPF and DMARC verdict on received mail (senderCheck) and a conversation reference (threadRef) (9.4). A refresh runs the same searches over new email (12.1). None of these is on any other response: the status, the summary and the events are unchanged.
  • Which pages to pull first, and when an index counts as pulled (17 October 2026). The session's index block, every page served and session.ready carry generation — the read the pages belong to; a new one means start again from page 1 — and earliestPageExpiresAt, the soonest a page still held is deleted (8.5). A page that cannot be read says why, in reason: a page deleted at the end of its three hours is expired, to skip (5). An index counts as pulled once its last page is served after it is complete, which it is only once the check is ready (9.2). A refresh or a fetch waits up to six hours for a worker, and a first check up to twenty-four (10.4).
  • The index is served in pages, each as soon as it is written (18 October 2026). GET /v1/sessions/:id/index takes ?page=N and answers one page of up to 500 emails, with page, pageExpiresAt, pagesWritten, complete, pageCount and entryCount; a page can be pulled while the check is still scanning, for three hours from its own write. The response's indexExpiresAt is replaced by each page's pageExpiresAt. A page that cannot be read is 404 index_page_not_found; junk in the query is 400 invalid_request (9.2).
  • No cap on the index. The former cap on messages read and on the index's size is gone (9.8); since FinLens's searches, what a check reads is what they find.
  • Progress on the status. A check's status carries an index block — pagesWritten, complete, and once complete pageCount and entryCount (8.5) — and its summary carries the same four (8.1).
  • An entry is an email, from anyone, received or sent, with every file it carries, of any type. An entry no longer carries institution, category, documentType or institutions, and a file's contentType is the type its email declared (9.5).
  • Session creation needs only externalUserRef and returnUrl. documentTypes and namedInstitutions are deprecated: still accepted, and ignored (6.2). unknown_institution is no longer sent, and the response's resolvedInstitutions is always empty.
  • A fetch carries references only. FinLens no longer opens password-protected PDFs and accepts no identity: a fetch with an identity is refused with 400 invalid_request (10.1).
  • Every file is delivered exactly as received; a locked PDF arrives locked. A delivered document no longer carries institution, category, documentType, sender, senderVerified, recipient, encrypted, unlock or originalSha256; it carries sentBy and attachmentName, as the index listed them (10.6).
  • Counts: the index summary drops senderAuthenticated, and two drop reasons remain; the fetch summary drops decryptedCount and lockedCount, and three outcomes of a reference — out_of_scope, not_a_document and unreadable — are no longer given (10.3); a fetch's status drops documentCounts, and its documents drop institution, category, documentType, encrypted and unlockState.
  • The standard disclosure is version 8. It says FinLens looks for emails with files that are likely your customer's financial documents, with fixed searches that are the same for everyone; that the list gives, for each email, the sender's name, the text of the email, whether Gmail confirmed it came from the sender's domain and which emails belong to the same conversation, as well as what it gave before; that FinLens looks when you ask for your customer's documents, and the list is usually ready within a few minutes of your asking; and that your customer's permission lasts 6 months. Version 6 said all of that but when FinLens looks, and with 12 months; consents given on it keep their twelve months. Consents given on versions 4 and 5 continue: their refreshes run the same searches, and their entries carry none of the email's text, the sender's name, Gmail's verdict or the conversation. Consents given under versions 2 and 3 ended when version 4 was published: they covered a narrower list. Ask those customers again with a new link (obligations).
  • Webhook hosts: the hosts FinLens calls itself, which no endpoint may use, are Google's three (11.1).

Migrating an existing integration

An integration built for the list arriving after your customer approves — one that polls the consent's session until it is ready, or waits for its session.ready — waits for ever on a consent approved from the date of version 8 of the disclosure: FinLens reads nothing until you ask. Three changes:

  1. Learn of the approval. Handle consent.granted (11), or poll the consent's session until its status is awaiting_request (7). If you registered your endpoint with a list of eventTypes, the list does not include consent.granted: a list is exhaustive. To receive it, de-register the endpoint and register it again with consent.granted added, or with no list, which means every event (11.1, 11.5) — or poll.
  2. Ask for the list, once, when you need it. Call POST /v1/sessions/:id/refresh with the body {} on the consent's own session. On a consent that is awaiting_request this starts the consent's first check and answers 202 with the consent's own sessionId, kind "initial", status "scanning" and windowAfter null; it is never refused as refresh_too_soon (12.1). Keep polling, and pulling, the consent's own session: the first call creates no new one.
  3. Expect session.ready only after that call. The consent's first index — its pages, then session.ready with kind "initial" — comes only once you have asked. Everything after it is unchanged: pull the pages (9.2), fetch (10), and refresh later at most once an hour, each refresh a session of its own, as before (12.1).

Ask when your feature needs the documents — straight after consent.granted if it needs them at once. A consent you never ask about reads nothing and ends at its term, or sooner (12). A consent approved before version 8 is unaffected: its first index was made at approval, and a refresh on it starts a refresh session, as before. Show version 8 of the standard disclosure, whose words say FinLens looks when you ask (obligations).

2. How an integration works

1 · in your app

Show the disclosure

Show your customer FinLens's standard disclosure, version 8, filled in with your registered name.

2 · post /v1/sessions

Send the link

Create a session with your reference for the customer and a return URL. You get a session id and a consentUrl, valid for 60 minutes. Send it to your customer.

3 · at google, then back to you

They approve

The link goes straight to Google's consent screen. Granting read-only Gmail access there is the consent; their browser returns to your returnUrl. FinLens reads nothing yet: you receive consent.granted, and the session waits as awaiting_request. A customer who declines can try again on the same link while it is valid.

4 · post /v1/sessions/:id/refresh

Ask for the list

When you need the documents, call it once on the consent. FinLens runs its searches and lists the emails they find, writing the index a page at a time — usually all of it within a few minutes. Poll the session to see pages as they are written; session.ready says the last one is.

5 · get /v1/sessions/:id/index?page=n

Pull the index

Page by page, each as soon as it is written and within three hours of its write, until the index is complete. Keep the references you may want: they work until the consent ends. Pulling its last page once it is complete is what moves the next refresh on.

6 · post /v1/sessions/:id/fetch

Fetch

Send up to 50 references, on any day while the consent lasts. You get a fetch session id.

7 · get /v1/sessions/:fetchId/documents

Pull the files

When the fetch is ready, pull its files within three hours, and scan each one. After that FinLens deletes them.

8 · post /v1/sessions/:id/refresh

Check again

At most once an hour, for up to six months, a refresh lists email newer than the start of the last check whose index you pulled, as a new index.

9 · delete /v1/sessions/:id/consent

Withdraw when asked

When your customer withdraws in your app. Every reference from that consent stops working at once, and any index or file of it not yet pulled is deleted.

Your obligations

FinLens shows your customer no consent page of its own: approval at Google is the consent, and a FinLens page appears only when something stops the way back to you (6.3). So the Partner Terms make these yours:

  • Show the standard disclosure before every link — for every session, not once per customer. The text is fixed and published at finlenstech.com/consent-disclosure (also Schedule 1 of the Partner Terms). Show version 8 for every link you send from the date on its page; no earlier version may be shown. A consent your customer gave on version 6 keeps its twelve months. A consent your customer gave on version 4 or 5 continues. A consent your customer gave on version 2 or 3 has ended; it needs a new link, under version 8. Fill in only your registered name; show it in full, unaltered, on the screen from which your customer continues to Google (Partner Terms).
  • Ask for the list when your feature needs it. FinLens reads your customer's Gmail only when you ask (12.1): ask for a consent's first index, a refresh or files only to provide the service you described (Partner Terms).
  • Ask only for the files you need, and use them only for the service you described — the index and the files alike (Partner Terms). The index names other people: the senders and recipients of your customer's email.
  • Protect the email's text. On a consent given under version 6 or later each entry carries the email's text, as the sender wrote it: it may contain passwords, one-time codes, account numbers or other sensitive data. Hold it as securely as the files, never write it to a log, and use it only to work with your customer's own documents (9.9).
  • Scan every file you fetch for malware before you open it or pass it on. No sender is verified (Partner Terms).
  • Let your customer withdraw in your app, as easily as they agreed, and call DELETE /v1/sessions/:id/consent straight away when they do (Partner Terms).
  • Take a refusal as a refusal. You may offer the same link again while it is valid, but not press your customer.

Every route

RouteAuthWhat it does
POST /v1/tokensSignedExchange your API key for a bearer token
DELETE /v1/tokens/currentBearerRevoke the token you present
POST /v1/sessionsBearerCreate a session and its consent link
GET /v1/sessions/:idBearerStatus and counts of any session, shaped by its kind; on the consent's session, whether the consent is live
GET /v1/sessions/:id/indexBearerOne page of a check's index (the consent's session, or a refresh), ?page=N
POST /v1/sessions/:id/fetchBearerFetch files by reference, under the consent
GET /v1/sessions/:id/documentsBearerThe files of a fetch
POST /v1/sessions/:id/refreshBearerAsk for the consent's first index, then check again for new email, under the consent
DELETE /v1/sessions/:id/consentBearerWithdraw a consent
POST /v1/webhooksBearerRegister your webhook endpoint
POST /v1/webhooks/:webhookId/rotateBearerReplace the webhook signing secret
DELETE /v1/webhooks/:webhookIdBearerDe-register the endpoint
GET /healthzNoneLiveness

Three kinds of session share one id space. The session your customer approved (kind "initial") holds the consent: fetch, refresh and withdraw all take its id. A refresh ("refresh") is a later check. A fetch ("fetch") delivers files. Checks are pulled at …/index, fetches at …/documents; the other route answers 409 wrong_kind. There is no endpoint to list sessions or webhooks, to fetch one file by id, or to edit a webhook in place. Keep your own record of the ids you are given.

3. Conventions

ThingRule
Content typeapplication/json, UTF-8. Send it when there is a body: a body must be application/json, and one sent with any other content type, or none, is refused with 415 unsupported_media_type.
Body size64 KiB at most. Larger: 413 payload_too_large. Fifty references of the longest size fit easily.
Unknown fieldsRejected with 400 invalid_request. Every request schema is strict.
TimestampsRFC 3339 with an offset, e.g. 2026-01-15T09:14:22.000Z. An index entry's emailAt is always whole seconds in UTC, with no fraction.
DatesYYYY-MM-DD
Ids and referencesOpaque strings. Do not parse them.
Rate limit60 requests per 60 seconds per credential (see Retention and limits), shared by every call made with it: status polls, every page of every index, fetches and pulls. Every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset. Over it: 429 rate_limited, with Retry-After in seconds: wait that long, then send the same request again. A large index is many pages, so pace its pages under the limit (9.2) and pull each page once.
BusyWhen FinLens is already assembling other large responses, GET …/index and GET …/documents may answer 503 service_busy, with Retry-After in seconds. Pull again after that many seconds: the three-hour window keeps running meanwhile.
Request idsGenerated by FinLens and returned in every error body. Quote one in a support request.
Response headerscache-control: no-store, x-content-type-options: nosniff, referrer-policy: no-referrer.
VersioningIn the path (/v1/). There is no version header.

4. Authentication

You hold an API key: a key id (not secret) and a secret. The key signs exactly one request, POST /v1/tokens, which returns a short-lived bearer token. Every other /v1/ route takes the token and refuses a signed request. Your secret never travels on a data request, and a token expires on its own and can be revoked without rotating your key.

RouteAuthenticate with
POST /v1/tokensA signed request (4.1 to 4.5)
Every other /v1/ routeauthorization: Bearer <token> (4.6)
GET /healthzNothing

4.1 Signing headers

All four are required on the token request.

HeaderValue
x-finlens-key-idYour API key id.
x-finlens-timestampThe current time in epoch milliseconds, as a decimal string.
x-finlens-nonceA value you never reuse within five minutes. 16 random bytes, base64url, works well.
x-finlens-signaturev1= followed by 64 lowercase hex characters.

4.2 The canonical string

Five parts joined by a single newline (\n), with no trailing newline:

METHOD \n PATH \n TIMESTAMP \n NONCE \n SHA256_HEX(BODY)
  • METHOD — uppercase: POST.
  • PATH — the path only, without the query string: /v1/tokens.
  • TIMESTAMP and NONCE — exactly the strings you send in the headers.
  • SHA256_HEX(BODY) — SHA-256 of the exact body bytes you send, as lowercase hex. With no body, hash the empty string (e3b0c442…b855).

Serialize the body once and send the same string you hashed. Re-serializing changes key order or whitespace, and the request fails as an ordinary 401.

4.3 The key

We issue your key id and API secret when your account is set up. The secret is shown once, so store it where your servers can read it and nothing else can.

The HMAC key is the SHA-256 of your API secret written as 64 lowercase hex characters, and those 64 characters are the key. Do not decode them to 32 bytes, and do not use the secret itself.

key       = lowercase_hex( SHA256( API_SECRET ) )      # a 64-character string
signature = "v1=" + lowercase_hex( HMAC_SHA256( key, canonical_string ) )

4.4 Example

// Node 18+, no dependencies.
import { createHash, createHmac, randomBytes } from "node:crypto";

function signedHeaders(keyId, secret, method, path, body = "") {
  const timestamp = String(Date.now());
  const nonce = randomBytes(16).toString("base64url");
  const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
  const canonical = [method.toUpperCase(), path, timestamp, nonce, bodyHash].join("\n");
  const key = createHash("sha256").update(secret, "utf8").digest("hex");
  const mac = createHmac("sha256", key).update(canonical, "utf8").digest("hex");
  return {
    "x-finlens-key-id": keyId,
    "x-finlens-timestamp": timestamp,
    "x-finlens-nonce": nonce,
    "x-finlens-signature": `v1=${mac}`,
  };
}

// 1. Get a token: the only signed request.
const tokenBody = JSON.stringify({ ttlSeconds: 3600 });
const token = await fetch("https://api.finlenstech.com/v1/tokens", {
  method: "POST",
  headers: { ...signedHeaders(KEY_ID, SECRET, "POST", "/v1/tokens", tokenBody),
             "content-type": "application/json" },
  body: tokenBody,                             // the same string that was hashed
}).then((r) => r.json());

// 2. Use it everywhere else. Nothing below is signed.
const res = await fetch("https://api.finlenstech.com/v1/sessions/" + sessionId, {
  headers: { authorization: `Bearer ${token.bearerToken}` },
});

The same signing in shell, with openssl. Our reference shell script wraps both steps; ask for it at developers@finlenstech.com.

BODY='{}'
KEY=$(printf '%s' "$FINLENS_API_SECRET" | openssl dgst -sha256 -r | cut -d' ' -f1)
TS=$(python3 -c 'import time; print(int(time.time()*1000))')
NONCE=$(python3 -c 'import uuid; print(uuid.uuid4())')
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -r | cut -d' ' -f1)
SIG=$(printf 'POST\n/v1/tokens\n%s\n%s\n%s' "$TS" "$NONCE" "$HASH" \
      | openssl dgst -sha256 -hmac "$KEY" -r | cut -d' ' -f1)

curl -sS -X POST https://api.finlenstech.com/v1/tokens \
  -H "content-type: application/json" \
  -H "x-finlens-key-id: $FINLENS_API_KEY_ID" \
  -H "x-finlens-timestamp: $TS" \
  -H "x-finlens-nonce: $NONCE" \
  -H "x-finlens-signature: v1=$SIG" \
  --data-binary "$BODY"

4.5 What gets rejected

Every authentication failure returns the same 401 unauthorized with the same message, whatever the cause: a missing header, an unknown or revoked key id, a malformed signature, a timestamp more than five minutes from our clock in either direction, a wrong MAC, or a nonce already used inside that window. The specific reason is logged against the request id, so quote it if you need help. Keep your clock synchronised (NTP).

Once your credential has verified, a suspended account gets 403 client_suspended instead, on every route.

4.6 Bearer tokens

POST/v1/tokens

Signed as above. Send no body or {} for the default lifetime of one hour, or {"ttlSeconds": n} with n from 60 to 86400. Nothing else is accepted.

Response — 201 Created

FieldTypeNotes
tokenTypestringAlways "Bearer".
bearerTokenstringThe token. Opaque; not a JWT.
keyIdstringThe API key that obtained it. Revoking the key revokes every token it obtained.
expiresAttimestampWhen it stops working.
{
  "tokenType": "Bearer",
  "bearerToken": "at_Xq3…",
  "keyId": "ak_8f0c8fdc8be920728d332ed5",
  "expiresAt": "2026-01-15T10:14:22.000Z"
}
  • Keep it secret and do not log it. FinLens stores only a digest, so it cannot be shown again.
  • Use it as authorization: Bearer <token> on every other call.
  • Renew early. Get a new token a minute or so before expiresAt. Old and new tokens both work until each expires, so overlap is harmless.
  • A missing or malformed header, a signed request where a token is wanted, and an unknown, expired or revoked token all return 401 unauthorized.

DELETE/v1/tokens/current

Revokes the token this request presents, at once. Returns 204 No Content. Use it when a process is finished with its token, or if a token may have been seen. To rotate your API key, ask us for a second one: both work until you tell us to revoke the old key. To revoke a key, and every token it obtained, contact us.

5. Errors

Every error has the same shape:

{
  "error": {
    "code": "not_found",
    "message": "The requested resource does not exist.",
    "requestId": "0f2c9a1e-6b4d-4f7a-9c31-2ad5e8b0c744"
  }
}

Branch on code. The message is a fixed sentence per code — and, for invalid_request, one more fixed sentence when a fetch carries an identity (10.1), and for index_page_not_found one per reason. Responses never carry field-level validation detail, which is logged against the requestId instead.

CodeHTTPWhen
invalid_request400The body failed validation: malformed JSON, a missing or unknown field, a bad value, an unsafe returnUrl or webhook URL, webhookId in a rotate body, a fetch carrying an identity, or a fetch whose references are not 1 to 50 distinct strings or open to the same file twice. Also a malformed request: a URL that does not decode, a body that does not match its Content-Length, or an index query that is not one page number written plainly (9.2).
invalid_reference400A fetch named a reference that does not open for this consent: altered, from another consent, or from another account. Carries referenceIndexes. (A string not shaped like a reference at all is invalid_request.) Nothing is fetched.
fetch_too_large400A fetch whose chosen files add up, by the sizeBytes the index gave each, to more than 32 MB. Nothing is fetched. Fetch them in smaller groups (10.2).
unauthorized401Any authentication failure (Authentication).
client_suspended403Your account is suspended. Contact us.
not_found404No such route, session or webhook in your account — including another account's id, a de-registered webhook, and a refresh's or a fetch's id where the consent's own session id is needed. All look the same.
index_page_not_found404GET …/index?page=N named a page that cannot be read. Carries reason: not_written (past pagesWritten while the check is still listing — wait for it), past_last_page (past pageCount, once complete — stop), expired (deleted at the end of its own three hours — skip it and read on), or nothing_to_read (past page 1 when no page is held). The session is there; the page is not (9.2).
conflict409You already have a registered webhook endpoint.
consent_not_active409A fetch or a refresh against a consent that is not live: never granted, withdrawn, expired, or its Google access gone. Also any pull — GET …/index or GET …/documents — of a session whose consent has ended, whatever the session's state: what it held was deleted when the consent ended (12.2). Only a new consent helps.
check_in_progress409A refresh was asked for while a check of this consent — the first check, a refresh or a fetch — is queued or running, or a fetch while another fetch of it is. A fetch is never refused because the index is still listing. Poll that one, then try again.
wrong_kind409The session exists, but is pulled at the other route: …/documents on a check, or …/index on a fetch.
payload_too_large413The body is over 64 KiB.
unsupported_media_type415The body is not application/json: another content type, or none. A request body must be application/json; this is checked before authentication.
rate_limited429Over the rate limit.
refresh_too_soon429The consent's last check started less than an hour ago. Carries retryAfterSeconds. Never sent for a fetch, or for the call that starts a consent's first check (12.1).
internal_error500Our fault. Quote the requestId.
service_busy503On GET …/index or GET …/documents only: FinLens is already assembling other large responses. Carries a Retry-After header; pull again after that many seconds.

Three codes add a field inside the error object. refresh_too_soon adds retryAfterSeconds — whole seconds, at least 1 — and the response carries a Retry-After header with the same number; wait that long once, rather than retrying in a loop. invalid_reference adds referenceIndexes: the positions in your request's references array, counted from 0, of each reference refused — positions, never the references themselves. Drop those and send the rest again. index_page_not_found adds reason, one of four, each with its own fixed sentence:

  • not_written — the check is still listing and has not written that page yet. Poll the session and pull it once pagesWritten reaches it.
  • past_last_page — the index is complete and that page is past pageCount. Nothing more is coming.
  • expired — the page was written and deleted at the end of its own three hours. It is gone: note it, skip it and pull the next page, which may still be there.
  • nothing_to_read — the check holds no page: not started, none written yet, or every page past its three hours. Page 1 says the check's status.

service_busy adds no field: its response carries a Retry-After header, in seconds, as rate_limited's does. Wait that long, then pull again. Treat any code not listed here as an error you cannot act on.

400 Bad Request
{
  "error": {
    "code": "invalid_reference",
    "message": "One or more references did not open for this consent. referenceIndexes lists their positions in the request.",
    "requestId": "7c1d2f0a-3e9b-4c55-8a17-5b0e9d2c41f6",
    "referenceIndexes": [1]
  }
}

429 Too Many Requests
{
  "error": {
    "code": "refresh_too_soon",
    "message": "The last check under this consent started too recently. Retry after retryAfterSeconds.",
    "requestId": "a4e09b77-1c2d-4f8e-9b60-3d7f5a2c8e91",
    "retryAfterSeconds": 1520
  }
}

404 Not Found — GET …/index?page=1 of a long first index, its page 1 past its three hours
{
  "error": {
    "code": "index_page_not_found",
    "message": "That page of the index was deleted at the end of its own three hours. Skip it: the pages after it may still be read.",
    "requestId": "c3b81e5d-0a47-4f29-8d6e-91f2a7c4b058",
    "reason": "expired"
  }
}

6. Create a session

POST/v1/sessions

Creates a session and its consent link. Nothing is read from any mailbox until your customer approves at Google and you then ask for the list (12.1). Show the standard disclosure before you send the link.

6.1 Request

FieldTypeNotes
externalUserRefstring · required1–128 characters. Your own reference for this customer. Echoed on every webhook event.
returnUrlstring · requiredWhere your customer's browser goes after Google. See returnUrl.

There is nothing to ask for. Every check runs the same fixed searches (9.1), the same for every customer and every partner, and nothing in a request changes them; you choose from the index afterwards. No identity is accepted here, or anywhere: an identity field is refused with 400 invalid_request, like any unknown field.

6.2 Deprecated fields

Two fields an earlier version required are still accepted, and ignored, so a request built for it keeps working. Each may be any array of strings; nothing in it is checked, stored, echoed or read. They will be removed in a later version of the API, announced on this page beforehand: stop sending them.

FieldTypeNotes
documentTypesstring[] · deprecatedIgnored. What the index lists is chosen by FinLens's fixed searches (9.1), the same for everyone: no request narrows or widens them.
namedInstitutionsstring[] · deprecatedIgnored. FinLens keeps no list of institutions, so a name is never resolved and never refused.

6.3 returnUrl

  • At most 2048 characters.
  • https:// only — except http://localhost, with an optional port, for local testing.
  • No credentials (user:pass@), no IP addresses in either family, and no .local host names.

What arrives there. When your customer approves at Google, their browser is sent to returnUrl exactly as you gave it, with a 302. Nothing is added — no session id and no outcome — so put your own reference in the URL if you need one, and learn the outcome from GET /v1/sessions/:id or your webhook (consent.granted, 11). If the URL no longer passes the checks above when they return, FinLens shows a plain page saying they are connected and can close it.

If they decline at Google, or untick Gmail access, FinLens shows a page saying nothing was shared, with two choices: Try again, which asks Google again, and Return to your name, a link to returnUrl. Once the link has expired, only the way back is offered.

A link that can no longer be used, because it was already approved, has expired or was withdrawn, opens a short FinLens page saying so, with no way back to your app: give your customer a new link from your app. A link opened many times in a short while asks them to wait a moment.

On mobile, open consentUrl in the system browser or an in-app browser tab, not an embedded web view: Google refuses sign-in inside embedded web views. Return to an https app link or universal link; custom schemes such as myapp:// are refused.

6.4 Response — 201 Created

FieldTypeNotes
sessionIdstringUse it on every other session call. On a granted session it is also the consent's id: fetch, refresh and withdraw take it.
statusstringAlways "created".
consentUrlstringhttps://connect.finlenstech.com/l/<token>. Each open redirects straight to Google's consent screen, so it can be reopened — after a decline, say — until it expires. Opening it more than once is safe: a double tap, a reload or a second tab each start an attempt, the latest five all stay valid, and the first one your customer answers at Google counts — the others then open a page saying the link can't be used. Anyone holding it can start a consent for this session, so send it only to your customer.
expiresInintegerSeconds the link stays valid: 3600, and never more. The clock starts now, not when your customer opens it, and a decline does not extend it.
resolvedInstitutionsarray · deprecatedAlways []. Removed with the two deprecated request fields.
scanScopeobject · deprecatedAlways { "history": "whole", "namedInstitutions": [] }: no institution named. Its history value is kept as it was and no longer describes the read: each of FinLens's searches has its own date window (9.1). Removed with the two deprecated request fields.

Create the link when you are about to show it. A link minted early can expire before it is opened. Its window bounds your customer's approval, not the list: the first check starts when you ask for it, at any time while the consent lasts (12.1).

Send your customer with a plain link or a redirect. If your page sends them by submitting a form and your Content-Security-Policy sets form-action, allow https://connect.finlenstech.com and https://accounts.google.com in it: browsers apply that directive to every redirect after a form is submitted, and the link is the first hop. A plain link needs nothing.

There is no idempotency key. Each successful call creates a new session and a new link, so a retry after a timeout can leave two. The one your customer never opens expires with its link after 60 minutes and is never read.

6.5 Example

POST /v1/sessions
{
  "externalUserRef": "cust_88213",
  "returnUrl": "https://app.example.com/finlens/done"
}

201 Created
{
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "status": "created",
  "consentUrl": "https://connect.finlenstech.com/l/QYzgj1EsIg4IkMDv_Tsu1T1w-4lmtDgJ6VsUUkEhI6I",
  "expiresIn": 3600,
  "resolvedInstitutions": [],
  "scanScope": {
    "history": "whole",
    "namedInstitutions": []
  }
}

7. Session states

Eleven states, shared by the three kinds of session. Every status response carries a terminal boolean: branch on it rather than on a hard-coded list.

StatusTerminalMeaning, and what to do
createdNoReturned by POST /v1/sessions only; a poll never shows it.
consent_pendingNoThe consent's session only. Waiting on your customer, whether or not they have opened the link — opening it changes nothing you can see. Keep polling: their approval makes it awaiting_request.
consent_declinedNoThey said no at Google. Not the end: until the link expires they can try again on the same link, and the session returns to consent_pending if they do. Keep polling.
consent_partialNoThey signed in but unticked Gmail access. Their choice, not an outage; retryable on the same link, as above. Keep polling.
link_expiredYesThe link's hour ran out with no consent: never opened, or declined and not retried.
awaiting_requestNoThe consent's session only. Your customer approved and the consent is recorded, and nothing has been read: FinLens waits for you. Call POST /v1/sessions/:id/refresh once on this session when you want the list; that starts its first check, and the session goes to scanning (12.1). It waits for as long as the consent lasts, reading nothing; its consent block says whether the consent is still live (8.4). If the consent ends before you ask for the list, the session becomes purged; nothing was read.
scanningNoA check is listing the mailbox, or a fetch is reading its files. A check has no time limit; a first index is usually ready within a few minutes. Each page of its index can be pulled as soon as it is written, while the check is still scanning (9.2). A refresh and a fetch show scanning from the moment they are created, and a consent's first check from the call that asks for it.
scan_completeNoA check only: its counts are known and its last page is being written. summary appears from here; the pages already written can still be pulled. A fetch goes from scanning to ready without it.
readyNoA check's index is complete, or a fetch's files can be pulled. Each page of an index can be pulled for three hours from its own write, a fetch's files for three hours from ready. Then the session becomes purged — at once if the consent ends first. Pull what is left, and stop polling.
purgedYesThe index or the files are deleted: the three hours are over, or the consent ended before they were. Counts remain. On its own says nothing about the consent, which may still be live. A consent's own session also comes here straight from awaiting_request. If the consent ends before you ask for the list, the session becomes purged; nothing was read.
failedYesThe check or fetch could not complete. failureReason says why. Nothing from it is served: a failed check has no index, a failed fetch no files.

A consent's session that is awaiting_request waits for you: until you ask for its list it reads nothing, and its consent block says whether the consent is still live. If the consent ends before you ask for the list, the session becomes purged; nothing was read. Every other session reaches a terminal state on its own. A declined session that is not retried becomes link_expired when the link expires, and a ready session becomes purged three hours later — including a check that listed nothing and a fetch that delivered nothing — or as soon as its consent ends. Do not create a second session while the first is still running: that asks your customer to consent twice. And a terminal session is not an ended consent: whether you may still fetch or refresh is the consent block's to say.

KindIts states, in orderPulled at
initialconsent_pending (and the declines) → awaiting_request → (your first refresh call) scanning → scan_complete → ready → purged; or link_expired, or failed. If the consent ends before you ask for the list, the session becomes purged; nothing was read.…/index
refreshscanning → scan_complete → ready → purged; or failed…/index
fetchscanning → ready → purged; or failed…/documents

Failure reasons

Present exactly when status is failed. A decline or an expired link is a status, never a failure; a mailbox in which the searches find nothing is ready with an empty index, and a fetch whose every reference failed is ready with no files. A session that ends failed serves nothing, whatever it had read before it stopped.

ReasonWhat it means for you
gmail_unavailableGoogle could not be reached, or Gmail refused one of FinLens's searches: a check runs all seven or fails, and never lists part of them as the whole. Ours to fix; a retry may hit the same problem.
quota_exhaustedGmail's API limits stopped the check or fetch: its daily limit, or its per-minute limit through repeated waits. Retry later — with a refresh or another fetch, if the consent is live.
internal_errorOur error, unclassified. Also used when a first check was not started within twenty-four hours of your asking for it, a refresh or fetch was not picked up within six hours (10.4), or a check or fetch was started three times and each time stopped without finishing (FinLens does not start it a fourth time). A refresh or another fetch may succeed, if the consent is live.
consent_revokedThe consent this session ran under is gone: your customer removed FinLens in their Google account, changed their Google password, did not use it for six months, or chose time-limited access at Google and did not renew it — or another of their FinLens consents ended (12.2), or the consent ended before the session started or while it was running. Nothing from the session is served. Not retryable; only a new consent helps.
scan_timeoutNo longer produced. It can still appear on an old session.

8. Read a session

GET/v1/sessions/:id

Any session's state and counts, shaped by its kind. Read kind first: a check's summary counts emails listed, a fetch's counts references delivered. It never carries a subject line, a file name, an address or a Gmail id: those are on the index and the delivered files alone.

Prefer the webhook: session.ready tells you when to pull. If you poll, read each session at most once every 30 seconds — the rate limit is shared by every session your token reads, and by every page you pull — and stop at ready or at a terminal state. A consent's session that is awaiting_request is waiting for you, not for FinLens: ask for its list (12.1) rather than poll it. Poll the session for progress; pull a page only when it is new to you (9.2).

FieldTypeKindsWhen present
sessionIdstringinitial, refresh, fetchAlways.
kindstringinitial, refresh, fetchAlways: "initial", "refresh" or "fetch".
parentSessionIdstringrefresh, fetchAlways on these two: the consent's own session.
statusstringinitial, refresh, fetchAlways. One of the eleven states.
terminalbooleaninitial, refresh, fetchAlways.
createdAttimestampinitial, refresh, fetchAlways.
failureReasonstringinitial, refresh, fetchExactly when status is failed.
consentobjectinitialOnce your customer has approved. See 8.4.
indexobjectinitial, refreshFrom the first page written — while the check is still scanning — until its last page is deleted: how far the index has got, which read its pages belong to and when the first of them goes. See 8.5.
indexExpiresAttimestampinitial, refreshWith index: when the last page written so far is deleted, three hours after its write. Each page's own deadline is on the page.
summaryobjectinitial, refresh, fetchA check's index summary from scan_complete, or a fetch's fetch summary from ready; a fetch that failed may have none. Kept after purged.
artifactsExpireAttimestampfetchWhile there are files: when they are deleted, three hours after ready.
documentsobject[]fetchFrom ready (8.3). Omitted entirely once purged.
200 OK — the consent's session, its first check ready
{
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "kind": "initial",
  "status": "ready",
  "terminal": false,
  "createdAt": "2026-01-15T09:01:10.000Z",
  "consent": { "status": "active", "expiresAt": "2026-07-14T09:03:41.000Z" },
  "index": {
    "pagesWritten": 1, "complete": true, "pageCount": 1, "entryCount": 412,
    "generation": 1, "earliestPageExpiresAt": "2026-01-15T12:14:22.000Z"
  },
  "indexExpiresAt": "2026-01-15T12:14:22.000Z",
  "summary": {
    "fetched": 439, "receivedIndexed": 371, "sentIndexed": 41,
    "entryCount": 412, "attachmentCount": 655,
    "pagesWritten": 1, "complete": true, "pageCount": 1,
    "dropped": [
      { "reason": "no_attachment", "count": 25 },
      { "reason": "unreadable_headers", "count": 2 }
    ],
    "truncated": false
  }
}

200 OK — a fetch, ready
{
  "sessionId": "ses_c1b8f0e2a94d7735e6a0b913",
  "kind": "fetch",
  "parentSessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "status": "ready",
  "terminal": false,
  "createdAt": "2026-01-15T10:02:00.000Z",
  "artifactsExpireAt": "2026-01-15T13:02:41.000Z",
  "summary": {
    "requested": 3, "deliveredCount": 2,
    "items": [
      { "position": 0, "outcome": "delivered" },
      { "position": 1, "outcome": "delivered" },
      { "position": 2, "outcome": "email_not_found" }
    ]
  },
  "documents": [
    { "documentId": "doc_6a49b922c10a7a2d", "position": 0,
      "reference": "flr_dHVPZMEQHGcTTdrNz78OwDpxeY6wkzwJj3Mad9GNr30PJ2eCs5Jcfq61h2avL-PS7WF3aalt3atZNMzebQ_cltCrsFq9dEx3KldBZuoNt8ki796Puo9a2hN7SEBvcS-2ayyD3VUgDa_0-4B3SBVRDGb7oMbg",
      "direction": "received", "contentType": "application/pdf", "emailDate": "2026-01-05" },
    { "documentId": "doc_0b7e5d13f2c84a96", "position": 1,
      "reference": "flr_R6G9u6Vo6gY_ZSjtrv6zXKC_J3cuLwRZbH3tgo4eyWT87R2gm4QdkQFCJK6ugfeMnVjkjYX83ftSMzHm525cCrHfJhUmcwNCZjdTaimso11q2k1AyGiZQKKycVvAqYByyFnr1sKZxck1YhnC5-dhNiUtq7_w",
      "direction": "sent", "contentType": "image/jpeg", "emailDate": "2025-11-20" }
  ]
}

8.1 Index summary

A check's counts, from scan_complete.

FieldMeaning
fetchedMessages read — those FinLens's searches found (9.1), each once — received and sent together. Nothing is read from them but their headers, the names, types and sizes of their files and, on a consent given under version 6 or later, their text.
receivedIndexedIndex entries for received email.
sentIndexedIndex entries for sent email.
entryCountEntries in the whole index: receivedIndexed plus sentIndexed. null if the check stopped before its last page.
attachmentCountFiles listed across those entries: one reference each.
pagesWrittenPages of the index written (9.2).
completeTrue once the check is ready, its last page written. While the check is still scan_complete it is false here, as on the session's index block, and entryCount and pageCount are null: the check can still fail before it is ready.
pageCountPages in the whole index; null unless complete.
droppedArray of { reason, count }: messages read and not listed, by reason (below). A reason with nothing to count may be left out.
truncatedTrue only if the check met FinLens's safeguard against a runaway read, set far beyond any real mailbox, or — for a consent given on version 4 of the disclosure — a check with no window to start after met the number of emails version 4 named (9.8): the index is then partial. There is no other limit. The counts are true of what was read. Known once the read has ended: the summary only exists from scan_complete.

Drop reasons:

  • no_attachment — nothing listable is left once images embedded in the body are set aside: no part with a file name and a size above zero.
  • unreadable_headers — a message whose headers could not be read into an entry: Gmail answered with an error for it, it has no readable date or From address, or what it carries breaks an entry's rules.

Every message read is either listed or dropped with one reason, so, unless truncated is true, fetched is receivedIndexed plus sentIndexed plus every dropped count. In the example above: 371 + 41 + 25 + 2 = 439. How many emails each search found is FinLens's own count and is on no response. A session counted by an earlier version of the API shows only the reasons that remain.

8.2 Fetch summary

A fetch's counts, from ready: what happened to each reference you sent.

FieldMeaning
requestedReferences in the request, 1 to 50.
deliveredCountItems delivered.
itemsArray of { position, outcome }, one per reference, in request order: "delivered" or the reason it was not (10.3).

8.3 documents

On a fetch: one entry per file still available. Not the file itself — that is pulled at …/documents.

FieldTypeNotes
documentIdstringMatches documentId in the pulled files.
positionintegerWhere its reference stood in your fetch request, from 0.
referencestringYour reference, handed back.
directionstring"received" or "sent".
contentTypestringThe type the index gave the file (9.5).
emailDatestringThe day of the email, as a UTC calendar day, so mail from before 05:30 in India carries the day before.

Once the fetch is purged the field is omitted rather than sent empty, so it is never mistaken for "nothing delivered".

Whether the consent is live. Present on the initial session from the moment your customer approves at Google; absent on a refresh and a fetch (read the parent) and on a session that never reached approval. It describes the consent, not the session: a consent can be active for months after its first index is purged, or before you have asked for it, while the session is awaiting_request.

FieldTypeNotes
statusstring"active", "revoked" or "expired".
expiresAttimestampWhen the term ends if nothing ends it sooner: 180 days after approval, or earlier when your customer chose time-limited access on Google's screen and Google's access ends first (12). 365 days after approval for a consent given before version 8 of the disclosure.
endedAttimestampWhen the consent ended. Present exactly when endReason is.
endReasonstringWhy it ended; values below.
  • client_revoked — you withdrew it.
  • grant_invalid — Google refused the access at a check or a fetch: your customer removed FinLens, changed their Google password, left it unused for six months, or chose time-limited access and did not renew it — or another of their FinLens consents ended (12.2).
  • expired — the term ran out: 180 days (365 for a consent given before version 8), or Google's time-limited access when it ended first (12).
  • operator_ended — FinLens ended it: on a withdrawal your customer sent our grievance officer, or because it was given under an earlier version of the standard disclosure (obligations).

Branch on status, not on whether endedAt is present: a term that has run out reads expired straight away, a moment before the end is recorded.

8.5 index

On a check (kind "initial" or "refresh"): how far its index has been written. Present from the first page written — while the check is still scanning — until the last page's three hours are over. Absent before the first page, and after.

FieldTypeNotes
pagesWrittenintegerPages written so far. Pages 1 to pagesWritten can be pulled now, each until its own deadline.
completebooleanTrue once the last page is written and the check is ready: nothing more is coming. False through scanning and scan_complete.
pageCountinteger or nullPages in the whole index. null until complete, then equal to pagesWritten.
entryCountinteger or nullEmails in the whole index. null until complete: there is no running count of emails, only of pages.
generationintegerWhich read of the check the pages belong to, from 1. It grows only when the read starts again from page 1 — the worker reading it stopped and another took it up — and every page of the earlier generation is deleted then. When it changes, discard the pages you kept from the old one and pull again from page 1.
earliestPageExpiresAttimestampThe soonest any page still held is deleted, three hours after its write. Pull the pages you have not pulled before it; indexExpiresAt is the latest.

An index that lists nothing is one empty page: complete, with pageCount 1 and entryCount 0.

9. The index

GET/v1/sessions/:id/index?page=N

The list a check produced — the consent's own session, or a refresh — one page at a time. One entry per email FinLens's searches found (9.1), received or sent, in the order they were read, across the pages in order: search by search, in the order of 9.1's table, newest first within each. An email two searches found is listed once, under the first, and its matchedBy names both. A fetch's id answers 409 wrong_kind.

  • Found by Gmail's search, judged by you. A search matches an email by its subject, its files' names, its sender or recipients and its date; FinLens opens nothing and recognises no document, so matchedBy says which search matched, not what the email is. Every file of an email found is listed, whatever its type: a spreadsheet or a signed email's .p7s signature beside the PDF a search matched is a file like any other.
  • Not listed: spam, trash, drafts and chats; an email none of the searches finds or with no file attached; and images embedded in an email's body, such as logos and signature images.
  • Pulling it moves the next refresh on. An index counts as pulled once this route has served you its last page after it is complete — page pageCount, from a response that says complete true — inside that page's three hours. A last page you pulled while the check was still listing does not count: pull it again once the index is complete. The next refresh lists email newer than the start of the last check whose index you pulled; an index you stopped part-way through, or never pulled, is listed again by the next refresh, so no email you did not collect is skipped (12.1).
  • Not after the consent ends. Once the consent is withdrawn, expires or loses its Google access, its indexes are deleted and this route answers 409 consent_not_active for every page of every check of it, whatever its state (12.2).
  • Busy. When FinLens is already assembling other large responses, this route may answer 503 service_busy with a Retry-After header: pull that page again after that many seconds.

9.1 What FinLens searches for

FinLens reads only what its searches find. It runs seven fixed searches — Gmail's own search, run by FinLens, with one set of terms that is the same for every customer and every partner — and lists every email they find: no search has a cap. Nothing in your request, or anyone else's, chooses or changes them. A search matches an email by its subject, its files' names, its sender or recipients and its date; nothing inside an email or a file decides it.

matchedByWhat the search looks forHow far back
kycProofs of identity: a subject or a file name naming Aadhaar, a PAN card, a passport, KYC, a photo, an ID or address proof, a driving licence or a voter ID.10 years
loanLoan papers: sanction, approval and offer letters, welcome letters, disbursement, key fact statements and MITC, repayment and amortisation schedules, EMIs, interest and provisional certificates, prepayment, foreclosure and top-ups, NOC and no-dues letters, loan applications and lists of documents, a loan's statement of account, and mail about a home loan or housing finance.10 years
propertyProperty papers: allotment, agreements for sale and builder-buyer agreements, sale deeds and registry, conveyance, demand and instalment letters, payment confirmations, receipts, ledgers and challans (Form 26QB among them), possession, handover, completion and occupancy, and mail naming a unit, a flat, a tower or a plot.5 years
casConsolidated account statements (CAS) of investments.45 days
fdFixed-deposit advices.1 year
cardCard statements: a subject naming a credit card or card statement, a card ending in given digits, or an e-statement.100 days
sent_to_lenderFiles your customer sent: email in their Sent mail addressed to one of a fixed list of banks, lenders, card issuers and builders.5 years
  • One shared negative list. Every search leaves out drafts, chats and calendar invitations (.ics, .vcs). Every search but the CAS and FD ones also keeps only an email with a PDF, an image (JPEG or PNG), a ZIP or a Word document attached; leaves out mail from a fixed list of senders — broker and exchange reports, food delivery, travel and rides, tickets, meetings, payments, invoicing, offers and recruitment; and leaves out mail whose subject has a word that marks it as something else: a daily statement or anything weekly, a contract note, trades executed, a securities balance or a statement of funds, retention or margin, an invoice, an e-ticket, a boarding pass or a booking, an invitation, a webinar, a newsletter, or an OTP.
  • Each email once. The searches' results are put together and each email is read once, at the first search that found it; matchedBy lists every search that did, in the order of the table.
  • A refresh runs the same seven searches over email newer than the start of the last check whose index you pulled (12.1), in place of each search's own window; with none pulled, each search's own window, as the first check.
  • Usually ready within a few minutes. The searches find a few hundred emails in most mailboxes, and Gmail is read at about 98 emails a minute (9.8).
  • The searches are FinLens's to maintain. A change to them is announced on this page. Treat a matchedBy value you do not recognise as one you cannot act on.

9.2 Pages, as they are written

A first index is usually ready within a few minutes, and usually fits in a page or two. FinLens writes it in pages of up to 500 emails as it reads, and each page can be pulled as soon as it is written — while the check is still scanning — for three hours from its own write (pageExpiresAt). You do not wait for the whole index: the first page is usually written within about a minute of the check starting — for a consent's first index, of your asking for it (12.1) — and you can fetch files from the pages you have while the rest are still being written.

  • page is a whole number from 1, written plainly: ?page=1, ?page=2, …. With no page, you get page 1. Anything else in the query — a second page, another key, 0, a sign, a fraction, a leading zero — is 400 invalid_request.
  • Every page says how far the index has got, as the session's index block does: pagesWritten so far, whether it is complete, and once it is, pageCount and entryCount — there is no running count of emails while it lists — with the generation its pages belong to and earliestPageExpiresAt, the soonest a page still held is deleted.
  • A page that cannot be read is 404 index_page_not_found, and its reason says which (5): not_written yet, past_last_page, or expired — past its own three hours, gone for good; skip it, the later pages may still be there.
  • Nothing to read — the check not started, no page written yet, or every page past its three hours — answers page 1 with the check's status, pagesWritten 0 and no entries.
  • A new generation starts again from page 1. If the worker reading a check stops, another takes the check up and reads it again from the start: every page written before is deleted, the pages are numbered from 1 again, and generation goes up by one. Discard what you kept of the old generation and read the new one from page 1.
  • Pace the pages. Your credential's 60 requests a minute cover every call it makes. Pull each page once — poll the session for progress and pull only the pages that are new to you — at no more than about one every two seconds, and on 429 rate_limited or 503 service_busy wait the Retry-After seconds and pull the same page again.

To read a whole index, poll the session (or its webhook) and pull each page as it appears:

generation = none; next = 1
repeat:
  status = GET /v1/sessions/:id                     at most every 30 seconds
  if status.status is failed, purged or another terminal state: stop
  if status.status is awaiting_request:             the consent's first index, not asked for yet
    POST /v1/sessions/:id/refresh with {} once (12.1); wait and repeat
  if there is no status.index: stop if status.status is ready, else wait and repeat
  if status.index.generation is not generation:     the read started again
    discard every page kept; generation = status.index.generation; next = 1
  while next <= status.index.pagesWritten:
    page = GET /v1/sessions/:id/index?page=next     before earliestPageExpiresAt; about one every 2 s
    on 200:
      if page.generation is not generation: start again at the next poll; leave the loop
      keep page.entries; next = next + 1
    on 404 with reason expired: note that page next is gone; next = next + 1
    on 404 with reason not_written: leave the loop
    on 429 or 503: wait Retry-After seconds, then pull the same page again
  if status.index.complete and next > status.index.pageCount:
    if the last page you pulled said complete false: GET …/index?page=pageCount once more
    done                                            the index now counts as pulled
  wait

Until the first page is written the session has no index block: keep polling. A ready session with no index block has nothing left to pull: every page's three hours are over. Once complete is true — only once the check is ready — the totals are final and nothing more will be written; the page you pull last, page pageCount with complete true, is the one that counts the index as pulled. A check that ends failed serves nothing more, even pages already written.

9.3 Response — 200 OK

FieldTypeNotes
sessionIdstringThe check you asked for.
kindstring"initial" or "refresh".
parentSessionIdstringOn a refresh only: the consent's session.
statusstringThe check's status. Pages come while it is scanning, scan_complete or ready.
pageintegerThe page served, from 1.
pageExpiresAttimestampOn a page served: when FinLens deletes this page, three hours after it was written.
pagesWrittenintegerPages written so far; 0 when there is nothing to read.
completebooleanTrue once the last page is written and the check is ready.
pageCountinteger or nullPages in the whole index; null until complete.
entryCountinteger or nullEmails in the whole index; null until complete.
generationintegerOn a page served: the read this page belongs to (8.5). A different one from the pages you hold means the read started again: discard those.
earliestPageExpiresAttimestampOn a page served: the soonest any page still held is deleted.
truncatedbooleanTrue when the list is incomplete (9.8). Known only once the read has ended: false on every page while the check is still scanning, so read it on a complete index.
entriesobject[]This page's emails (9.4), up to 500. Empty when there is nothing to read.
200 OK — GET /v1/sessions/ses_5a5aa6e53d539a69c3d5fc04/index?page=1, a small index, complete, of a consent given under version 6
{
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "kind": "initial",
  "status": "ready",
  "page": 1,
  "pageExpiresAt": "2026-01-15T12:14:22.000Z",
  "pagesWritten": 1,
  "complete": true,
  "pageCount": 1,
  "entryCount": 3,
  "generation": 1,
  "earliestPageExpiresAt": "2026-01-15T12:14:22.000Z",
  "truncated": false,
  "entries": [
    {
      "direction": "sent",
      "emailAt": "2025-11-20T11:02:57Z",
      "subjectLine": "Documents for my home loan application",
      "sentBy": "asha.menon@gmail.com",
      "sentTo": ["homeloans@icicibank.com"],
      "sentToOmitted": 1,
      "files": [
        {
          "attachmentName": "salary-slip-october.pdf",
          "contentType": "application/pdf",
          "sizeBytes": 96412,
          "reference": "flr_naj7yXHC8YoCw4fBJ1PtKtUunltmwKaVRfMrlJypJF69njHfdutP8DX6KxA6rNmC7uK8V2bjp4jqGO2AcXS6neKgd3woXDfN5ceFMXubws0SMHe5i3CBaodetJ_f0FDtAZ5QghIQbcNW0Yqe-yBhNxdvfZeV"
        },
        {
          "attachmentName": "address-proof.jpg",
          "contentType": "image/jpeg",
          "sizeBytes": 412880,
          "reference": "flr_R6G9u6Vo6gY_ZSjtrv6zXKC_J3cuLwRZbH3tgo4eyWT87R2gm4QdkQFCJK6ugfeMnVjkjYX83ftSMzHm525cCrHfJhUmcwNCZjdTaimso11q2k1AyGiZQKKycVvAqYByyFnr1sKZxck1YhnC5-dhNiUtq7_w"
        }
      ],
      "matchedBy": ["loan", "sent_to_lender"],
      "sentByName": "Asha Menon",
      "bodyText": "Hi,\n\nPlease find my October salary slip and address proof attached for the home loan application.\n\nRegards,\nAsha",
      "threadRef": "thr_LtEw0Nl3f9EJgbTGAFIX5uvC"
    },
    {
      "direction": "received",
      "emailAt": "2025-12-28T15:20:44Z",
      "subjectLine": "Allotment letter — Tower B, Flat 1204",
      "sentBy": "crm@skyline-homes.example",
      "sentTo": ["asha.menon@gmail.com"],
      "files": [
        {
          "attachmentName": "AllotmentLetter_B1204.pdf",
          "contentType": "application/pdf",
          "sizeBytes": 238117,
          "reference": "flr_2VRuK3sSTERq2ZtYjMM_bBqqPqiUjOvQV0tj9fNhCVPCHYPZ818WbbU2dD1Xe_fu5LruZfWxI55SIqGFzLwSakK737PBO57-jfZYuYPGJ2RbEBWZXOeU-9RbEhn0-xgtgijM0NfDpVcu5eCGni1u10i0jzdJ"
        },
        {
          "attachmentName": "PaymentSchedule.docx",
          "contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
          "sizeBytes": 41207,
          "reference": "flr_Wq8m3kTb5sV1xY7nP0cR2dF6gH9jL4zA8eU3iO5uK7yT1rE6wQ0aS2dF9gH3jK5lZ7xC1vB4nM8qW2eR6tY0uI3oP5aS7dF9gH1jK3lZ5xC7vB9nM1qW3eR5tY7uI9oP1aS3d"
        }
      ],
      "matchedBy": ["property"],
      "sentByName": "Skyline Homes — Customer Relations",
      "bodyText": "Dear Ms Menon,\n\nPlease find attached the allotment letter for Flat 1204, Tower B, and the payment schedule.\nThe first instalment is due on 15 January 2026.\n\nView your account (https://skyline-homes.example/account)",
      "senderCheck": { "dkim": "pass", "spf": "pass", "dmarc": "none", "dkimDomain": "skyline-homes.example" },
      "threadRef": "thr_7i6DS_cX0B6nfkpVqymX_epo"
    },
    {
      "direction": "received",
      "emailAt": "2026-01-05T06:41:09Z",
      "subjectLine": "Your HDFC Bank Credit Card statement for December 2025",
      "sentBy": "emailstatements.cards@hdfcbank.net",
      "sentTo": ["asha.menon@gmail.com"],
      "files": [
        {
          "attachmentName": "CreditCardStatement_Dec2025.pdf",
          "contentType": "application/pdf",
          "sizeBytes": 184213,
          "reference": "flr_dHVPZMEQHGcTTdrNz78OwDpxeY6wkzwJj3Mad9GNr30PJ2eCs5Jcfq61h2avL-PS7WF3aalt3atZNMzebQ_cltCrsFq9dEx3KldBZuoNt8ki796Puo9a2hN7SEBvcS-2ayyD3VUgDa_0-4B3SBVRDGb7oMbg"
        }
      ],
      "matchedBy": ["card"],
      "sentByName": "HDFC Bank Credit Cards",
      "bodyText": "Dear Cardmember,\n\nYour statement for December 2025 is attached.\nTo open it, enter the first four letters of your name in capitals and your date of birth as DDMM.\n\nPay now (https://www.hdfcbank.com/personal/pay/cards)",
      "senderCheck": { "dkim": "pass", "spf": "pass", "dmarc": "pass", "dkimDomain": "hdfcbank.net" },
      "threadRef": "thr_0p_OkXXNx0TZGOnRc0WAhaT1"
    }
  ]
}

The entries come search by search: the loan email first (its matchedBy also names sent_to_lender), then the property papers, then the card statement. On a consent given under version 4 or 5 the same entries carry matchedBy and none of sentByName, bodyText, senderCheck or threadRef.

9.4 Each entry

An entry is one email, received or sent. Both directions carry the same fields.

FieldTypeNotes
directionstring"received" — email your customer was sent — or "sent" — email your customer sent. From anyone, to anyone.
emailAttimestampWhen Gmail received or sent it: UTC, whole seconds, written one way only, e.g. 2026-01-05T06:41:09Z.
subjectLinestringThe Subject header, with control and direction-override characters removed and whitespace collapsed; at most 1,000 characters; "" when there is none. Untrusted text (9.9).
sentBystringThe From address: lower case, no display name. On sent mail, your customer's own. Who the email claims to be from: no sender is verified.
sentTostring[]The To addresses, the same way, each once, at most 50; may be empty. Never Cc, never Bcc.
sentToOmittedintegerHow many To members are not listed — not one plain address, or past 50. A count, never the members; absent when none was left out.
filesobject[]The files attached, at least one and at most 100, in the order the email carries them (9.5).
matchedBystring[]Which of FinLens's searches found the email (9.1), each once, in the order of 9.1's table: "kyc", "loan", "property", "cas", "fd", "card", "sent_to_lender". The first is the search it is listed under. FinLens's own label: it says which search matched, not what the email is, and nothing in it is read from the email. Absent only on a page written before FinLens began searching, within three hours of that change.
sentByNamestringVersion 6 or later consents only. The From header's display name, cleaned as subjectLine is, at most 200 characters; "" when there is none. Whatever the sender typed: never proof of who sent it. Untrusted text (9.9).
bodyTextstringVersion 6 or later consents only. The email's text: its plain-text part, or else its HTML part turned into plain text by FinLens, with each link kept as text (URL). Line breaks (\n) and tabs (\t) kept; every other control, direction-override and zero-width character removed. At most 100,000 characters; "" when the email has no text. Untrusted, and possibly sensitive (9.9).
bodyTruncatedbooleanVersion 6 or later consents only: true when bodyText was cut at 100,000 characters. Absent otherwise.
bodyUnavailablebooleanVersion 6 or later consents only: true when Gmail did not send the email's text with it, so bodyText is "". Absent otherwise.
senderCheckobjectVersion 6 or later consents, received mail only. Gmail's own verdict on the sender's domain, below.
threadRefstringVersion 6 or later consents only. Which conversation the email belongs to: "thr_" and 22 to 64 letters, digits, - and _. The same for every email of one conversation, across this consent's first check and its refreshes; meaningless under any other consent; never Gmail's thread id. Compare it, never parse it.

sentByName, bodyText and threadRef are on every entry of a consent your customer gave under version 6 of the disclosure or later, and senderCheck on each received one; on a consent given under version 4 or 5 — whose disclosure said the email's text is never read — they are absent, all of them, on every check and refresh. senderCheck is read only from the Authentication-Results header Gmail itself added when it delivered the email (mx.google.com), never one a sender wrote, and is reported as Gmail recorded it: FinLens checks no signature of its own.

  • dkim, spf, dmarc — each one of "pass", "fail", "softfail", "neutral", "none", "temperror", "permerror", "policy", or "missing" when Gmail recorded no result.
  • dkimDomain — the domain that signed the email, as DKIM's header.d names it (a passing signature of the From address's domain if there is one, else the first), lower case; or null.
  • A pass means the sending domain sent it — not that the sender is who sentByName says, nor that the domain is the institution it looks like.

The index carries nothing else of an email: no Cc or Bcc, no header but the verdict Gmail wrote, no raw or HTML body, and none of Gmail's message, thread or attachment ids.

9.5 Each file

FieldTypeNotes
attachmentNamestringThe file name as the sender gave it, cleaned as subjectLine is, 1 to 255 characters. Untrusted text (9.9).
contentTypestringThe MIME type the email declared for the file, lower case and without parameters, e.g. "application/pdf", "image/jpeg", "text/calendar". A declaration that is not a valid type/subtype of at most 127 characters reads "application/octet-stream". The sender's claim, not a check: FinLens opens nothing to build an index.
sizeBytesintegerGmail's stated size, above zero. Nothing is downloaded to build an index.
referencestringWhat you send to fetch this file (9.6).

9.6 References

  • Opaque. A reference starts flr_, is at most 512 characters of letters, digits, - and _, and is encrypted: it tells you nothing and you cannot build or alter one. Store it as a string, exactly as received.
  • Valid until the consent ends. Each is sealed under a key that belongs to the consent. When the consent is withdrawn, expires or loses its Google access, the key is destroyed and every reference of that consent stops working; a fetch is then refused with consent_not_active, and so is a pull of any index or files of that consent not yet pulled, which are deleted then too. Until then you may fetch any of them, on any day.
  • Yours only. A reference works only for your account, under the consent whose index listed it. Any other — another consent's, another account's, an altered one — is refused with invalid_reference.
  • Not a promise that the file is still there. Your customer may delete the email or move it to spam or trash; the fetch then says so for that reference (10.3).

9.7 The same email in two lists

A refresh reads from the start of the last check whose index you pulled, so consecutive indexes overlap — and after an index you missed, the next one lists all of it again. An email can appear in both, with a different reference each time. De-duplicate entries on (emailAt, sentBy, subjectLine, attachmentName, sizeBytes). A fetch that names the same file twice, through two references, is refused with 400 invalid_request. threadRef stays the same across a consent's checks, so it groups a conversation whichever index listed each email.

9.8 A long index

There is no cap on any search: a first index lists every email its searches find, however many (9.1). It is usually ready within a few minutes. FinLens reads Gmail at about 98 emails a minute — Google charges 60 units for each email read in full and allows 6,000 units a minute for each Gmail user — and the searches find a few hundred emails in most mailboxes: about 575 in a large one, listed in about six minutes. A fetch beside a listing shares that minute. Its pages arrive as it is read (9.2). Memory on neither side grows with the mailbox: FinLens writes a page at a time, and you read one.

  • truncated is true only if the read met FinLens's safeguard against a runaway read — set far beyond any real mailbox — or, for a consent your customer gave on version 4 of the disclosure, if a check with no window to start after (a refresh of a consent none of whose indexes you pulled) met the number of emails version 4 named: such a consent is read no further than it was told. The index is then partial: an email missing from it may still be in the mailbox. The flag is on every page and in the summary, and is known once the read has ended: while the check is still scanning it is false on every page.
  • A refresh does not fill a gap. It lists email newer than the start of the last check whose index you pulled (12.1).
  • Pull each page within its three hours. Over a long index the early pages can expire before the last is written — earliestPageExpiresAt says when the next one goes — and a page you miss is gone (404, reason expired). The index then still counts as pulled once you pull its last page after it is complete, so the next refresh does not list a missed page's emails again: pull every page in time.

A page is at most a few megabytes, so allow for that in your client.

9.9 Subject lines, names, the email's text and files are untrusted

Anyone can send an email, write a subject line or its text, choose a display name or name a file. FinLens removes control and direction-override characters, but whatever else the sender wrote reaches you as written: markup, script, formulas, misleading text, and — in the email's text — links that may lead to phishing. And no sender is verified by FinLens: an email that claims to be from a bank may not be, and senderCheck is only Gmail's verdict on the sending domain.

  • Escape them wherever they are shown: in HTML, as text, never as markup — escape & < > " ' — and isolate each value (for example in a <bdi> element), since it may be right-to-left text.
  • Guard spreadsheets. Before writing one into a CSV or spreadsheet cell, prefix any value that begins with =, +, - or @ with a single quote ('), so it is read as text, not as a formula.
  • Never use a file name as a path, and trust neither its extension nor its contentType: both are what the sender said.
  • Scan every file you fetch for malware before you open it, store it with other files or pass it on (Partner Terms §3.5).
  • Never follow a link in the email's text on your customer's behalf, and show one only as text, with its address visible.
  • Treat them as personal data. A subject line or a file name can carry an account number, a PAN or a name, and the addresses and display names on an entry are other people's: your customer's correspondents.
  • The email's text may contain sensitive data the sender wrote — a password or the rule for one, a one-time code, an account number. FinLens delivers it as written and neither looks for nor removes any of it. Protect it as you protect the files, never write it to a log or an analytics tool, and use it only to work with your customer's own documents (obligations).

10. Fetch files

POST/v1/sessions/:id/fetch

Ask FinLens to read chosen files from Gmail and deliver them. :id is the consent's own session; a refresh's or a fetch's id is 404. Any day while the consent lasts, with nothing asked of your customer.

10.1 Request

FieldTypeNotes
referencesstring[] · required1 to 50 references from this consent's indexes, each once, whose files' sizeBytes add up to 32 MB at most. Their order is the order of the outcomes.

References, and nothing else. FinLens does not open password-protected files, so it accepts no identity — no name, PAN, date of birth or mobile number, here or anywhere. A fetch that carries an identity field is refused before anything else about it is judged, with 400 invalid_request and its own message, so you can tell why:

400 Bad Request
{
  "error": {
    "code": "invalid_request",
    "message": "FinLens accepts no identity: a fetch carries references only, and every file is delivered exactly as received. Remove the identity field.",
    "requestId": "3b8e51c2-9f04-4d6a-a7e2-0c1d5f9b8a73"
  }
}

10.2 Response — 202 Accepted

FieldTypeNotes
sessionIdstringThe fetch's own session. Poll it and pull from it.
parentSessionIdstringThe consent's session: the id you called.
statusstringAlways "consented": queued for a worker. A poll shows scanning.
referenceCountintegerHow many references it will read, so you can match the outcomes to your request.
POST /v1/sessions/ses_5a5aa6e53d539a69c3d5fc04/fetch
{
  "references": [
    "flr_dHVPZMEQHGcTTdrNz78OwDpxeY6wkzwJj3Mad9GNr30PJ2eCs5Jcfq61h2avL-PS7WF3aalt3atZNMzebQ_cltCrsFq9dEx3KldBZuoNt8ki796Puo9a2hN7SEBvcS-2ayyD3VUgDa_0-4B3SBVRDGb7oMbg",
    "flr_R6G9u6Vo6gY_ZSjtrv6zXKC_J3cuLwRZbH3tgo4eyWT87R2gm4QdkQFCJK6ugfeMnVjkjYX83ftSMzHm525cCrHfJhUmcwNCZjdTaimso11q2k1AyGiZQKKycVvAqYByyFnr1sKZxck1YhnC5-dhNiUtq7_w",
    "flr_2VRuK3sSTERq2ZtYjMM_bBqqPqiUjOvQV0tj9fNhCVPCHYPZ818WbbU2dD1Xe_fu5LruZfWxI55SIqGFzLwSakK737PBO57-jfZYuYPGJ2RbEBWZXOeU-9RbEhn0-xgtgijM0NfDpVcu5eCGni1u10i0jzdJ"
  ]
}

202 Accepted
{
  "sessionId": "ses_c1b8f0e2a94d7735e6a0b913",
  "parentSessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "status": "consented",
  "referenceCount": 3
}
  • One fetch at a time, even while the list is still arriving. Each page of the index can be read as soon as it is written, so you can ask for files from the pages you have while the consent's first check or a refresh is still listing the rest; the two share your customer's Gmail limit, so the list may arrive a little more slowly meanwhile. At most one fetch of a consent runs at a time: while another fetch is queued or running, a fetch is refused with 409 check_in_progress, and a running fetch holds off a refresh the same way. Poll the running fetch, then send yours.
  • No interval. Fetches may follow each other as soon as each finishes. refresh_too_soon never applies to a fetch, and a fetch does not count towards the hour between refreshes.
  • At most 32 MB a fetch. Add up the sizeBytes the index gave each chosen file: a fetch whose files come to more than 32 MB is refused with 400 fetch_too_large, and nothing is fetched. Split the references into smaller groups and send them one fetch after another.
  • Fetches are started before any refresh or first check, and have a worker lane of their own, so a fetch does not queue behind other consents' long first indexes. A fetch waits up to six hours for a worker to pick it up; one not picked up by then ends failed, with consent_revoked if the consent ended meanwhile, otherwise internal_error. A consent that ends while the fetch is running ends it failed with consent_revoked too, and nothing from it is served.
  • From here the fetch is an ordinary session of kind "fetch": scanning, then ready, then purged three hours later — or as soon as the consent ends, when its files are deleted and a pull is refused. Its session.ready and session.failed events carry kind "fetch" and parentSessionId.
Refused withWhen, and what to do
400 invalid_requestAn identity (with its own message), no references, more than 50, one listed twice, two that open to the same file, or another field.
400 invalid_referenceSome references do not open for this consent. referenceIndexes names them by position; nothing was fetched. A string not shaped like a reference is invalid_request.
400 fetch_too_largeThe chosen files add up, by their sizeBytes in the index, to more than 32 MB. Nothing was fetched; fetch them in smaller groups.
404 not_foundNo such session in your account, or the id of a refresh or a fetch.
409 consent_not_activeThe consent is not live, so its references no longer open. Only a new consent helps.
409 check_in_progressAnother fetch of this consent is queued or running. A check that is still listing does not refuse a fetch.

10.3 What happened to each reference

The fetch's summary answers every position of your request exactly once. A fetch where every reference failed is still ready, with nothing to pull. A fetch that ends failed is different: it has no files to pull, even for references it had read, and may carry no summary.

OutcomeWhat it means, and what to do
deliveredThe file is in the fetch's documents, with this position.
invalid_referenceIt did not open under this consent when the fetch ran. Do not send it again.
email_not_foundGmail no longer holds the email, or it is in your customer's trash or spam.
attachment_not_foundThe email is there and the file is not, or not as the index listed it: its name, type or size changed.
too_largeOver the 25 MiB FinLens fetches.
fetch_failedGmail refused this one read for a reason of its own, or handed over a file that is not the size it declared. The one worth trying again, in a later fetch. A fetch counted by an earlier version of the API shows its retired outcomes as this one.

10.4 The two three-hour windows

pull promptly

Each page of an index lives three hours from the moment it is written; a fetch's files live three hours from the moment the fetch is ready. Then FinLens deletes them. There is no archive and no way to extend either window. A nightly batch job will find nothing.

  • Pull each page of an index as a poll shows it written (9.2), and a fetch's files on session.ready or as soon as a poll shows ready. Over a long first index the early pages' three hours run while the mailbox is still being read, so do not wait for the last page to pull the first.
  • Each page's deadline is its pageExpiresAt; the session's, every page's and the event's earliestPageExpiresAt is when the first page still held goes, and indexExpiresAt (the session's and the event's) when the last page written goes. The files' is artifactsExpireAt on the fetch and its event, and expiresAt on each file.
  • Waiting for a worker. A first check waits up to twenty-four hours after you ask for it for a worker to start it, and a refresh or a fetch up to six hours after you ask for it. Fetches are started first, then refreshes, then first checks, and fetches have a lane of their own. One not started by then ends failed with internal_error (or consent_revoked if the consent ended meanwhile). The three hours of a page or a file start only when it is written or ready, never while it waits.
  • After a window the session reads purged, not 404: "your three hours are up", not "wrong id". Counts remain.
  • Ending the consent ends both windows. When the consent is withdrawn, expires or loses its Google access, every index and file of it not yet pulled is deleted at once, its ready sessions read purged, and any pull of them is refused with 409 consent_not_active (12.2).
  • A missed index is listed again. A refresh lists email newer than the start of the last check whose index you pulled — its last page served to you after it was complete, inside that page's three hours. Miss one, or stop part-way through it, the first say, and the next refresh lists everything that index would have, again: for the first check, everything its searches found. Pulling each index to its last page promptly is what moves the window on. The references you already hold still fetch until the consent ends.
  • Missed files cost a fetch: send the same references again, while the consent lasts.
  • A 503 service_busy on either pull does not close the window: wait the Retry-After seconds and pull again.

10.5 Pull the files

GET/v1/sessions/:id/documents

The delivery, on a fetch's id; a check's id answers 409 wrong_kind. Returns { sessionId, status, documents }, with every available file in one response; there is no pagination and no per-file route. Files are served only while the fetch is ready: before that the list is empty, and a fetch that ended failed has no files to pull. The status travels with the list, so an empty list tells you why: not ready yet, failed, nothing delivered, or purged. Once the consent has ended, the route answers 409 consent_not_active instead, whatever the fetch's state. When FinLens is already assembling other large responses, this route may answer 503 service_busy with a Retry-After header: pull again after that many seconds.

10.6 Each file

Each entry is the file exactly as it was attached to the email — FinLens does not open, read, convert or unlock it — and what the index said about it. Received and sent files carry the same fields.

FieldTypeNotes
documentIdstringMatches documentId in the fetch's documents.
sessionIdstringThe fetch that delivered it.
tenantIdstringYour account id.
referencestringYour reference, handed back.
positionintegerWhere that reference stood in your request, from 0.
directionstring"received" or "sent", as the index listed the email.
emailDatestringYYYY-MM-DD: the day of the email, as a UTC calendar day.
sentBystringThe email's From address, as the index listed it: lower case, no display name; on sent mail, your customer's own. Not verified.
attachmentNamestringThe file's name, as the index listed it. Untrusted text (9.9).
documentBase64stringThe file, base64, byte for byte as it was attached. A password-protected PDF is still protected.
contentTypestringThe type the index gave the file: what the email declared, not a check of the bytes.
documentSizeBytesintegerSize of the decoded file.
documentSha256stringLowercase hex SHA-256 of the decoded file. Check what you pulled.
expiresAttimestampWhen FinLens deletes this file.
200 OK
{
  "sessionId": "ses_c1b8f0e2a94d7735e6a0b913",
  "status": "ready",
  "documents": [
    {
      "documentId": "doc_6a49b922c10a7a2d",
      "sessionId": "ses_c1b8f0e2a94d7735e6a0b913",
      "tenantId": "ten_7dd0eca207030a50185b2e0d",
      "reference": "flr_dHVPZMEQHGcTTdrNz78OwDpxeY6wkzwJj3Mad9GNr30PJ2eCs5Jcfq61h2avL-PS7WF3aalt3atZNMzebQ_cltCrsFq9dEx3KldBZuoNt8ki796Puo9a2hN7SEBvcS-2ayyD3VUgDa_0-4B3SBVRDGb7oMbg",
      "position": 0,
      "direction": "received",
      "emailDate": "2026-01-05",
      "sentBy": "emailstatements.cards@hdfcbank.net",
      "attachmentName": "CreditCardStatement_Dec2025.pdf",
      "documentBase64": "JVBERi0xLjcK…",
      "contentType": "application/pdf",
      "documentSizeBytes": 184213,
      "documentSha256": "df214919d2d29ad2123c81e311025debd3e36791ffda93aa7ee64fd285be5f4c",
      "expiresAt": "2026-01-15T13:02:41.000Z"
    },
    {
      "documentId": "doc_0b7e5d13f2c84a96",
      "sessionId": "ses_c1b8f0e2a94d7735e6a0b913",
      "tenantId": "ten_7dd0eca207030a50185b2e0d",
      "reference": "flr_R6G9u6Vo6gY_ZSjtrv6zXKC_J3cuLwRZbH3tgo4eyWT87R2gm4QdkQFCJK6ugfeMnVjkjYX83ftSMzHm525cCrHfJhUmcwNCZjdTaimso11q2k1AyGiZQKKycVvAqYByyFnr1sKZxck1YhnC5-dhNiUtq7_w",
      "position": 1,
      "direction": "sent",
      "emailDate": "2025-11-20",
      "sentBy": "asha.menon@gmail.com",
      "attachmentName": "address-proof.jpg",
      "documentBase64": "/9j/4AAQSkZJRgABAQ…",
      "contentType": "image/jpeg",
      "documentSizeBytes": 412880,
      "documentSha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b",
      "expiresAt": "2026-01-15T13:02:41.000Z"
    }
  ]
}

Handle these as sensitive, and as untrusted. A statement carries your customer's account details and often their PAN, and a file your customer sent may be a photo of an identity document. They reach you because your customer agreed to share them with you, and what you may do with them is set by the Partner Terms, your own privacy policy and your regulator. And since no sender is verified, any file may be malicious: scan each one before you open it.

10.7 Password-protected files

FinLens does not open or unlock any file. A password-protected PDF is delivered as it was attached, still protected, and nothing about it says it is: open it as you would any protected file. FinLens accepts no identity and derives no password.

11. Webhooks

A webhook tells you something happened; it never carries an index or a file. Pull them from the API afterwards. Webhooks are optional — polling GET /v1/sessions/:id works just as well — and a missed event costs you only a poll.

11.1 Register an endpoint

POST/v1/webhooks

FieldTypeNotes
urlstring · requiredhttps only, at most 2048 characters, a host name (not an IP address), port 443, no credentials. A host name containing ".." is refused, as are the hosts FinLens calls itself: gmail.googleapis.com, oauth2.googleapis.com and cloudkms.googleapis.com.
eventTypesstring[] · optionalThe events to receive. A list is exhaustive; empty or omitted means every event, including ones added later.
descriptionstring · optional1–200 characters, for your reference.
POST /v1/webhooks
{ "url": "https://hooks.example.com/finlens",
  "eventTypes": ["consent.granted", "session.ready", "session.failed", "consent.revoked", "consent.expired"] }

201 Created
{ "webhookId": "wh_a4704d51cc7e4a7404d74282",
  "url": "https://hooks.example.com/finlens",
  "eventTypes": ["consent.granted", "session.ready", "session.failed", "consent.revoked", "consent.expired"],
  "createdAt": "2026-01-15T08:00:00.000Z",
  "signingSecret": "whsec_…" }
shown once

signingSecret appears in this response and nowhere else. Store it with your key material and do not log the response. If you lose it, rotate. description is echoed when you sent one.

Your endpoint's host must be on our outbound list. Events leave FinLens only through a proxy that connects to listed hosts, and registering a URL does not list it. Send the host name to developers@finlenstech.com before you register, and before you move to a new host. Until it is listed, events are not delivered; polling is unaffected. On every send the host is resolved again, and an address that is private, loopback or otherwise internal is refused.

11.2 One endpoint per account

A second registration returns 409 conflict. To change the URL, de-register the endpoint and register the new one, which issues a new secret. There is no listing endpoint, so keep the webhookId.

11.3 Verify each event

Each event is a POST with content-type: application/json and these headers:

HeaderValue
x-finlens-timestampEpoch milliseconds.
x-finlens-nonceUnique per delivery.
x-finlens-signaturev1= plus 64 lowercase hex characters; two of them, comma-separated, during a rotation overlap.
x-finlens-event-idThe event's eventId. Use it to de-duplicate.

The canonical string has the same shape as for the token request, with two differences: PATH includes the query string of your registered URL, and the key is derived with HKDF from your signing secret.

canonical = "POST" \n PATH_AND_QUERY \n TIMESTAMP \n NONCE \n SHA256_HEX(RAW_BODY)

key       = HKDF-SHA256( ikm    = signingSecret,                  # the whsec_… string, UTF-8
                         salt   = "",                             # empty
                         info   = "finlens/webhook-signature/v1",
                         length = 32 )                            # raw bytes, not hex

expected  = "v1=" + lowercase_hex( HMAC_SHA256( key, canonical ) )
// Node 18+. rawBody: the request body exactly as received, before JSON parsing.
import { createHash, createHmac, hkdfSync, timingSafeEqual } from "node:crypto";

const key = Buffer.from(hkdfSync("sha256", SIGNING_SECRET, Buffer.alloc(0), "finlens/webhook-signature/v1", 32));

function isFromFinLens(req, rawBody) {
  const ts = req.headers["x-finlens-timestamp"];
  const nonce = req.headers["x-finlens-nonce"];
  if (!(Math.abs(Date.now() - Number(ts)) <= 5 * 60 * 1000)) return false;  // also rejects a missing timestamp
  const bodyHash = createHash("sha256").update(rawBody, "utf8").digest("hex");
  const canonical = ["POST", req.originalUrl ?? req.url, ts, nonce, bodyHash].join("\n");  // full path + query
  const expected = Buffer.from("v1=" + createHmac("sha256", key).update(canonical, "utf8").digest("hex"));
  return String(req.headers["x-finlens-signature"]).split(",").some((value) => {
    const given = Buffer.from(value.trim());
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Hash the raw body before parsing it, compare in constant time, reject timestamps more than five minutes off (or missing), and accept the event if any comma-separated signature matches. Sign over the full path and query your endpoint was registered with: in Express that is req.originalUrl, because req.url is relative to the router it is mounted on.

Delivery

  • One attempt, no retry. A 10-second timeout; redirects are not followed; the response body is ignored. Answer with any 2xx quickly and do your work afterwards.
  • A non-2xx answer, a timeout, or an unlisted host means the event is lost. Nothing is queued for later — but the index and the documents are unaffected: poll and pull as normal.
  • While your account is suspended, session.ready, session.failed and session.consent_declined are withheld; consent.revoked and consent.expired are still sent.
  • A session that fails before any check — at the link, or when Google hands your customer back — ends failed with internal_error without a session.failed event. Polling shows it.

Events

Six events. Every event carries eventId, eventType, occurredAt, tenantId, sessionId and externalUserRef, plus:

EventWhenAdditional fields
session.readyA check's index is complete — its last page written — or a fetch's files can be pulled.kind, always. For a check (kind initial or refresh): entryCount, attachmentCount, indexExpiresAt (when its last page is deleted), earliestPageExpiresAt (when the first page still held is), generation (the read its pages belong to), summary (with pageCount); parentSessionId on a refresh. For a fetch (kind fetch): parentSessionId, documentCount, artifactsExpireAt, summary.
session.failedA check or a fetch could not complete.kind, always; failureReason; on a check, summary if its counts were reached, while a failed fetch may carry none; parentSessionId on a refresh or a fetch
session.consent_declinedYour customer said no — every time, including after a retry.reason, attempt, linkExpiresAt
consent.grantedYour customer approved at Google and the consent is recorded. Nothing has been read: the consent's session is awaiting_request until you ask for the list (12.1).kind, always "initial"; consent: { expiresAt, disclosureVersion }
consent.revokedA live consent was ended by a decision.endedAt, reason: client_revoked, grant_invalid or operator_ended
consent.expiredA consent's term ran out.endedAt, reason: always expired

Read kind on every session event: it says which route to pull — …/index for a check, …/documents for a fetch — and which summary you are reading. An endpoint subscribed to session.ready receives both. A check that listed nothing is still session.ready, with entryCount 0, and so is a fetch that delivered nothing, with documentCount 0. session.failed leaves nothing to pull: a failed check has no index, and a failed fetch no files. A check's pages can be pulled before its session.ready, as they are written: no event is sent per page, so poll the session for them (9.2). On a check's session.ready, compare generation with the pages you hold — a different one means the read started again, and those are gone — and pull the pages you have not, starting before earliestPageExpiresAt; then page pageCount, which counts the index as pulled.

session.ready — the first check's index
{
  "eventId": "evt_4a649511ea49a42fc9f99d3f",
  "eventType": "session.ready",
  "kind": "initial",
  "occurredAt": "2026-01-15T09:14:22.000Z",
  "tenantId": "ten_7dd0eca207030a50185b2e0d",
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "externalUserRef": "cust_88213",
  "entryCount": 412,
  "attachmentCount": 655,
  "indexExpiresAt": "2026-01-15T12:14:22.000Z",
  "earliestPageExpiresAt": "2026-01-15T12:14:22.000Z",
  "generation": 1,
  "summary": {
    "fetched": 439,
    "receivedIndexed": 371,
    "sentIndexed": 41,
    "entryCount": 412,
    "attachmentCount": 655,
    "pagesWritten": 1,
    "complete": true,
    "pageCount": 1,
    "dropped": [
      { "reason": "no_attachment", "count": 25 },
      { "reason": "unreadable_headers", "count": 2 }
    ],
    "truncated": false
  }
}

session.ready — a fetch's files
{
  "eventId": "evt_9d0e21c4b7a35f6e8c12a047",
  "eventType": "session.ready",
  "kind": "fetch",
  "occurredAt": "2026-01-15T10:02:41.000Z",
  "tenantId": "ten_7dd0eca207030a50185b2e0d",
  "sessionId": "ses_c1b8f0e2a94d7735e6a0b913",
  "parentSessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "externalUserRef": "cust_88213",
  "documentCount": 2,
  "artifactsExpireAt": "2026-01-15T13:02:41.000Z",
  "summary": {
    "requested": 3,
    "deliveredCount": 2,
    "items": [
      { "position": 0, "outcome": "delivered" },
      { "position": 1, "outcome": "delivered" },
      { "position": 2, "outcome": "email_not_found" }
    ]
  }
}

session.failed — a refresh
{
  "eventId": "evt_1f6b3a9e0d42c7158e9b20d4",
  "eventType": "session.failed",
  "kind": "refresh",
  "occurredAt": "2026-02-15T09:20:03.000Z",
  "tenantId": "ten_7dd0eca207030a50185b2e0d",
  "sessionId": "ses_ffadf41590748a3364d18f7f",
  "parentSessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "externalUserRef": "cust_88213",
  "failureReason": "consent_revoked"
}

session.consent_declined does not end the session. Its reason says where your customer said no:

  • declined_at_google — on Google's consent screen.
  • gmail_unticked — they signed in but left Gmail access out (consent_partial).

attempt counts this session's declines, this one included. linkExpiresAt is when the link stops working; a decline does not move it. Until then your customer can use the same link again; after it, the session becomes link_expired, with no event.

{
  "eventId": "evt_72de237e04052cd728b01d26",
  "eventType": "session.consent_declined",
  "occurredAt": "2026-01-15T09:03:41.000Z",
  "tenantId": "ten_7dd0eca207030a50185b2e0d",
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "externalUserRef": "cust_88213",
  "reason": "gmail_unticked",
  "attempt": 1,
  "linkExpiresAt": "2026-01-15T10:01:10.000Z"
}

consent.granted is sent once per consent, when your customer's approval at Google is recorded, with the consent's own session id. It carries kind "initial" and a consent object: expiresAt, when the consent's term ends if nothing ends it sooner (8.4), and disclosureVersion, the version of the standard disclosure recorded on the consent. It carries nothing from the mailbox, because nothing has been read. The consent's session is then awaiting_request: call POST /v1/sessions/:id/refresh on it when you want the list (12.1). A missed event costs a poll: the session's status says the same.

{
  "eventId": "evt_3c8e51a07b29d4f6e1a0c573",
  "eventType": "consent.granted",
  "kind": "initial",
  "occurredAt": "2026-01-15T09:03:41.000Z",
  "tenantId": "ten_7dd0eca207030a50185b2e0d",
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "externalUserRef": "cust_88213",
  "consent": {
    "expiresAt": "2026-07-14T09:03:41.000Z",
    "disclosureVersion": 8
  }
}

consent.revoked and consent.expired carry the consent's own session id. By the time consent.revoked arrives, FinLens has given up its Gmail access at Google and deleted its copy. consent.expired is sent when the term runs out, and the access is given up and the copy deleted in the same cleanup pass or the next, within minutes. From either event, every reference of the consent stops working, any index or file of it not yet pulled has been deleted, and fetches, refreshes and pulls are refused with consent_not_active. Stop scheduling work for that consent. Neither is sent for a decline, because no consent existed.

{
  "eventId": "evt_ec54e70cf239d469ddf368a9",
  "eventType": "consent.revoked",
  "occurredAt": "2026-03-02T11:20:05.000Z",
  "tenantId": "ten_7dd0eca207030a50185b2e0d",
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "externalUserRef": "cust_88213",
  "endedAt": "2026-03-02T11:19:12.000Z",
  "reason": "client_revoked"
}

11.4 Rotate the signing secret

POST/v1/webhooks/:webhookId/rotate

Issues a new secret for the endpoint; its id, URL and event types stay. Returns 200 with the registration response's shape and the new signingSecret, again shown once.

  • {} — cut over now. Only the new secret signs from this moment. Use this if a secret may have leaked; it also closes any overlap still open.
  • {"overlapSeconds": n}, n from 0 to 3600 — for n seconds every event carries two signatures, new secret first, so you can roll the new secret out without failing events. The response's previousSecretExpiresAt says when the old one stops. 0 is the same as {}.
  • webhookId in the body is 400 invalid_request; the path names the endpoint. An unknown id is 404 not_found.

11.5 De-register an endpoint

DELETE/v1/webhooks/:webhookId

Stops delivery and frees you to register another endpoint. Returns 200 with one field, disabledAt: no event is sent after that instant. An unknown or already de-registered id is 404 not_found.

12. Consents and refreshes

Your customer's approval at Google is a consent: for you alone, for the index of the emails FinLens's searches find and the files you then choose, for up to six months (180 days; a consent given before version 8 of the disclosure, up to twelve months, 365 days). It lives on the session your customer approved — the one POST /v1/sessions returned, kind "initial" — and fetch, refresh and withdraw all take that session's id. Its consent block says whether it is live. A consent cannot be widened or extended: more time, or a different recipient, needs a new session and a new approval.

Approval reads nothing. When your customer approves, FinLens records the consent and its Google grant and reads nothing: the consent's session becomes awaiting_request and you receive consent.granted (11). FinLens first reads their Gmail when you ask for the list (12.1), and not before. A consent you never ask about reads nothing at all, and ends like any other — at its term, or sooner, for the reasons below; until then FinLens holds its Google grant, encrypted. If the consent ends before you ask for the list, the session becomes purged; nothing was read. A consent approved before version 8 of the disclosure had its first check at approval, and is otherwise as it was.

A consent can end before its term. You learn of it from consent.revoked or consent.expired (11), and from the consent block:

  • Time-limited access at Google. Google's screen has a box your customer can tick for ongoing access; left unticked, as it comes, the access is for a limited time — about six months — after which Google ends it unless they renew it there. When Google's limit comes before the end of the term, the consent's expiresAt is Google's limit, and the consent ends then, as expired. A consent given before version 8 of the disclosure has a 365-day term: if your customer chose time-limited access and did not renew it, Google refuses the access at its limit, and the consent ends at its next check or fetch, as grant_invalid.
  • Removed at Google. Your customer removed FinLens in their Google account's permissions: grant_invalid, at the next check or fetch.
  • A password change. Your customer changed their Google password: grant_invalid, at the next check or fetch.
  • Another consent of the same Google account ended. Google keeps one grant per account, so withdrawing or ending any of a person's FinLens consents ends Google's access for all of them (12.2): grant_invalid, at the next check or fetch.
  • Unused for six months. Google may end a grant not used for six months (12.3): grant_invalid, at the next check or fetch.
  • A withdrawal by you (client_revoked) or through FinLens (operator_ended).

On a consent you have not yet asked about, an end at Google shows at its first check: your first refresh call is accepted, and the check then fails with consent_revoked as the consent ends.

12.1 Ask for the list, and for a refresh

POST/v1/sessions/:id/refresh

One route, on the consent's own session, for two things: the first call on a consent that is awaiting_request asks for its first index, and every call after that asks for a refresh. The body is {} either way.

The first call: the consent's first index

On a consent whose session is awaiting_request — approved, nothing read yet — the call starts the consent's own first check, over each search's own window (9.1). It creates no new session: the consent's session goes from awaiting_request to scanning, then scan_complete and ready; its pages are pulled at …/index on the consent's own id; and its session.ready and session.failed carry kind "initial", as a first check's always have. It is never refused as refresh_too_soon. A second call while the first check is queued or running is 409 check_in_progress; once it has started, the next refresh may come an hour after.

Response to the first call — 202 Accepted

FieldTypeNotes
sessionIdstringThe consent's own session: the id you called. Poll it, and pull its index.
kindstringAlways "initial": the consent's first check.
statusstringAlways "scanning".
windowAfternullAlways null: each search reads its own window (9.1).
POST /v1/sessions/ses_5a5aa6e53d539a69c3d5fc04/refresh with the body {}, the consent awaiting_request

202 Accepted
{
  "sessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "kind": "initial",
  "status": "scanning",
  "windowAfter": null
}

Every later call: a refresh

Starts a new check of the same mailbox, with nothing asked of your customer. It builds a new index of email newer than the start of the last check whose index you pulled, found by the same seven searches (9.1), with new references; it fetches nothing. :id is the consent's own session; a refresh's or a fetch's id is 404. The body is {}: a refresh accepts no field. On a consent approved before version 8 of the disclosure, whose first check ran at approval, every call is a refresh.

Response to a refresh — 202 Accepted

FieldTypeNotes
sessionIdstringThe refresh's own session. Poll it, and pull its index.
parentSessionIdstringThe consent's session: the id you called.
statusstringAlways "consented": queued for a worker.
windowAftertimestamp or nullThis check's searches read mail after this instant — the start of the consent's last check whose index you pulled (the first or a refresh, never a fetch). null when you have pulled none: each search then reads its own window, as the first check did (9.1).
POST /v1/sessions/ses_5a5aa6e53d539a69c3d5fc04/refresh with the body {}

202 Accepted
{
  "sessionId": "ses_ffadf41590748a3364d18f7f",
  "parentSessionId": "ses_5a5aa6e53d539a69c3d5fc04",
  "status": "consented",
  "windowAfter": "2026-01-15T09:05:02.000Z"
}

Pulling each index is what moves the window. A check's index counts as pulled once GET /v1/sessions/:id/index has served you its last page after it is complete — page pageCount, in a response saying complete true — inside that page's three hours. A last page pulled while the check was still listing does not count; pull it again once the index is complete. A check whose index you did not pull to its last page leaves the window where it was: the next refresh lists everything that index would have, again — for a first check you missed, everything its searches found. Pull each index promptly, and de-duplicate the overlap (9.7).

From here the refresh is an ordinary session of kind "refresh". A poll shows scanning from the start, then scan_complete and ready, and it becomes purged three hours after it is ready. Its session.ready and session.failed events carry kind "refresh" and parentSessionId. A refresh waits up to six hours for a worker to pick it up (10.4); one not picked up by then ends failed, with consent_revoked if the consent ended meanwhile, otherwise internal_error. A consent that ends while the refresh is running ends it failed with consent_revoked too, and no index from it is served. The same holds for a consent's first check, which waits up to twenty-four hours for a worker (10.4).

Refusals, on either call

Refused withWhen, and what to do
400 invalid_requestThe body has a field.
404 not_foundNo such session in your account, or the id of a refresh or a fetch.
409 consent_not_activeThe consent is not live: not approved yet, withdrawn, expired, or its Google access gone. Read its consent block; only a new consent helps.
409 check_in_progressA check of this consent — the first check, a refresh or a fetch — is queued or running; poll it.
429 refresh_too_soonThe consent's last check (the first or a refresh) started less than an hour ago, whatever its outcome. Wait retryAfterSeconds. Fetches do not count. Never sent on the call that starts a consent's first check.

12.2 Withdraw a consent

DELETE/v1/sessions/:id/consent

Ends the consent, on the consent's own session. Call it as soon as your customer withdraws in your app. Returns 204 No Content, every time: withdrawing a consent that has already ended, however it ended, changes nothing and returns 204 too. A refresh's or a fetch's id is 404.

  • At once: no new check or fetch starts under it, and every reference of the consent stops working. A fetch or a refresh is refused with consent_not_active, and one still queued or running ends failed with consent_revoked, serving nothing.
  • Also at once: every index and file of the consent that FinLens still holds is deleted, whatever is left of its three hours, and its ready sessions read purged. Any pull of them — GET …/index or GET …/documents — is refused with 409 consent_not_active. Pull what you need before you withdraw.
  • Within about a minute, if your customer had approved: FinLens revokes its Gmail access at Google, deletes its copy, and sends consent.revoked with reason client_revoked.
  • Before approval: the same call cancels an unused link. It stops opening, and an approval that arrives anyway is refused. No consent.revoked is sent, because no access was ever granted; the session reads consent_pending until the link expires, then link_expired.
  • The same holds however the consent ends — by FinLens, at the end of its term, or by your customer at Google (from the next check or fetch, when Google's refusal shows): what is still held is deleted then, and pulls are refused.
  • It does not reach the index entries, references or files you already hold. Those are yours to delete under your own policy and the Partner Terms.

Your customer can also withdraw without you: at their Google account's permissions page, or by writing to our grievance officer. You learn of it from consent.revoked (grant_invalid at the next check or fetch, or operator_ended) and from the consent block.

One Google grant per person. Google keeps a single Gmail grant for FinLens per Google account, whichever app the consent was given in. When any one of a person's FinLens consents ends (withdrawn by you, by them or by another app, or at the end of its term), the others lose their access at Google with it. Yours then ends at its next check or fetch, as consent.revoked with grant_invalid, and that session fails with consent_revoked. To continue, show the disclosure again and create a new session.

12.3 Working with a consent

  • Ask for the list when your feature needs it. Nothing is read until your first refresh call (12.1). Ask straight after consent.granted if your feature needs the documents at once, or later, while the consent lasts.
  • Fetch what you need when you need it. Keep the references from each index, and fetch the files your feature uses — not every file on the list.
  • Refresh when your feature needs newer email, not on a timer. Frequent refreshes mostly list little. Pull the index of each refresh you start: one you skip is listed again by the next.
  • There is no keep-alive. Google may end a grant left unused for six months. A fetch or refresh after that fails with consent_revoked, the consent ends as grant_invalid, and you receive consent.revoked. FinLens does not touch the mailbox just to keep the grant alive.
  • Keep your own map from externalUserRef to the consent's session id, from each refresh and fetch to its consent, and from each reference to the entry it came from. There is no listing endpoint.
  • Stop when the consent ends: on consent.revoked, consent.expired, consent_not_active, or a session that fails with consent_revoked. The references you hold stop working at the same moment; delete them.
  • To continue past the term, show the disclosure again and create a new session; your customer approves again at Google.

13. Health check

GET/healthz

Unauthenticated liveness: 200 {"status":"ok","service":"api-gateway"}. It reports no version and no dependency status.

14. Retention and limits

WhatLimit
Consent link60 minutes from creation (expiresIn 3600). A decline does not extend it.
IndexEach page 3 hours from its own write, then deleted. FinLens keeps no other list of your customer's email.
ReferencesWork until the consent ends. FinLens does not store them; the key that opens them is destroyed when the consent ends.
Files3 hours from the moment the fetch is ready, then deleted.
Countssummary is kept after the index or the files are deleted.
Consent termUp to 180 days from approval, or until Google's time-limited access ends if that is sooner (12); up to 365 days for a consent given before version 8 of the disclosure. FinLens holds its Gmail access, encrypted, until the consent ends, whether or not you have asked for its list, then revokes it at Google.
First checkOnly when you ask: the first POST /v1/sessions/:id/refresh on a consent that is awaiting_request (12.1). Never refused as too soon. Nothing is read before it.
ChecksOne at a time per consent, a refresh or a fetch. Refreshes start at least one hour apart; fetches have no interval.
Refresh windowA refresh's searches list email newer than the start of the last check whose index you pulled to its last page once complete; with none pulled, each search's own window (9.1).
Waiting for a workerA first check up to 24 hours after you ask for it; a refresh or a fetch up to 6 hours after you ask, fetches in a lane of their own; then failed, internal_error (10.4).
Check sizeNo cap: a check reads every email the seven searches find (9.1) — a few hundred in most mailboxes — at about 98 emails a minute, usually all within a few minutes. Pages of up to 500 emails, each readable as soon as it is written (9.2); truncated only at a safeguard against a runaway read (9.8).
Index entryUp to 100 files and 50 To addresses per email; on a consent given under version 6 or later, its text up to 100,000 characters (bodyTruncated beyond) and its sender's name up to 200.
Fetch size1 to 50 references per fetch, whose sizeBytes add up to 32 MB at most (400 fetch_too_large); each file up to 25 MiB.
BusyGET …/index and GET …/documents may answer 503 service_busy while FinLens assembles other large responses; pull again after Retry-After seconds.
Bearer token1 hour by default; 60 seconds to 24 hours on request.
Rate limit60 requests per 60 seconds: per API key on POST /v1/tokens, per bearer token on other routes. Calls that carry neither, such as GET /healthz, are limited per IP address.
Clock skew±5 minutes, for signed requests and webhook timestamps.
Request body64 KiB, application/json only (413 payload_too_large, 415 unsupported_media_type).
WebhooksOne endpoint per account; one delivery attempt per event, 10-second timeout.

If a worker stops in the middle of a check or a fetch, another picks it up again; one that cannot be resumed ends failed, so every check and fetch still ends on its own.

What never appears in a response

No response or webhook has a field for an email's Cc or Bcc, its raw or HTML body, any header other than those named below, a Gmail message, thread or attachment id, your customer's Google credential, any password field, or anything FinLens read inside a file — it reads nothing there. What reaches you of an email, and where:

  • On the index, and nowhere else — GET /v1/sessions/:id/index, for every email it lists: the subject line (subjectLine), the files' names (attachmentName), and the From and To addresses (sentBy, sentTo), each a bare address; and, for a consent given under version 6 or later, the sender's display name (sentByName), the email's text (bodyText), Gmail's own verdict on the sender's domain (senderCheck) and the conversation reference (threadRef).
  • From GET /v1/sessions/:id/documents, and nowhere else — the files you fetched, each with two of the same: its email's From address (sentBy) and its own name (attachmentName). Never the subject line, the To addresses, the email's text, the sender's name, the verdict or the conversation.

Status responses, summaries, events and errors carry none of these. Automated tests check every response FinLens can produce against this list.

What does not exist

  • No listing or search of sessions, consents, indexes or webhooks.
  • No per-file route, and no copy of an index or a file after its window.
  • No way to cancel a running check or fetch, or to extend a consent.
  • No identity, and no unlocking: FinLens accepts no name, PAN, date of birth or mobile number, and opens no file.
  • No list of institutions, no document types, and no sender check of FinLens's own: senderCheck is Gmail's verdict.
  • No sandbox or test mode, and no OpenAPI file; this page is the reference.

15. Contact

Integration and API access · developers@finlenstech.com
Security reports · security@finlenstech.com
Privacy questions · privacy@finlenstech.com

What your customers are told is in the privacy policy and the consent disclosure — worth reading before you integrate.

Value Garage Private Limited · CIN U66190DL2025PTC453505
Registered office: Flat no. 26, Vandana Apartment, East Delhi, Delhi, India — 110092