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.
Quickstart
Install the SDK and drop a single call into the catch block you already have around Plaid.
npm i brook
Server-side (Node), the most common case — your backend already calls Plaid:
import { Brook } from "brook/node"
import { plaid } from "./plaid"
const brook = new Brook({ apiKey: process.env.BROOK_SECRET })
try {
return await plaid.assetReportGet({ asset_report_token })
} catch (E) {
// Plaid couldn't connect — fall through to Brook
const result = await brook.collect({
as: "assets",
plaidClient: plaid, // version auto-detected from your client
borrower: { email: loan.borrowerEmail },
idempotencyKey: loan.id,
})
if (result.pending) {
// borrower hasn't uploaded yet — you'll get a webhook,
// or call brook.resume(result.token) later
return { status: "awaiting_borrower" }
}
return result.data // identical Plaid AssetReportGetResponse
}
result.data is byte-for-byte the shape your existing parser expects — no new model, no mapping layer.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 useBrook in your catch. With embed: true the upload popup renders right on your page; the borrower never leaves.
import { useBrook } from "brook/react"
function VerifyStep({ loanId }) {
const brook = useBrook({
publishableKey: "pk_live_…",
as: "assets",
idempotencyKey: loanId,
})
async function connectBank() {
try {
await openPlaidLink()
} catch (E) {
// renders the embedded popup, resolves when the borrower uploads
const { exchangeToken } = await brook.collect({ embed: true })
// hand exchangeToken to your server to fetch the data (see Data delivery)
await fetch("/api/brook/exchange", { method: "POST", body: exchangeToken })
}
}
return <button onClick={connectBank}>Connect your bank</button>
}
The popup is styled with your theme tokens and works with keyboard and screen readers out of the box. Don't want a popup? Omit embed and pass borrower.email to send a link instead — same session, same result.
Node — the server catch
The server path holds your secret key and is the most accurate place to detect your Plaid version. Pass your existing plaidClient and Brook reads the configured Plaid-Version and package version off it.
const result = await brook.collect({
as: "transactions", // or "assets"
plaidClient: plaid, // preferred version signal
borrower: { email, name },
idempotencyKey: loan.id,
certify: true, // attach a Certificate of Provenance
})
plaidClient handy? Brook falls back to require.resolve("plaid"), then an explicit plaidVersion option, then Plaid's current default. See Plaid version matching.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 matching
The 1:1 promise depends on returning the shape your Plaid expects. There are two version axes, and Brook handles both:
- API version — the dated
Plaid-Versionheader (e.g.2020-09-14) that decides the JSON shape. - Client version — the
plaidnpm package that decides your TypeScript types.
Brook resolves the API version server-side using the best available signal, in order:
plaidClient // reads its Plaid-Version header + pkg version (best)
?? require.resolve("plaid") // node resolution, handles hoisting
?? plaidVersion // explicit override
?? PLAID_DEFAULT // Plaid's current default
The resolved version is stamped on the session, so the React side inherits it automatically — the browser never needs to detect anything. For types, Brook re-exports from your installed plaid peer dependency, so compile-time matches runtime.
plaidClient whenever you can. It's the only signal that captures a custom Plaid-Version header you've set.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. The SDK surfaces that with an await-with-resume model:
- Fast path: if the borrower uploads while your call is still alive,
await collect()resolves directly with the data. - Long tail: if it takes longer than the request can stay open,
collect()returns{ pending: true, token }. You finish later via a webhook orresume(token).
const result = await brook.collect({ as: "assets", borrower })
if (result.pending) {
// store result.token, return control to your app
// when the webhook fires (or on a later request):
const done = await brook.resume(result.token)
use(done.data)
}
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 token | Browser (pk) path, fast | Browser receives an exchangeToken; your server calls brook.exchange(token) with the secret key. |
| Webhook | Durable / long-tail | Brook POSTs the result to your configured webhook URL with a signature. |
// server endpoint that your frontend posts the exchangeToken to
const { data, provenance } = await brook.exchange(exchangeToken)
// data is the Plaid-shaped response; PII never touched the client
Webhooks
Register a webhook URL on your key to receive collection results and lifecycle events. Always verify the signature before trusting the payload.
app.post("/webhooks/brook", (req, res) => {
const event = brook.webhooks.verify(
req.body,
req.headers["brook-signature"],
)
switch (event.type) {
case "collection.completed":
event.data // Plaid-shaped result
break
case "collection.failed":
event.error // Plaid-shaped error (see Errors)
break
}
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
Set certify: true (Certified tier and up) to attach a Certificate of Provenance to the result. It documents where the data came from and how it was read — defensible enough to put in front of an auditor.
{
"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.
Reference — collect()
Opens (or resumes, via idempotency) a collection session and waits for the result.
| Option | Type | Notes |
|---|---|---|
as required | "assets" | "transactions" | Which Plaid response shape to return. |
borrower | { email, name? } | Required for the email path. Omit when using embed. |
embed | boolean | React only. Render the upload popup inline. |
plaidClient | PlaidApi | Node. Best version signal. |
plaidVersion | string | Explicit API-version override, e.g. "2020-09-14". |
idempotencyKey | string | Stable key to dedupe/resume. Strongly recommended. |
certify | boolean | Attach a Certificate of Provenance (Certified tier+). |
Returns
| Field | Type | Notes |
|---|---|---|
data | Plaid response | Present when resolved. Exact Plaid shape for your version. |
pending | boolean | True when the borrower hasn't uploaded yet. |
token | string | Resume token, present when pending. |
exchangeToken | string | Browser (pk) path. Exchange server-side for data. |
provenance | object | Present when certify: true. |
Reference — resume() & exchange()
brook.resume(token)— fetch the result for a pending session. Resolves when ready; safe to call repeatedly.brook.exchange(exchangeToken)— server-side exchange of a browser token for the Plaid-shapeddata. Requires the secret key.
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. |
VERSION_UNRESOLVED | No Plaid version could be detected and none was supplied. |
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_…"
}