// brook

Brook documentation

Brook is the exception handler for your Plaid integration. When the bank link fails, Brook collects the borrower's statement (popup or email), reads it, and returns the data in the exact Plaid schema you already parse.

npm i @multiversal-ventures/brook react + rest assets + transactions pay per verified statement
New here? The 5-minute quickstart gets you real Plaid-shaped data with two curl calls — no SDK, no frontend. Then wire up the React SDK when you want it in-app.

How it works

Brook does exactly one thing: handle the moment Plaid can't connect. It is not a workflow engine and it does not chase borrowers. One call in your catch block opens a collection session:

  • 1. Plaid fails. Your existing Plaid call throws, exactly like today.
  • 2. You call Brook. collect() opens a session and either renders the upload popup (embed) or emails the borrower a portal link.
  • 3. The borrower uploads. Any bank, any format PDF. Brook reads and verifies it.
  • 4. You get Plaid-shaped data. Returned in the exact schema and version your code already parses. Nothing downstream changes.
The schema mapping, verification, and durable wait all run server-side in the Brook backend. The SDK is a thin client: it opens the session with your publishable key, surfaces the upload popup, and hands back an exchangeRef. Your server swaps that ref for the data over REST. The wire is the canonical Plaid 2020-09-14 shape.
Enterprise: that backend can run inside your own cloud (VPC or on-prem) so borrower documents never leave your perimeter. The SDK just points at your Brook base URL — same API, your infrastructure. See pricing →

5 minutes to Plaid-shaped data

No SDK, no frontend, no CORS setup — just two server-side calls and a borrower upload. This is the fastest way to see a real result.

terminalbash
# 1. Get keys (30s): sign up, reveal pk_live_ / sk_live_ once
#    https://veritas.fenero.ai/integrations/brook_plaid_sdk/signup
export BROOK_PK="pk_live_…"
export BROOK_SK="sk_live_…"
export BASE="https://veritas.fenero.ai/integrations/brook_plaid_sdk"

# 2. Open a collection session (publishable key)
curl -sX POST "$BASE/collect" \
  -H "X-Brook-Key: $BROOK_PK" -H "Content-Type: application/json" \
  -d '{"as":"transactions"}'
# -> {"uploadUrl":"…","exchangeRef":"…"}

# 3. Open uploadUrl in a browser, upload a bank statement PDF.

# 4. Exchange for the Plaid wire (secret key, server-side)
export REF="…"   # the exchangeRef from step 2
curl -sX POST "$BASE/exchange" \
  -H "X-Brook-Key: $BROOK_PK" -H "Authorization: Bearer $BROOK_SK" \
  -H "Content-Type: application/json" -d "{\"exchangeRef\":\"$REF\"}"
# -> {"data":{ …Plaid 2020-09-14 wire… }}  (or {"pending":true} — poll again)
That's the whole loop. Point your existing Plaid parser at data. When you want it in-app, add the React SDK below.

React SDK

Install from the public npm registry — no auth or .npmrc needed.

terminalbash
npm i @multiversal-ventures/brook
!Browser CORS: useBrook calls /collect from the browser with your publishable key, so your app's origin must be on your tenant's CORS allowlist (ask us to add it). No allowlist? Use the server flow above — it has no origin restriction.

Two moving parts. In the browser, useBrook opens the session with your publishable key and drives the upload popup; it resolves with an exchangeRef:

VerifyStep.tsxtsx
import { useBrook } from "@multiversal-ventures/brook"

function VerifyStep({ loanId }) {
  const { collect } = useBrook({ publishableKey: "pk_live_…", as: "assets" })

  async function onPlaidFail() {
    // opens the hosted upload popup, resolves when the borrower uploads
    const { exchangeRef } = await collect({ idempotencyKey: loanId })
    // hand the ref to YOUR server — it holds the secret key
    await fetch("/api/brook/exchange", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ exchangeRef }),
    })
  }
}

On your server, exchange the ref for the data with the secret key over REST:

/api/brook/exchange (server)typescript
const r = await fetch(
  "https://veritas.fenero.ai/integrations/brook_plaid_sdk/exchange",
  {
    method: "POST",
    headers: {
      "X-Brook-Key": process.env.BROOK_PK,
      Authorization: `Bearer ${process.env.BROOK_SK}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ exchangeRef }),
  },
)
const { data } = await r.json()   // Plaid 2020-09-14 wire, or { pending: true }
data is the canonical Plaid shape your existing parser expects — no new model. The secret key never touches the browser; only exchangeRef does.

Authentication

Brook uses two key types, mirroring the publishable/secret split you already know from Stripe and Plaid.

KeyWhere it livesWhat it can do
pk_live_…
publishable
Browser. Safe to ship in client JS.Open a collection session and run the upload UI. Domain-scoped. Cannot read statement data.
sk_live_…
secret
Server only. Never in the browser.Open sessions, exchange tokens for data, resume, manage webhooks.

The default, zero-backend path uses a publishable key: the browser opens the session directly. If you want backend control, mint a short-lived sessionToken server-side with your secret key and hand it to the frontend.

!A publishable key can open a session but can never read statement data. Extracted PII is only ever delivered server-side — see Data delivery.

React — the Plaid Link catch

When Plaid Link fails in the browser, call collect() from useBrook in your catch. It opens the hosted upload page as a popup and resolves once the borrower has uploaded. status tracks the lifecycle for your UI.

VerifyStep.tsxtsx
import { useBrook } from "@multiversal-ventures/brook"

function VerifyStep({ loanId }) {
  const { collect, status, error } = useBrook({
    publishableKey: "pk_live_…",
    as: "assets",
  })

  async function connectBank() {
    try {
      await openPlaidLink()
    } catch (E) {
      // opens the upload popup, resolves when the borrower uploads
      const { exchangeRef } = await collect({ idempotencyKey: loanId })
      // hand exchangeRef to your server to fetch the data (see Data delivery)
      await fetch("/api/brook/exchange", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ exchangeRef }),
      })
    }
  }

  return <button onClick={connectBank} disabled={status !== "idle"}>
    {status === "awaiting_borrower" ? "Waiting for upload…" : "Connect your bank"}
  </button>
}

Prefer to send the borrower a link instead of a popup? Pass popup: false and borrower.email — same session, same exchangeRef; the promise still resolves when they finish on another device.

Other frontends

useBrook is a thin wrapper over a framework-agnostic core, createBrook. Both resolve with an exchangeRef — statement data is never delivered to the browser (see Data delivery).

Vanilla JS

Plain DOM, no framework. Works in server-rendered apps, plain HTML, jQuery, htmx — anything.

verify.jsjavascript
import { createBrook } from "@multiversal-ventures/brook"

const brook = createBrook({ publishableKey: "pk_live_…", as: "assets" })

try {
  await openPlaidLink()
} catch (e) {
  const { exchangeRef } = await brook.collect()
  await fetch("/api/brook/exchange", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ exchangeRef }),
  })
}

Hosted page (no SDK)

No SDK at all. Open the session's hosted upload URL — returned as uploadUrl from POST /collect — in a redirect, new tab, or iframe. This is the universal option, including mobile webviews and backend-rendered apps.

hostedtext
# POST /collect returns uploadUrl for the borrower to open
https://veritas.fenero.ai/integrations/brook_plaid_sdk/u/<sessionToken>

# poll POST /exchange (server) or register a webhook for the result
Roadmap: a drop-in Web Component (<brook-upload>) and a native mobile SDK aren't shipped yet. Today, non-React stacks use createBrook or the hosted page above. Ask us if you need one sooner.

Node — the server exchange

Statement data is only ever delivered server-side. Your server holds the secret key and swaps the browser's exchangeRef for the Plaid-shaped wire over plain REST — no SDK needed, works from any language.

exchange.tstypescript
const BASE = "https://veritas.fenero.ai/integrations/brook_plaid_sdk"

export async function exchange(exchangeRef: string) {
  const r = await fetch(`${BASE}/exchange`, {
    method: "POST",
    headers: {
      "X-Brook-Key": process.env.BROOK_PK!,        // pk
      Authorization: `Bearer ${process.env.BROOK_SK}`,   // sk — server only
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ exchangeRef }),
  })
  const body = await r.json()
  return body.pending ? null : body.data   // poll again if pending
}
Brook returns the canonical Plaid 2020-09-14 wire, so the parser you already have for that version consumes it unchanged. See Plaid version.

Choosing the schema

The as option selects which Plaid response shape Brook returns.

asMirrorsUse for
"assets"/asset_report/getAssetReportGetResponseAsset / income verification, statement-based underwriting.
"transactions"/transactions/getTransactionsGetResponseCash-flow underwriting, transaction history.

The returned object is the genuine Plaid type for your version — accounts, balances, transactions, owners, and historical balances are all populated from the uploaded statement.

Plaid version

Brook returns the canonical Plaid 2020-09-14 wire — the dated Plaid-Version shape most integrations already parse. Point your existing Plaid deserializer at data and it consumes a Brook result unchanged.

Because the shape is fixed and stable, there's nothing to configure — no version header to pass, no client to detect.
Roadmap: per-tenant version pinning (targeting a different dated Plaid-Version automatically) is planned. Until then, everything is 2020-09-14. Ask us if you need another version.

Durable wait & resume

Borrowers don't upload instantly. Brook's backend holds the collection open for as long as it takes — minutes or days — on a durable workflow. Two ways to bridge the wait:

  • In-session (SDK): collect() polls the session status and resolves as soon as the borrower finishes — whether that's on the popup or a link opened on another device. Its status field drives your UI.
  • Long tail (server): the exchangeRef is durable. Your server can call POST /exchange (or /resume) any time later, or register a webhook to be pushed the result.
resume (server)typescript
// same ref, any time later — resolves to data once ready, else { pending }
const r = await fetch(`${BASE}/resume`, {
  method: "POST",
  headers: { "X-Brook-Key": PK, Authorization: `Bearer ${SK}`, "Content-Type": "application/json" },
  body: JSON.stringify({ ref: exchangeRef }),
})
const { data, pending } = await r.json()

Data delivery

Extracted statement data is sensitive and is never returned to the browser. There are two server-side delivery channels, used automatically depending on timing:

ChannelWhenHow you get data
Exchange refBrowser (pk) path, fastBrowser receives an exchangeRef; your server POSTs it to /exchange with the secret key.
WebhookDurable / long-tailBrook POSTs the result to your configured webhook URL with an HMAC signature.
exchange (server)typescript
// your frontend posts { exchangeRef } here; you call Brook with the secret key
const { data, provenance } = await (await fetch(`${BASE}/exchange`, {
  method: "POST",
  headers: { "X-Brook-Key": PK, Authorization: `Bearer ${SK}`, "Content-Type": "application/json" },
  body: JSON.stringify({ exchangeRef }),
})).json()
// data is the Plaid-shaped response; PII never touched the client

Webhooks

Register a webhook URL at signup to receive collection results and lifecycle events. Verify the HMAC-SHA256 signature against the raw body before trusting the payload — it's a couple of lines, no SDK needed.

webhook.tstypescript
import { createHmac, timingSafeEqual } from "node:crypto"

app.post("/webhooks/brook", (req, res) => {
  const raw = req.rawBody   // the exact bytes received
  const expected = createHmac("sha256", process.env.BROOK_WEBHOOK_SECRET!)
    .update(raw).digest("hex")
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers["x-brook-signature"])))
    return res.sendStatus(401)

  const event = JSON.parse(raw)
  if (event.type === "collection.completed") use(event.data)   // Plaid wire
  res.sendStatus(200)
})

Event types

EventFires when
collection.completedBorrower uploaded and the statement was verified. Carries data.
collection.failedUnreadable upload or expired session. Carries a Plaid-shaped error.
collection.openedSession created, borrower notified.

Idempotency

Pass a stable idempotencyKey (your loan or borrower id is ideal). Calling collect() again with the same key returns the same session — no duplicate borrower email, no double charge.

retry.tstypescript
// downstream rejected the first result — just call again
await brook.collect({ as: "assets", idempotencyKey: loan.id })
// same key  -> same session, billed once
// new requirement (12mo not 6mo) -> use a new key
You are billed per verified result, not per call. A borrower who never uploads costs nothing, and retries against the same key never double-charge.

Provenance & audit

On the Certified tier ($12/statement), the result carries a Certificate of Provenance under the top-level provenance key — documenting where the data came from and how it was read, defensible enough to put in front of an auditor. It's selected server-side at collection time, not a client flag.

!In progress: today provenance returns an availability stub. Full in-JSON attestation (the shape below — source hash, tamper check, audit URL) is a fast-follow; don't build a hard dependency on the fields yet.
provenancejson
{
  "certificate_id": "cert_3kf9a…",
  "source_hash": "sha256:9b1c…",   // hash of the uploaded PDF
  "tamper_check": "passed",
  "extracted_at": "2026-06-13T18:22:04Z",
  "line_confidence": 0.997,
  "audit_url": "https://trib.multiversal.ventures/audit/cert_3kf9a…"
}

The certificate is exportable for underwriting and compliance. It is not available on the base $5 tier — see pricing.


// brook for salesforce

Brook for Salesforce

Brook for Salesforce is a native managed package. A loan officer points Salesforce at a borrower's bank statements and gets the cashflow back — money in (deposits) and out (withdrawals), per account, with line-item audit and a rendered report — without leaving the record page.

managed package native LWC Named Credential auth zero data retention

The analysis engine is the same one behind the developer SDK. Brook is the native package that drives it: drop one Lightning Web Component on any record page, click Analyze Bank Statements, and the results land on Salesforce records.

Want it for your org? Brook for Salesforce is in private betarequest access and we onboard you personally.

Install & connect

Two things happen once, then every analysis is a single click.

  • 1. Install the managed package. Add Brook to your org from the install button on the connect page (AppExchange listing or a package install URL).
  • 2. Connect your org. One-click Login with Salesforce issues a one-time connect code that wires the org to Brook.
  • 3. Create the Named Credential. Auth uses a Salesforce Named Credential pointed at your Brook base URL — no secrets in code. Nothing runs until it exists.
  • 4. Assign the permission set. Brook_User grants object-level access to the result objects below.
!FLS note: Brook_User grants object-level access. For non-admin users, also grant field-level security on the custom fields (admins see everything by default).

The LWC

Drop brookBankStatements on any record page — Contact, Opportunity, or a custom loan object. It reads recordId and objectApiName, so it works wherever you place it in Lightning App Builder.

  • Click Analyze Bank Statements. Brook gathers the PDFs attached to the record, opens a case, and uploads them.
  • An Apex Queueable polls until the analysis reaches a terminal state, then writes results back onto the record.
  • No statements attached? Send the borrower a secure upload link instead (see Borrower upload links).
data flow
[Any record page] ──LWC──> Apex controller (@AuraEnabled)
                              │  Named Credential (no secrets in code)
                              ▼
            create case → upload PDFs → poll until terminal
                              ▼
   Veritas_Case__c + Bank_Account__c + Bank_Transaction__c + report file

Result objects

When the analysis completes, results land as native Salesforce records, linked record-to-record so you can report and drill down.

ObjectWhat it holds
Veritas_Case__cOne per analysis run. Parent of the accounts; tracks state.
Bank_Account__cOne per account on the statements. Carries the money-in / money-out summary.
Bank_Transaction__cOne per line item — the full audit trail under each account.
Report fileThe rendered analysis, attached to the record as a ContentDocument.
Because these are standard custom objects, the in/out summary, line items, and report are all reportable, filterable, and visible in standard Salesforce list views and related lists.

Borrower upload links

When the borrower's statements aren't already attached, Brook can send them a secure, hosted upload link. The borrower drops their PDFs; results flow back onto the record automatically — same as a direct upload.

  • Upload links and the document viewer are time-limited (7 days) and purged after that under the zero-data-retention policy.
  • The borrower never needs a Salesforce login — the upload page and viewer are hosted by Brook and rendered under the brook domain.
The borrower never needs a Salesforce login. The upload page is hosted by Brook and rendered under the brook domain.

Batch & automation

The LWC analyzes one record at a time. To run Brook across many records — a list view, a nightly cohort, a Flow trigger — call the same Apex the component uses. Every path enqueues one analysis case per record and polls it to a terminal state on a self-rescheduling Queueable, so you stay inside Salesforce governor limits no matter the volume.

Ways to incorporate it

OptionHowBest for
Per-recordThe brookBankStatements LWC on the record page.Manual, one borrower at a time.
List-view bulk actionSelect rows in a list view → a Flow / Apex action enqueues a case per selected record.Underwriter clears a queue of files.
Flow (no code)An invocable action called from a Record-Triggered or Scheduled Flow."When stage = Docs In, analyze."
Apex Batch / QueueableProgrammatic — chunk records, enqueue, poll via Finalizer.Backfills, 1k–100k+ records.
Scheduled ApexNightly job over a report or list view.Hands-off recurring cohorts.

Invocable action — call Brook from Flow

The package ships an invocable so a Flow can analyze records with no code. Pass record ids; each becomes a case.

BrookBatch.clsapex
global class BrookBatch {
  @InvocableMethod(label='Analyze Bank Statements')
  global static List<Id> run(List<Id> recordIds) {
    return BrookService.enqueueCases(recordIds);  // one case per record
  }
}

Bulk enqueue from Apex

Drive it directly — e.g. from a list-view button, a Batch Apex execute, or a Scheduled job. enqueueCases is bulk-safe and idempotent per record.

bulk.apexapex
// every Opportunity with statements attached and not yet analyzed
List<Id> ids = new List<Id>();
for (Opportunity o : [
  SELECT Id FROM Opportunity
  WHERE StageName = 'Docs In' AND Brook_Analyzed__c = false
  LIMIT 200
]) ids.add(o.Id);

BrookService.enqueueCases(ids);
// each polls itself to terminal; results land on Bank_Account__c + Bank_Transaction__c
Idempotent: enqueueing the same record twice resolves to the same case — no duplicate borrower email, no double analysis. Use a stable field (the record id) as the key, the same way the SDK's idempotencyKey works.
Limits: each case polls on its own Queueable chain, so a batch of N records uses N independent poll loops, not one long transaction. For very large backfills, chunk the enqueue (e.g. 200 ids per call) from Batch Apex.
!Push instead of poll: a webhook path (Brook → Salesforce Platform Event) that removes polling entirely is on the roadmap. Ask us if high-volume push matters to you.

Reference — useBrook() / collect()

useBrook(options) returns { collect, status, isCollecting, error, result, reset }. createBrook(options) returns { collect }. Options:

OptionTypeNotes
publishableKey requiredstringpk_live_…. Safe in the browser.
as"assets" | "transactions"Which Plaid shape /exchange returns. Default "transactions".
baseUrlstringOverride the Brook API base (self-hosted / VPC).
pollIntervalMsnumberStatus poll cadence. Default 3000.

collect(args?) arguments

ArgTypeNotes
as"assets" | "transactions"Overrides the hook-level as.
borrower{ email?, name? }For the link path (popup: false).
idempotencyKeystringStable key (loan/borrower id) to dedupe & resume.
popupbooleanOpen the hosted upload page as a popup. Default true.
onStatus(status) => voidPer-transition callback.

Resolves with

FieldTypeNotes
exchangeRefstringSend to your server; it POSTs /exchange (sk) for the Plaid data.
sessionTokenstringThe session id (also embedded in uploadUrl).
uploadUrlstringHosted upload page URL (for the link/hosted path).

status: "idle" | "opening" | "awaiting_borrower" | "processing" | "done" | "error".

Reference — server REST (/exchange, /resume)

  • POST /exchange — body { exchangeRef }, headers X-Brook-Key (pk) + Authorization: Bearer (sk). Returns { data, provenance } or { pending: true }.
  • POST /resume — body { ref }, same auth. Same contract as /exchange; use it to pick a pending session back up later.

Errors

Brook errors mirror Plaid's error shape, so the handling you already have in the same catch works unchanged.

errorjson
{
  "error_type": "BROOK_ERROR",
  "error_code": "UPLOAD_UNREADABLE",
  "display_message": "We couldn't read that statement. Please re-upload.",
  "request_id": "req_8a2f…"
}
error_codeMeaning
UPLOAD_UNREADABLEThe PDF couldn't be parsed. Brook asks the borrower to re-upload in-session before failing.
SESSION_EXPIREDThe borrower never uploaded within the session window.
INVALID_KEYKey missing, revoked, or wrong type for the operation.

Response shape

For as: "assets", data is a Plaid AssetReportGetResponse for your resolved version. Abbreviated:

data — as: "assets"json
{
  "report": {
    "asset_report_id": "…",
    "items": [{
      "institution_name": "Chase",
      "accounts": [{
        "account_id": "…",
        "balances": { "current": 4218.55, "iso_currency_code": "USD" },
        "transactions": [ /* … */ ],
        "historical_balances": [ /* … */ ],
        "owners": [ /* … */ ]
      }]
    }]
  },
  "request_id": "req_…"
}
This is the genuine Plaid type. If you already deserialize Plaid asset reports, the same code path consumes a Brook result with no changes.

On this page

Introduction How it works 5-min quickstart React SDK Authentication React Node Version matching Durable wait Webhooks Errors Salesforce