// 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 brook node + react assets + transactions pay per verified statement
New here? Jump to the Quickstart for a working integration in about five minutes, then read Plaid version matching to understand the 1:1 schema guarantee.

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 detects your Plaid version, surfaces the upload, and returns the typed result. That's why every language SDK produces identical output.

Quickstart

Install the SDK and drop a single call into the catch block you already have around Plaid.

terminalbash
npm i brook

Server-side (Node), the most common case — your backend already calls Plaid:

verify.tstypescript
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
}
That's the whole integration. 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.

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 useBrook in your catch. With embed: true the upload popup renders right on your page; the borrower never leaves.

VerifyStep.tsxtsx
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.

collect.tstypescript
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
})
No 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.

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 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-Version header (e.g. 2020-09-14) that decides the JSON shape.
  • Client version — the plaid npm package that decides your TypeScript types.

Brook resolves the API version server-side using the best available signal, in order:

resolution 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.

Pass 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 or resume(token).
resume.tstypescript
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:

ChannelWhenHow you get data
Exchange tokenBrowser (pk) path, fastBrowser receives an exchangeToken; your server calls brook.exchange(token) with the secret key.
WebhookDurable / long-tailBrook POSTs the result to your configured webhook URL with a signature.
exchange.tstypescript
// 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.

webhook.tstypescript
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

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

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.

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.


Reference — collect()

Opens (or resumes, via idempotency) a collection session and waits for the result.

OptionTypeNotes
as required"assets" | "transactions"Which Plaid response shape to return.
borrower{ email, name? }Required for the email path. Omit when using embed.
embedbooleanReact only. Render the upload popup inline.
plaidClientPlaidApiNode. Best version signal.
plaidVersionstringExplicit API-version override, e.g. "2020-09-14".
idempotencyKeystringStable key to dedupe/resume. Strongly recommended.
certifybooleanAttach a Certificate of Provenance (Certified tier+).

Returns

FieldTypeNotes
dataPlaid responsePresent when resolved. Exact Plaid shape for your version.
pendingbooleanTrue when the borrower hasn't uploaded yet.
tokenstringResume token, present when pending.
exchangeTokenstringBrowser (pk) path. Exchange server-side for data.
provenanceobjectPresent 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-shaped data. Requires the secret key.

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.
VERSION_UNRESOLVEDNo Plaid version could be detected and none was supplied.
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 Quickstart Authentication React Node Version matching Durable wait Webhooks Errors