FinLens Talk to us

Developers · Preview

The FinLens API

Preview documentation · 10 August 2026
Value Garage Private Limited · CIN U66190DL2025PTC453505

Five endpoints and a health check. Your customer's financial documents, collected from their mailbox with their consent, handed to you as PDFs and extracted JSON on a three-hour link.

not live

This API is not live. There is no production deployment, no base URL we can give you, and no sandbox to sign up for. This page describes the interface exactly as it is built in code, so that an integrator can read it, argue with it, and plan against it before anything is running.

Read §1 before you plan around any of it. There are things this system cannot do yet, and they are named on this page rather than left for you to discover.

1. Status of this document

This is pre-release documentation for an API that has not been deployed. It is written against the shipped contracts and route handlers, not against a design document — every endpoint path, field name, enum value and error code below was checked against the source before it was written here. Where the code and an internal design note disagree, the code wins and this page follows the code.

What that means for you, stated plainly rather than buried:

  • There is no base URL yet, no hosted sandbox, and no self-serve signup. API credentials are issued by hand.
  • There are no SDKs. The request signing scheme in §4 is about thirty lines in any language, and it is documented here in full for that reason.
  • The sender registry is only partly verified. FinLens reads mail only from senders in a curated registry, and an entry becomes readable only after a real message header from that institution has been captured and checked. 11 of 187 entries have passed that check. The rest emit no queries at all, so a live scan today can run correctly, complete, and return an empty document list. Plan for that outcome rather than treating it as a fault. The posture is deliberate: an entry that never matches is safer than one that quietly matches the wrong sender.
  • Field names and shapes can still change before the first production integration. Anything that changes after that becomes a coordinated breaking change across two companies, which is exactly why this is being written down now rather than later.

Nothing on this page is a roadmap item written in the present tense. If a capability is missing, it is listed in §13 with what it would take to close it.

2. The shape of an integration

FinLens sits between you and your customer's mailbox. You already know who your customer is. You need documents from them, and you would rather not ask them to find and upload PDFs. So you send us the identity you already hold, we send you a link, and your customer decides twice — once at Google, once at FinLens — whether their documents reach you.

1 · post /v1/sessions

You ask

Call POST /v1/sessions with your customer's identity and the document categories you need. You get back a session id and a consent URL, which is Google's own OAuth screen. FinLens serves no page on the way in.

2 · consent at google

They authorise Google

You put that URL in front of your customer. They grant read-only Gmail access at Google.

3 · consent at finlens

They approve you

They then land on a FinLens page that names your company, lists the categories you asked for, and states how far back the scan will look. Nothing is fetched until they approve there. This second step is what makes the transfer lawful. It cannot be skipped.

4 · scan

The scan runs

It fetches only PDF attachments from registered financial senders, and verifies each message's sender authentication before opening anything.

5 · notify

Your webhook fires

Your registered webhook endpoint receives a notification, if you have one. The webhook does not carry documents — see §11.

6 · get /v1/sessions/:id/documents

You collect

Call GET /v1/sessions/:id/documents and receive the PDFs plus extracted JSON. You have three hours from the scan, after which everything derived is destroyed.

The whole surface

Six routes in total: the five /v1/ endpoints and a health check. That is the entire HTTP interface. There is nothing else, and no undocumented endpoints are held in reserve.

RouteAuthWhat it does
POST /v1/sessionsSignedIdentity and document categories in, consent link out
GET /v1/sessions/:idSignedSession state, counts, per-document provenance
GET /v1/sessions/:id/documentsSignedThe documents and their extracted JSON
POST /v1/webhooksSignedRegister a notification endpoint; returns the signing secret once
POST /v1/webhooks/:webhookId/rotateSignedReplace the signing secret
GET /healthzNoneLiveness

There is no endpoint to list your webhooks, no endpoint to fetch a single document by id, no endpoint to change a webhook's URL, and no endpoint to delete one. See §13.

3. Conventions

ThingRule
Content typeapplication/json, UTF-8. Send the header only when there is a body.
Request body limit64 KiB. No endpoint accepts anything larger; the biggest legitimate body is four identity fields, a category list and a return URL.
Unknown fieldsRejected. Every request schema is strict, so a typo'd or extra key is a 400 rather than a silently ignored field.
TimestampsRFC 3339 with an explicit offset, e.g. 2026-08-10T09:14:22.000Z
DatesYYYY-MM-DD
Rate limit120 requests per 60 seconds, keyed on the API key id in your headers (falling back to source IP before a key is established). Over the limit is 429 rate_limited.
Request idsGenerated by us, never read from an inbound header. Returned in every error body. Quote it in a support request.
Response headersEvery response carries x-content-type-options: nosniff, cache-control: no-store and referrer-policy: no-referrer.
VersioningThe version is in the path (/v1/). There is no version header.

4. Authentication

Every /v1/ route requires a signed request. There are no bearer tokens and no session cookies: each request carries an HMAC over its own method, path, timestamp, nonce and body, so a captured request cannot be replayed against a different route or a different payload.

4.1 Headers

All four are required on every call. Any missing one is a 401.

HeaderValue
x-finlens-key-idYour API key id. Not a secret.
x-finlens-timestampCurrent time in epoch milliseconds, as a decimal string.
x-finlens-nonceUnique per request within the skew window. 16 random bytes, base64url, is what our own client uses.
x-finlens-signaturev1= followed by 64 lowercase hex characters. During a rotation overlap window, two such values comma-separated — split and accept if any matches (§11.4).

4.2 The canonical string

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

METHOD \n PATH \n TIMESTAMP \n NONCE \n SHA256_HEX(BODY)
  • METHOD — uppercase, e.g. POST.
  • PATH — the path only. The query string is excluded, so a proxy that appends tracking parameters does not break your authentication.
  • TIMESTAMP — the exact string you put in the header.
  • NONCE — the exact string you put in the header.
  • SHA256_HEX(BODY) — SHA-256 over the raw body bytes, lowercase hex. For a request with no body, hash the empty string — this is the value e3b0c442..., not an empty field.

Serialize the body exactly once. The signature covers the bytes you actually send. If you re-serialize the parsed object anywhere between signing and sending, key order or whitespace will differ and every request will fail with a generic 401 that looks like a credential problem.

4.3 The key

The HMAC key is not your API secret directly. It is the SHA-256 of your API secret, lowercase hex — and that 64-character string is used as the key. Do not hex-decode it to 32 bytes first; the key is the ASCII text of the digest.

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

4.4 Worked example

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

function signedHeaders(apiKeyId, apiSecret, 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(apiSecret, "utf8").digest("hex");
  const mac = createHmac("sha256", key).update(canonical, "utf8").digest("hex");

  return {
    "x-finlens-key-id":    apiKeyId,
    "x-finlens-timestamp": timestamp,
    "x-finlens-nonce":     nonce,
    "x-finlens-signature": `v1=${mac}`,
  };
}

const body = JSON.stringify({ /* ... */ });          // serialize once
const headers = signedHeaders(KEY_ID, SECRET, "POST", "/v1/sessions", body);
await fetch(BASE_URL + "/v1/sessions", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body,                                              // the same string
});

4.5 What gets rejected

The server checks, in order: signature format, version prefix, timestamp validity, clock skew, the MAC itself, and finally nonce replay. Every one of these failures returns the same 401 with the same message.

That is deliberate. Telling you whether the timestamp or the MAC was wrong tells anyone probing with a stolen key id which half of the credential to keep working on. The specific reason is recorded against your request id on our side, so a support request quoting that id gets a real answer.

  • Clock skew is ±5 minutes. Requests dated more than five minutes in the past or the future are rejected. Run NTP.
  • Nonces must be unique within that five-minute window. A repeated nonce on an otherwise valid request is rejected.
  • An unknown key id, a revoked key id and a bad signature are indistinguishable from outside.

5. Errors

Every failure returns the same envelope. There is no other error shape anywhere in the API.

{
  "error": {
    "code": "unknown_institution",
    "message": "One or more named institutions could not be resolved against the sender registry.",
    "requestId": "0f2c9a1e-6b4d-4f7a-9c31-2ad5e8b0c744"
  }
}

The message is a fixed string per code. You will never receive an exception message, a stack trace, a database error, or field-level validation detail — a credential-bearing endpoint that reports which field failed is an oracle for anyone probing it. Validation detail is logged against the requestId.

CodeHTTPWhen
invalid_request400Body failed schema validation, malformed JSON, unknown field, bad returnUrl, bad webhook URL, or webhookId sent in a rotate body.
unknown_institution400One or more namedInstitutions did not resolve against the sender registry. See §6.3.
unsupported_overlapRetired 11 Aug 2026. Overlap windows are supported; this code is no longer returned. See §11.4.
unauthorized401Any authentication failure, collapsed to one code and one message.
not_found404No such session or webhook — or one belonging to another tenant. The two are deliberately indistinguishable.
conflict409You already have a registered webhook endpoint. One per tenant.
rate_limited429Over 120 requests in 60 seconds.
internal_error500Ours. Quote the request id.

The error contract also defines forbidden (403), but no route currently emits it. It is listed here for completeness and you should not write a branch for it.

6. Create a session

POST  /v1/sessions

Identity in, Google's consent URL out. This call mints a link and stores your customer's identity sealed; nothing is fetched and no mailbox is touched until they consent.

6.1 Request

FieldTypeNotes
externalUserRefstring · required1–128 characters. Your own identifier for this customer. Opaque to us; echoed back on webhook events so you can reconcile.
identityobject · requiredExactly four fields, listed below. No others are accepted.
documentTypesstring[] · requiredAt least one. Restated to your customer on the consent page.
namedInstitutionsstring[] · optionalDefaults to []. Maximum 50. Each 1–128 characters. See §6.3.
returnUrlstring · requiredWhere the customer lands when the flow ends. See §6.4.

identity

FieldFormatNotes
fullNamestring, 1–128As the institution holds it.
pan^[A-Z]{5}\d{4}[A-Z]$Ten characters, uppercase, e.g. ABCDE1234F
dateOfBirthYYYY-MM-DD
mobile^[6-9]\d{9}$Ten digits, no country code, no spaces.

Four fields, fixed — and that has a cost we would rather name than have you discover. These four are what derive the passwords on protected statement PDFs. Many Indian institutions do not key their passwords off any of them: HDFC savings, Yes Bank and Canara use a Customer ID; SBI Card and HDFC credit cards use the card number; LIC and most life insurers use the policy number.

Those documents are still delivered to you — as the original locked PDF, with a machine-readable reason naming the identifier that would have opened it (§10.3). Mutual fund CAS and tax documents key off PAN alone and open reliably. The categories that suffer are retail banking and life insurance specifically.

6.2 Document types

Eight categories. Send the exact strings.

ValueWhat it covers
bank_statementBank account statements
card_statementCredit card statements
loan_statementLoan account statements
loan_sanctionSanction letters
ecasConsolidated Account Statements from the depositories and RTAs
policy_documentInsurance policy documents
premium_noticePremium due and payment notices
itr_acknowledgementITR-V acknowledgements

itr_acknowledgement is the whole of the tax category, and that is a scope statement rather than a gap we have not got to. AIS, TIS and Form 26AS are portal downloads and never arrive by email; a Form 16 sitting in a mailbox came from a payroll vendor, not from a government domain. ITR-V is the only tax document reliably delivered by email, so it is the only one promised.

6.3 Named institutions, and why they fail loudly

By default the scan sweeps 18 months of history across all registered financial senders. Naming an institution lifts that to unbounded history for that institution. Insurers are always unbounded, without being named.

fails at intake

Names resolve against the sender registry on this call, before the link is minted, and an unresolvable one is a 400 unknown_institution.

Write "Bajaj Finance" against a registry entry named "Bajaj Finserv" and your intake call is rejected. That is the point. The alternative — accepting the name, scanning nothing extra, and handing you a disappointing result set after your customer has already granted mailbox access — is an error with no error in it, and there is no way for you to tell it apart from a customer who simply has no documents.

Matching is exact after normalisation, and normalisation only removes noise that cannot change identity: case, surrounding whitespace, and punctuation that institution names carry inconsistently (ICICI Bank Ltd. and ICICI Bank Ltd are the same entry). There is no fuzzy matching, and there will not be — edit distance resolves "Bajaj Finance" to "Bajaj Finserv", which are two different companies, turning the loud failure into a silent wrong answer with a response confirming your mistake.

Resolution is all-or-nothing: if any name fails, the whole call fails and no session is created. The response echoes what each name resolved to, so you can diff what you asked for against what we matched. Send either the display name or the stable institution id. The id is what the response carries back, so a client scripting against that response can send it straight back in.

6.4 returnUrl

Where the customer's browser lands when the flow ends. We redirect there, so it is validated tightly — an open redirector on the domain a user trusted with their mailbox thirty seconds ago is a phishing primitive, not a nuisance.

  • Maximum 2048 characters.
  • https:// only, with one exception: http://localhost (optionally with a port) is permitted for local testing, because Google permits it for its own redirect URIs.
  • No credentials in the URLhttps://user:pass@example.com is rejected.
  • No IP literals, in either family. Your return page has a name. Note that https://0x7f000001/ and https://2130706433/ are also IP literals as far as a browser is concerned, and are also rejected.
  • No .local hostnames.

6.5 Response — 201 Created

FieldTypeNotes
sessionIdstringUse it on every other session call.
statusstringAlways "created".
consentUrlstringGoogle's own OAuth consent URL, fully formed. Put your customer here.
expiresInintegerSeconds the link is good for. Capped at 3600; 900 by default.
resolvedInstitutionsobject[]One entry per name you sent: requested, institutionId, displayName.
scanScopeobjectdefaultSweepMonths (18), unboundedInstitutions (string[], de-duplicated display names), insurersUnbounded (boolean, always true).

The expiresIn clock starts now, not when your customer clicks. If you pre-generate links and mail them out, some will expire before anyone opens them, and your own delivery channel is the part we cannot see. Mint the link at the moment you are about to show it.

There is no session token in this response. An earlier build returned one and nothing verified it — a signed-looking token in a response reads to an integrator as the thing protecting the flow, and it protected nothing. Your API credentials are what authenticate you; the session id is an identifier, not a capability.

6.6 Example

POST /v1/sessions
{
  "externalUserRef": "cust_88213",
  "identity": {
    "fullName": "Asha Menon",
    "pan": "ABCDE1234F",
    "dateOfBirth": "1991-04-17",
    "mobile": "9876543210"
  },
  "documentTypes": ["bank_statement", "card_statement", "ecas"],
  "namedInstitutions": ["HDFC Bank"],
  "returnUrl": "https://app.example.com/finlens/done"
}

201 Created
{
  "sessionId": "ses_7Qn2xK4mB9",
  "status": "created",
  "consentUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "expiresIn": 900,
  "resolvedInstitutions": [
    { "requested": "HDFC Bank", "institutionId": "hdfc-bank", "displayName": "HDFC Bank" }
  ],
  "scanScope": {
    "defaultSweepMonths": 18,
    "unboundedInstitutions": ["HDFC Bank"],
    "insurersUnbounded": true
  }
}

7. Session states

Ten states. The obvious three-state model — in progress, dropped, done — was rejected because "dropped" merges outcomes that each imply a different next action on your side.

StatusTerminalMeaning, and what to do
createdNoLink minted. Returned by the intake call only — see the note below.
consent_pendingNoWaiting on your customer. Keep polling.
consent_declinedYesThey refused at Google. Their choice, not an error, and not retryable. Stop polling.
consent_partialYesThey authorised the account but unticked Gmail access. This is not an outage. Stop polling.
link_expiredYesNever opened inside the window. Stop polling.
scanningNoMailbox scan running. Retried automatically if a worker stops mid-scan (§13.1).
scan_completeNoCounts are known, extraction still running. summary and documentCounts are available from here.
readyNoDocuments are pullable. Not terminal — it becomes purged when the window closes.
purgedYesThree hours elapsed. Documents, mailbox access and identity destroyed. Counts survive.
failedYesThe scan could not complete and the cause is ours. failureReason is present.
terminal means terminal

Terminal states are terminal. A client seeing consent_declined must mint a new session, not keep polling.

We are saying this because polling a terminal state forever is the default behaviour of every naive client, and because four of these ten states are terminal in ways that look retryable if you squint. consent_partial is the worst of them: a customer who unticked Gmail looks exactly like an outage under a coarser model, so a client retries — repeatedly, against a person who deliberately declined.

Every status response carries a terminal boolean, derived server-side. Branch on that rather than hardcoding the list, and your poller stays correct when the list grows.

created is not reachable from a poll. It is what POST /v1/sessions returns. Poll immediately afterwards and you will see consent_pending, because nothing in this system ever learns that the link was opened — your customer goes from your app straight to Google, and there is no observation for us to report in between. Do not write a state machine that waits for created to appear on a poll.

Failure reasons

Present exactly when status is failed. Operational causes only — a customer declining, unticking Gmail or letting a link expire is a status, never a failure reason. A mailbox with no matching documents is ready with an empty summary, not a failure.

ReasonWhat it means for you
gmail_unavailableGoogle was unreachable. Something is broken; a new session may hit the same wall.
quota_exhaustedAPI quota ran out. Retry later with a new session.
scan_timeoutThe scan did not finish in time.
internal_errorOurs, and unclassified. Also the value used when a session failed before any scan ran.

8. Read session status

GET  /v1/sessions/:id

Counts and named reasons only. No subject lines, no filenames, no sender addresses, no Gmail message ids — none of those exist on any response this API can produce.

FieldTypeWhen present
sessionIdstringAlways
statusstringAlways. One of the ten in §7.
terminalbooleanAlways. Derived from status.
createdAttimestampAlways
artifactsExpireAttimestampOnce there are artifacts. Absent before that.
summaryobjectFrom scan_complete onward. Survives purged.
documentCountsobject[]From scan_complete onward. Survives purged.
documentsobject[]From scan_complete onward. Omitted entirely once purged.
failureReasonstringExactly when status is failed.

8.1 summary — stage counts

FieldMeaning
fetchedMessages fetched.
senderAuthenticatedOf those, how many passed sender authentication.
documentMatchedOf those, how many matched a known document template.
extractedCountOpened, read, delivered with structured content.
deliveredUnparsedCountDelivered as the original PDF with a reason.
droppedArray of { reason, count }. Messages that never became a document.
quarantinedArray of { reason, count }. Documents delivered unparsed.

Drop reasons are sender_unauthenticated, no_document_match and no_attachment. A drop produced nothing; a quarantine produced a PDF you can still open by hand. Both are counted, only quarantines are delivered.

sender_unauthenticated means the message failed DKIM alignment (or, for .gov.in senders that publish no DKIM, SPF with an aligned envelope sender). A message addressed from a bank is not proof it came from one, and that check runs after fetch.

8.2 documentCounts — per-category, split by outcome

An array of { documentType, extractedCount, deliveredUnparsedCount }.

The split matters. bank_statement: 2 as a single number reads as a promise that two statements are machine-readable, when one of them may be a locked PDF. The split says exactly which. This field is also a durable derivation, so it outlives the artifacts — a client reconciling the next morning still learns what the scan found, even though the documents themselves are gone.

8.3 documents — per-document provenance

An array of { documentId, institution, documentType, period, status, quarantineReason? }. status is extracted or delivered_unparsed; quarantineReason is present exactly when the status is delivered_unparsed.

institution is the registry display name, never free text off a mail header. period is a coarse label such as "Jun 2026", deliberately not a message timestamp.

This is provenance, not content — the documents themselves come from §10. Once the session is purged this field is omitted entirely rather than sent as [], because an empty array reads as "the scan found nothing" and documentCounts is still there to say what it actually found.

9. The three-hour window

retention window

The pull window is the retention window. A client building a nightly batch job against this API will find nothing.

Derived artifacts — the unlocked PDF and its extracted JSON — live for at most three hours from the scan, then are hard-deleted. Source email content is never stored at all. There is no archive, no re-fetch, and no way to extend the window: after it closes the mailbox grant is destroyed too, so the documents cannot be produced again without a fresh consent from your customer.

Design your integration to pull on notification, or to poll and pull promptly. Concretely:

  • artifactsExpireAt on the status response, and expiresAt on each individual document, are the deadline. The session.ready webhook carries artifactsExpireAt too, deliberately — you should see the deadline at the moment you learn there is something to fetch.
  • After the window, the session reports purged. Not 404. The two mean opposite things: one says "your three hours are up, mint a new session", the other says "you have the wrong id", and a client that cannot tell them apart goes looking for a bug in its own code.
  • The counts survive. The documents do not.

10. Pull documents

GET  /v1/sessions/:id/documents

This is the delivery. Everything else is notification or metadata.

The response envelope is { sessionId, status, documents }. The status travels with the documents so that an empty list always tells you why it is empty — still scanning, nothing found, or purged — from the same response.

10.1 Each document

Every entry carries these fields, whatever the outcome:

FieldTypeNotes
documentIdstringMatches the documentId in the status response.
sessionIdstring
tenantIdstringYours. Re-checked on every artifact before it is serialized.
institutionstringRegistry display name, e.g. "HDFC Bank".
documentTypestringOne of the eight categories.
periodstringCoarse label, e.g. "Jun 2026".
documentBase64stringThe PDF, base64-encoded.
contentTypestringAlways "application/pdf".
documentSizeBytesintegerOf the decoded bytes.
documentSha256stringLowercase hex, over the decoded bytes. Verify what you pulled.
expiresAttimestampWhen this artifact is destroyed.
statusstring"extracted" or "delivered_unparsed". Branch on this.

Then, depending on status:

  • "extracted" — plus extractedJson, a JSON object.
  • "delivered_unparsed" — plus quarantineReason, and no extractedJson key at all. Not an empty object: an empty object reads as "a document with nothing in it", and a missing key makes a client that forgot this branch fail loudly instead of quietly.

10.2 extractedJson

The shape is per-template and is deliberately not fixed by the contract. It is validated as JSON and otherwise passed through. Constraining it further would be false precision — extraction shape varies by document family, and no decision has settled it.

What it typically carries: account holder name, account or policy number, statement period, balances, transactions, holdings. It is machine-read from the document. It is not analysis, scoring or opinion, and no person reads it — extraction runs in our own containers, with no third party in the pipeline and no human review step anywhere in it.

Treat this as sensitive. The extracted JSON carries your customer's account details and frequently their PAN. It reaches you because your customer approved a page naming your company. What happens to it afterwards is governed by your own privacy policy and your own regulator — and by Partner Terms §3, which flows Google's Limited Use restrictions down to you along with the documents.

10.3 Locked documents — the quarantine reasons

A document we could not open is still a document you want, so it ships as the original PDF rather than being dropped. The reason is specific, never a generic "locked": it tells you which additional identifier would have opened it, which is information you can act on — you may well hold that identifier already.

ReasonWhat would have opened itTypical of
needs_customer_idThe institution's Customer IDHDFC savings, Yes Bank, Canara
needs_card_numberThe card numberSBI Card, HDFC credit cards
needs_policy_numberThe policy numberLIC and most life insurers
identity_mismatchNothing further — the password is the identity, and it did not workCAS and ITR-V
user_set_passwordNothing. Permanently unopenable.CAMS and KFintech mailback statements
ocr_failedThe file opened but could not be readScanned or malformed PDFs

Two of these deserve reading rather than skimming:

  • identity_mismatch is not a request for more data. For PAN-keyed families the password derives from PAN and date of birth alone, so a failure cannot mean "send another identifier". It means the identity you gave us does not match what the institution holds — a PAN typo, a date of birth recorded differently, or a document belonging to someone else. Correct the identity and a new session may open it.
  • user_set_password is permanent. The investor chose that password themselves and no derivation will ever produce it. Do not retry these, and do not build a UI that asks your customer for an identifier that cannot help.

The other four are not permanent: a correction, or a future release that accepts more identifiers, could open them.

11. Webhooks

The webhook notifies. It does not deliver. No document content rides on a webhook payload — the event says "there are results", and you then pull them from the API inside the three-hour window.

That split is deliberate, and it is what makes a webhook outage on your side cost almost nothing: it costs you a poll, rather than costing your customer a fresh consent. It also means webhooks are optional. You can poll GET /v1/sessions/:id and never register one.

11.1 Register an endpoint

POST  /v1/webhooks

FieldTypeNotes
urlstring · requiredhttps:// only, maximum 2048 characters.
eventTypesstring[] · optionalsession.ready and/or session.failed. Omitted or empty means every type — see below.
descriptionstring · optional1–200 characters, for your own reference.

The URL is checked at registration for the things that are stable: https scheme, no credentials in the URL, no port other than 443, and no private or reserved IP literal. The hostname is deliberately not resolved here — a resolution made at registration is a check that lapses, since a name answering publicly today can answer an internal address tomorrow. The real check runs immediately before every send, and pins the address it validated.

eventTypes is a real filter. A named list is exhaustive: subscribe to session.ready alone and session.failed is not sent to you at all.

An empty list subscribes to every event type, including types added later — and an omitted field is an empty list, so the default is "send me everything". That is deliberate rather than accidental: endpoints registered before this filter existed are stored empty, and reading empty as "nothing" would have taken every one of them silently dark.

Response — 201 Created

Returns webhookId, url, eventTypes, description (if you sent one), createdAt, and signingSecret.

shown once

The signing secret is shown exactly once and is never recoverable. It appears in this response body and in nothing else — there is no endpoint that reads it back, and the field does not exist on any other schema.

Store it wherever you keep key material, and do not log this response. If you lose it you do not ask us for it again; you rotate, which is a different operation with different consequences. On our side the secret is redacted from logs unconditionally. On yours, this line is the control.

You do not choose the secret and cannot supply one. A client-chosen secret is a client-chosen entropy level, and it would have travelled through your logs and ours in a request body before it was ever used. The request schema has no field for it.

A previousSecretExpiresAt field exists on this schema and is never set. See §11.4.

11.2 One endpoint per tenant

A second POST /v1/webhooks is refused with 409 conflict. One live endpoint per tenant. A second registration would make which endpoint receives events undefined — decided by an ordering nobody wrote down as a policy — so it is refused rather than guessed at.

The consequence you need to plan for: an endpoint's URL cannot be changed, and there is no de-registration. Neither operation exists in the API today. If your webhook URL must move, that is a support request, not an API call.

11.3 Verifying an event

An outbound event is a POST with content-type: application/json and four FinLens headers:

HeaderValue
x-finlens-timestampEpoch milliseconds.
x-finlens-noncePer-delivery nonce.
x-finlens-signaturev1= plus 64 lowercase hex characters. Two comma-separated values during a rotation overlap window (§11.4).
x-finlens-event-idThe event's eventId. Use it to deduplicate.

The canonical string is the same five-component shape as inbound requests, so you can reuse most of one verifier:

"POST" \n PATH \n TIMESTAMP \n NONCE \n SHA256_HEX(RAW_BODY)
not symmetric

Two differences from inbound, and both will silently break a verifier that assumes symmetry.

  • PATH here includes the query string, where inbound signing uses the path alone. If your webhook URL carries a query string, sign path and query when you verify.
  • The key is derived differently. Inbound uses the hex SHA-256 of your API secret as an ASCII key. Outbound uses HKDF over your webhook signing secret.

The verification key is HKDF-SHA256 over the plaintext whsec_… secret you were given at registration:

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_string ) )

Hash the raw request body bytes as received, before any JSON parsing. Compare in constant time. Reject anything outside a ±5 minute skew.

Delivery behaviour

  • One attempt. There is no retry. A 10-second timeout, no redirects followed, and the response body is discarded.
  • A non-2xx response, a timeout, or an unreachable host means you simply did not get the nudge. The documents are unaffected and still pullable — poll GET /v1/sessions/:id and pull as normal.
  • There is no durable queue of pending notifications, and that is a retention decision rather than an oversight: a queue of pending notifications is a durable record of who scanned what, which is precisely the thing we have promised not to keep.
  • Having no registered endpoint at all is a normal state, not an error.

Event payloads

Both events carry eventId, occurredAt, tenantId, sessionId, externalUserRef and eventType. Then:

EventAdditional fields
session.readydocumentCount, artifactsExpireAt, summary
session.failedfailureReason, and summary if any counts were reached before the failure

summary is optional on session.failed for a reason worth knowing: a scan that died before reaching the mailbox has counts nobody knows. Sending a zeroed summary instead would assert that we reached Gmail and found nothing, which is the claim you would size a retry against.

{
  "eventId": "evt_3Kd91mQb",
  "eventType": "session.ready",
  "occurredAt": "2026-08-10T09:14:22.000Z",
  "tenantId": "ten_example",
  "sessionId": "ses_7Qn2xK4mB9",
  "externalUserRef": "cust_88213",
  "documentCount": 6,
  "artifactsExpireAt": "2026-08-10T12:14:22.000Z",
  "summary": {
    "fetched": 41,
    "senderAuthenticated": 38,
    "documentMatched": 7,
    "extractedCount": 5,
    "deliveredUnparsedCount": 1,
    "dropped": [{ "reason": "sender_unauthenticated", "count": 3 }],
    "quarantined": [{ "reason": "needs_customer_id", "count": 1 }]
  }
}

11.4 Rotate the signing secret

POST  /v1/webhooks/:webhookId/rotate

Mints a new secret for an existing endpoint. The endpoint keeps its id, its URL and its subscriptions; only the key material changes. The response is the same shape as registration, carrying the new signingSecret — again, exactly once.

Send an empty body, {}. Two things are refused:

  • webhookId in the body is a 400 invalid_request. The path is authoritative for which endpoint. Two sources for one identifier is how a request comes to mean something other than what it was signed for — and since the signature covers the path, a signature minted to rotate endpoint A cannot be re-aimed at endpoint B by swapping the body.
  • overlapSeconds below 0 or above 3600 fails schema validation and comes back as invalid_request. An hour is the ceiling because an overlap is a deploy window, not a second permanent secret.
overlap window

Ask for an overlap and both secrets are honoured until it expires.

Send {"overlapSeconds": 300} and, for those five minutes, every event carries a signature under the new secret and under the one it replaced. That is what lets you roll the new value across your fleet without a moment where in-flight events fail verification on whichever instances have not restarted yet. The response reports previousSecretExpiresAt, read from the stored record rather than echoed back from your request.

An absent overlapSeconds still means cut over now, so an ordinary {} body behaves exactly as it always has and you get no window you did not ask for. overlapSeconds: 0 is the same thing said explicitly.

A rotation with no overlap also closes a window already open. If you rotate because a secret is believed disclosed, rotate without an overlap: that stops the old secret being signed with immediately, rather than for the remainder of a window somebody set earlier. Rotating twice inside a window keeps only the immediately previous secret — there are never more than two live.

This changed on 11 August 2026. Until then overlapSeconds above zero was refused with 400 unsupported_overlap, because the endpoint record held one secret and reporting a window nothing honoured would have been worse than reporting none. The record holds both now. If you built against the refusal nothing you did breaks — an absent field still cuts over.

verify every signature in the header

During an overlap window x-finlens-signature carries two values, comma-separated.

x-finlens-signature: v1=<current>,v1=<previous>

Split the header on commas and accept the event if any value matches your computed MAC. Both cover the identical canonical string, so they are alternatives rather than two different claims — and the current secret is always first, so once you have finished rolling out you match on the first candidate and never compute the second.

A verifier that compares the whole header string will reject every event in a window. That is the one integration change worth checking before you ask for an overlap. Outside a window the header carries exactly one value, so a split-and-scan verifier is correct in both cases and is what we recommend writing from the start.

A rotation aimed at an unknown endpoint id, or another tenant's, returns 404 not_found. The two are indistinguishable, for the same reason they are on sessions.

12. Health check

GET  /healthz

Unauthenticated, and deliberately uninformative: { "status": "ok", "service": "api-gateway" }. It touches neither the database nor the ledger — a readiness probe that queries the database turns a five-second blip into a restart loop across every instance at once. It reports no version, no dependency status and no uptime.

13. Known limits

Everything here is a real gap in the current build, not a caveat about edge cases. Each one is something you would otherwise find out the hard way.

13.1 A stalled scan retries itself

retries itself

A scan that stops making progress is picked up again automatically, about fifteen minutes after the worker handling it stopped. The session then either completes normally or ends failed.

So you do not need a wall-clock ceiling on your poller, and you should not mint a new session to recover. Poll until terminal is true. Starting over costs your customer a consent they did not need to give again.

One honest caveat: if the retry also cannot complete, the session can end purged rather than failed — the artifacts' three-hour deadline can arrive before a status is written. Treat purged the same way you treat failed: it is terminal, and nothing is coming.

Your customer's data is not left dangling either way: mailbox access is destroyed at its own deadline and any bound artifacts are destroyed at the three-hour mark regardless.

13.2 Webhook management is minimal

  • One endpoint per tenant, enforced with 409 on a second registration and, since 11 Aug 2026, by a database constraint rather than by that check alone — two registrations arriving together could previously create two live endpoints, leaving which one received events decided by an ordering nobody had written down as a policy.
  • No listing endpoint. There is no GET /v1/webhooks, and that absence is what makes the signing secret genuinely show-once. Keep your own record of the webhookId you were given.
  • No partial update. There is no PATCH for a registered endpoint's URL. To move where events land, de-register and register again — which mints a new secret, deliberately: a partner pointing events at a different host should not carry the old host's key material there.
three of these were true and no longer are

Until 11 August 2026 this section also said there was no overlap window on rotation, no way to change an endpoint's URL, and no de-registration — which together meant no self-serve recovery from a wrong URL at all. All three are closed:

  • DELETE /v1/webhooks/:webhookId de-registers an endpoint and reports the instant it stopped receiving events.
  • De-registering frees the tenant to register again, at a new URL, with a new secret.
  • overlapSeconds is honoured — see §11.4.

Nothing built against the old behaviour breaks. Registration and rotation are unchanged for a caller who does not use the new field, and an absent overlapSeconds still cuts over.

13.3 Other things that do not exist

  • No per-document endpoint. Documents come as a collection from GET /v1/sessions/:id/documents. There is no /documents/:documentId.
  • No session listing or search. Keep your own mapping from externalUserRef to sessionId.
  • No cancellation endpoint. A session ends by expiring, being declined, completing, or being purged.
  • No purge receipt endpoint. Deletion receipts are written internally; there is no route that serves one.
  • No extended identity fields. Customer IDs, card numbers and policy numbers cannot be supplied, which is why §10.3 exists.
  • No sandbox, no test mode, no fixture data.
  • No OpenAPI specification published yet.

13.4 What never appears in a response

This is a property of the system rather than a policy statement, and it is worth knowing before you design around it. The interface through which anything leaves has no field for an email subject, a sender address, an attachment filename, a Gmail message or thread id, your customer's Google credential, or any password derived to open a document. A test walks every response the API can produce and fails the build if one of those ever appears.

Your customer's documents are the deliberate exception, and only towards you, because that is the service they approved by name. Everything above stays behind the boundary regardless of who is asking.

14. Contact

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

What your customers are told about all of this is in the privacy policy — worth reading before you integrate, because it is the page they will read.

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