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.
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.
exchangeRef. Your server swaps that ref for the data over REST. The wire is the canonical Plaid 2020-09-14 shape.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.
# 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)
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.
npm i @multiversal-ventures/brook
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:
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:
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.
| Key | Where it lives | What 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.
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.
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.
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.
# 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
<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.
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
}
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.
as | Mirrors | Use for |
|---|---|---|
"assets" | /asset_report/get → AssetReportGetResponse | Asset / income verification, statement-based underwriting. |
"transactions" | /transactions/get → TransactionsGetResponse | Cash-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.
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. Itsstatusfield drives your UI. - Long tail (server): the
exchangeRefis durable. Your server can callPOST /exchange(or/resume) any time later, or register a webhook to be pushed the result.
// 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:
| Channel | When | How you get data |
|---|---|---|
| Exchange ref | Browser (pk) path, fast | Browser receives an exchangeRef; your server POSTs it to /exchange with the secret key. |
| Webhook | Durable / long-tail | Brook POSTs the result to your configured webhook URL with an HMAC signature. |
// 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.
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
| Event | Fires when |
|---|---|
collection.completed | Borrower uploaded and the statement was verified. Carries data. |
collection.failed | Unreadable upload or expired session. Carries a Plaid-shaped error. |
collection.opened | Session 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.
// 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
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.
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.{
"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 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.
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.
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_Usergrants object-level access to the result objects below.
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
Queueablepolls 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).
[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.
| Object | What it holds |
|---|---|
Veritas_Case__c | One per analysis run. Parent of the accounts; tracks state. |
Bank_Account__c | One per account on the statements. Carries the money-in / money-out summary. |
Bank_Transaction__c | One per line item — the full audit trail under each account. |
| Report file | The rendered analysis, attached to the record as a ContentDocument. |
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.
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
| Option | How | Best for |
|---|---|---|
| Per-record | The brookBankStatements LWC on the record page. | Manual, one borrower at a time. |
| List-view bulk action | Select 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 / Queueable | Programmatic — chunk records, enqueue, poll via Finalizer. | Backfills, 1k–100k+ records. |
| Scheduled Apex | Nightly 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.
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.
// 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
idempotencyKey works.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.Reference — useBrook() / collect()
useBrook(options) returns { collect, status, isCollecting, error, result, reset }. createBrook(options) returns { collect }. Options:
| Option | Type | Notes |
|---|---|---|
publishableKey required | string | pk_live_…. Safe in the browser. |
as | "assets" | "transactions" | Which Plaid shape /exchange returns. Default "transactions". |
baseUrl | string | Override the Brook API base (self-hosted / VPC). |
pollIntervalMs | number | Status poll cadence. Default 3000. |
collect(args?) arguments
| Arg | Type | Notes |
|---|---|---|
as | "assets" | "transactions" | Overrides the hook-level as. |
borrower | { email?, name? } | For the link path (popup: false). |
idempotencyKey | string | Stable key (loan/borrower id) to dedupe & resume. |
popup | boolean | Open the hosted upload page as a popup. Default true. |
onStatus | (status) => void | Per-transition callback. |
Resolves with
| Field | Type | Notes |
|---|---|---|
exchangeRef | string | Send to your server; it POSTs /exchange (sk) for the Plaid data. |
sessionToken | string | The session id (also embedded in uploadUrl). |
uploadUrl | string | Hosted 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 }, headersX-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.
{
"error_type": "BROOK_ERROR",
"error_code": "UPLOAD_UNREADABLE",
"display_message": "We couldn't read that statement. Please re-upload.",
"request_id": "req_8a2f…"
}
| error_code | Meaning |
|---|---|
UPLOAD_UNREADABLE | The PDF couldn't be parsed. Brook asks the borrower to re-upload in-session before failing. |
SESSION_EXPIRED | The borrower never uploaded within the session window. |
INVALID_KEY | Key missing, revoked, or wrong type for the operation. |
Response shape
For as: "assets", data is a Plaid AssetReportGetResponse for your resolved version. Abbreviated:
{
"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_…"
}