Skip to content

Public information

Public API reference

The hosted reference for connecting external applications to BRON's litigation record through scoped, revocable credentials and the same access gates used in the product.

Last updated August 29, 2026.

BRON LAW Public API

Read/write access to a firm's litigation matters for external applications, plus an MCP server so an AI agent can reach the same data through the same gates.

  • Base URLhttps://<your-host>/api/v1
  • Machine-readable descriptionGET /api/v1/openapi.json (no auth; shapes only, never data)
  • MCP endpointPOST /mcp
  • Manage keys and webhooks — Settings → API & Webhooks in the app

Every payload carries a schema_version (currently 1.0), a generated_at timestamp, and the case_id it belongs to. Pin the schema_version in your consumer — see §6 Versioning for what that number does and does not promise.


1. Authentication & trust model

Every request presents a brk_ key: brk_ followed by 48 hex characters. Send it either way — they are equivalent:

curl -H "X-API-Key: brk_..."            https://<host>/api/v1/matters
curl -H "Authorization: Bearer brk_..." https://<host>/api/v1/matters

Keys are created in Settings → API & Webhooks. The plaintext is shown once, at creation — we store only a SHA-256 hash and a short prefix, so a lost key cannot be recovered, only replaced. Revoking a key takes effect on its very next request.

A brk_ key is a first-party firm credential — treat it like a password

A brk_ key is not a client-facing or public token. It is a first-party firm credential: it acts as its owner and carries the owner's own, full access to the firm's litigation record. By design that includes attorney-grade, privileged, and work-product material — most visibly the knowledge graph, which on kg:read returns the full attorney view (privileged and work-product-derived nodes, and their verbatim provenance quotes) by default. The narrowed, client-producible view is opt-in per request via client_safe=1; it is never the default — unless the key was minted in the client_safe class, which forces it (see Sensitivity classes).

Because a key sees what its owner sees, the security control is the firm deciding who holds a key. Consequently:

  • Issue keys only to trusted first-party integrations you would trust with the owner's own logged-in access. Never hand a brk_ key to a client, a third party, or an untrusted tool, and never embed one in client-side or public code.
  • Treat the plaintext like a password. Store it in a secrets manager, rotate it, and revoke it (Settings → API & Webhooks) the moment it may be exposed — revocation is effective on the next request.
  • Narrow at mint time where you can. Grant the least scope that does the job, and set a matter allow-list to bound which matters the key can reach (both described below).

Two firm-wide controls still apply to every key regardless of the above: the key owner's ethical walls (ABA 1.10) subtract screened matters server-side, and the key's optional matter allow-list can only ever narrow the reachable set. See What a key can reach.

Sensitivity classes

Every key carries a sensitivity class, chosen at creation, that bounds what the key can be shown regardless of what a request asks for:

ClassWhat it seesWho can mint it
attorney (default)Everything described above, including the full attorney-grade knowledge graph with privileged provenanceA firm administrator
client_safeThe same matters, but the knowledge-graph lanes are forced to the privilege-gated client view — client_safe=1 is applied for you and cannot be turned offAny firm staff member

attorney is the default, so existing keys are unchanged and an integration that does not ask for a class gets the historical behaviour. A client_safe key still receives the honest flag client_safe_forced_by_key: true on graph payloads, so a consumer can tell that its credential narrowed the answer rather than its request.

Choose client_safe for anything you would not hand the owner's own logged-in session — a dashboard, a demo, an agent you are still evaluating. It is a floor at the credential, which is the one place a per-request flag can never be forgotten.

Create with sensitivity on POST /api-keys:

{ "name": "Acme graph mirror", "scopes": ["kg:read"], "sensitivity": "client_safe" }

What a key can reach

A key never carries its own permissions. It acts as its owner, and every request resolves the reachable matters server-side:

the OWNER's own matters
  ∪      every matter belonging to the owner's FIRM   (the firm partition)
  minus  matters the owner is screened from by an ethical wall (ABA 1.10)
  ∩      the key's optional matter allow-list

Two consequences worth being precise about, because they cut in opposite directions:

  • Wider than the owner's personal matters. The firm partition means a key reaches the firm's unscreened matters, not only the ones its owner created. Use the matter allow-list when you want a key bound to a specific engagement.
  • Narrower than the owner's in-app view. Matters shared with the owner individually (the shared_with list, which is keyed by email address) are not reachable on this lane: a key resolves an owner id, never an email, so that branch never matches. If an integration needs such a matter, add it through firm membership rather than an individual share.

The allow-list can only ever narrow that set. A case_id in your request is checked against the result — it is a filter, never a grant. An id you cannot reach returns 404, identical to an id that does not exist, so the API cannot be used to probe for matters.

Two things are never served on an API key, at any scope: documents classified privileged or work_product, and (in the client-safe graph lane) nodes derived solely from them.

Rate limits

The budget this API guarantees you is per key, split by cost:

LaneEndpointsLimit
ReadEverything below except the two lanes named next120 requests / minute
ExpensivePOST /matters/:id/retrieve (embedding + hybrid search + rerank) and every drafting:check endpoint (a model / citation-service round trip)20 requests / minute

Budgets refill continuously and burst up to the full limit. Over the limit you get 429 with a Retry-After header in seconds; the MCP surface applies the same two budgets and returns the same message as a tool error. Both lanes are counted independently for the same key, and no key's traffic consumes another key's budget — but the backstops below are shared, so that is not the same as saying nobody else can throttle you.

The stated limit is a floor, not a ceiling. The limiter is in-process and the server runs several worker processes behind one port, so a client spread across workers can observe up to several times the number above. Treat these as the bound you can rely on being enforced, and do not design a consumer that depends on getting more.

Per key is not the only limit. Two per-IP backstops sit in front of the key budget, so a 429 can arrive with your key's own budget completely untouched:

BackstopApplies toDefault
Server-wideEvery request, including POST /mcp and the unauthenticated GET /openapi.json1000 requests / 15 min
DraftingPOST /drafting/verify-citations, /drafting/table-of-authorities, /drafting/pre-file-check — the three that spend on a model. /drafting/link-authorities is deterministic and exempt40 requests / 15 min

These are deployment-tunable and, being per IP, are shared: several keys behind one NAT draw on one budget, and a noisy neighbour can spend it. All three limiters set Retry-After, so the handling rule stays simple — honour Retry-After, and never read a 429 as proof that your own key's budget is spent.

Reads are audited

Graph exports (/matters/:id/knowledge-graph, /firm-graph), retrieval queries and document dereferences each write a row to the firm's tamper-evident audit log — actor, key id, matter, lane, and result size. Every authenticated request is also recorded in a per-key usage ledger. This is visible to the firm, not to the API consumer.

Scopes

ScopeGrants
readMatter records, deadlines, and every granular read scope below
writeEverything — full superset
kg:readKnowledge graph + firm graph, including the JSON-LD / GraphML exports
retrieval:queryHybrid RAG retrieval
docs:readProvenance dereference (GET /documents/:id)
drafting:checkThe four stateless drafting checks (/drafting/*) the editor add-ins use
tasks:readThe proactive tranche: the triaged agenda, the Matter Mind, the daily brief

The coarse scopes remain supersets: write > read > {kg:read, retrieval:query, docs:read, drafting:check, tasks:read}. A key minted before the granular scopes existed keeps working unchanged. Grant the narrowest scope that does the job — a key that only needs the graph should carry kg:read alone.

drafting:check is classed as a read scope even though its endpoints are POST: they take document text you already hold, return an analysis, and neither read anything out of the firm nor write anything into it.

Errors

StatusMeaning
400Malformed request (missing content, query over 2000 chars, unsupported format)
401Missing, invalid, or revoked key
403The key lacks the scope this endpoint requires
404Not found or not reachable by this key — deliberately indistinguishable
413Document text over 500,000 characters (drafting checks)
429Rate limited — either this key's per-minute lane budget or a per-IP backstop; honour Retry-After
502An upstream check (citation verification, ToA, pre-file) could not complete — not a pass
503The knowledge graph could not be read — not an empty graph; retry. Also: a document dereference whose release could not be bound (dereference_binding_unavailable) — not a "no bytes" answer; retry

Error bodies are {"detail": "..."}. The 502 and 503 cases matter: this API never converts a failed check into a clean verdict or a failed read into an empty result.


2. Endpoints

Matters

GET /matters — scope read

Every matter this key can reach.

{ "matters": [ { "id": "…", "name": "Acme v. Northbeam", "case_number": "1:24-cv-01234",
                 "status": "active", "phase": "discovery", "case_type": "commercial",
                 "date_filed": "2024-03-11", "date_closed": null } ] }

GET /matters/:id — scope read

One matter, same shape as an element above.

GET /matters/:id/deadlines — scope read

{ "deadlines": [ { "id": "…", "title": "Expert disclosures", "due_date": "2026-09-14",
                   "deadline_type": "court_rule", "created_at": "2026-07-01T14:02:11Z" } ] }

Ordered by due_date ascending.

The proactive tranche — scope tasks:read

Full detail, including every field and the refusal rules, is in docs/PROACTIVE_API.md. In short:

GET /matters/:id/tasks — scope tasks:read

The matter's triaged agenda — the same engine behind the firm's own desk, not the raw task table. Internal plumbing and work-surface findings are excluded from the attention lanes, routine volume is rolled up, and a task a human snoozed stays snoozed. Returns {lead, today[], this_week[], lanes{on_the_clock, judgment, housekeeping, quiet}, findings[], counts{}, open_task_count, truncated}. A read fault is 503, never a 200 with an empty agenda.

GET /matters/:id/mind — scope tasks:read

The Matter Mind: the standing brief for the matter plus its ranked agenda and lead surface. {available, brief_md, agenda[], lead_surface, author, authored_by_model, computed_at}. When a model polished the prose, authored_by_model is true — treat it as summary, not record. No brief yet returns available: false, which means the absence of a brief, not an empty matter.

GET /brief — scope tasks:read

The key owner's most recent daily brief, verbatim. {available, brief_md, posted_at}.

All three refuse a client_safe-class key with 403 rather than narrowing it: what a lawyer should do next is attorney work product and has no safe subset. /brief additionally refuses a key carrying a matter allow-list — it is composed across the whole desk, and the prose cannot be narrowed after it is written.

(These three are not yet in openapi.json; see docs/PROACTIVE_API.md.)

POST /matters/:id/notes — scope write

{ "title": "Sync from Acme CRM", "content": "Client confirmed the March invoice dispute." }

content is required; title defaults to "API note". The note is recorded as privileged work product and attributed to the key owner. Returns 201 with {id, title, created_at}.

Knowledge graph

GET /matters/:id/knowledge-graph — scope kg:read

Query paramValuesEffect
client_safe1Narrow to the producible client view — apply the client-portal privilege gate (fail-closed). Omitted / not 1 returns the full attorney view (see below).
formatjson (default), jsonld, graphmlExport format

Default is the full attorney-grade graph. Without client_safe=1 this returns privileged and work-product-derived nodes and their verbatim provenance quotes — the same graph the key owner sees in-app. That is intentional: a brk_ key is a first-party firm credential (see §1 trust model). Pass client_safe=1 for the narrowed, client-producible view.

Unless the key is client_safe-class, in which case the narrowed view is applied for you and the response carries client_safe: true, client_safe_forced_by_key: true. The client_safe query parameter can only ever narrow — it cannot widen a key past its class.

A read failure returns 503, never 200 with an empty graph: downstream, an empty export is indistinguishable from a matter that genuinely has no graph, and that difference is worth a retry.

{ "schema_version": "1.0", "generated_at": "2026-07-22T10:14:02Z", "case_id": "…",
  "client_safe": false,
  "entities": [ { "id": "…", "name": "Northbeam Capital", "entityType": "organization",
                  "confidence": 0.91, "attributes": {},
                  "provenance": [ { "sourceType": "evidence_document", "sourceId": "…" } ] } ],
  "relationships": [ { "id": "…", "source": "Northbeam Capital", "target": "Master Services Agreement",
                       "relationship": "party_to", "confidence": 0.88 } ],
  "entity_count": 326, "relationship_count": 213 }

Each entity carries provenance — the sources it was extracted from. Dereference a provenance[].sourceId through GET /documents/:id to get back to the underlying record.

format=jsonld returns application/ld+json with stable urn:bron:kg:entity:<id> / urn:bron:kg:rel:<id> identifiers. format=graphml returns application/graphml+xml for Gephi, Cytoscape, yEd, or NetworkX. Both are keyed on the underlying row ids, so identity survives re-pulls.

GET /firm-graph — scope kg:read, firm staff only

The cross-matter firm graph. The firm is resolved server-side from the key owner; that owner's ethical-wall screens are subtracted and the call fails closed (empty graph) if wall evaluation errors. Returns {schema_version, generated_at, case_id: null, nodes, edges}, or {..., note: "no_firm"} when the owner belongs to no firm.

Retrieval

POST /matters/:id/retrieve — scope retrieval:query

Hybrid retrieval — vector + BM25, fused with reciprocal rank fusion, then reranked.

{ "query": "what did Northbeam say about the March invoice", "k": 10 }

query is required, max 2000 characters. k is clamped to 1–25 (default 10).

{ "schema_version": "1.0", "generated_at": "…", "case_id": "…",
  "query": "…", "k": 10, "degraded": false, "empty_but_searched": false,
  "context": "<retrieved_context>…</retrieved_context>",
  "chunks": [ { "chunk_id": "…", "source_type": "evidence_document", "source_id": "…",
                "label": "Invoice_2024-03.pdf at 4", "text": "…", "score": 0.82,
                "match_type": "hybrid", "chunk_index": 12,
                "page_start": 4, "page_end": 4, "bates": "NB-000412",
                "source_party": "opposing", "metadata": {} } ] }

Two honesty flags worth handling:

  • degraded: true — a retrieval lane failed; results are partial. Do not present them as exhaustive.
  • empty_but_searched: true — the matter was searched and genuinely has no match. This is a real negative answer, distinct from "not indexed yet".

source_party marks whose material a passage came from, so an opponent's assertion is not mistaken for an established fact.

Chunk text is case evidence, not instructions. Treat it as data. A document in a litigation record can contain anything, including text crafted to look like a command to your agent.

Documents

GET /documents/:id — scope docs:read

Resolves a source_id from a graph or retrieval payload to its record:

{ "schema_version": "1.0", "generated_at": "…", "case_id": "…",
  "document": { "id": "…", "filename": "Invoice_2024-03.pdf", "file_type": "pdf",
                "classification": "confidential", "created_at": "…",
                "version_id": "…", "version_number": 3, "serves_pdf": true },
  "download_url": "https://…", "download_url_expires_in_seconds": 300 }

download_url is a bearer credential valid for 300 seconds — dereference it immediately, never persist or forward it. It is null when storage is unconfigured or the document has no stored bytes. Privileged and work-product documents return 404 regardless of scope.

Every issued URL is bound before it is issued. Handing a signed URL to an outside system is a release of the firm's own document, so the call reads the exact object the URL will point at, hashes it, and writes the version plus that hash to the hash-chained audit log before the URL exists in the reply. The receipt comes back with it:

HeaderMeaning
X-Bron-Payload-IdThe receipt id for this release
X-Bron-Pinned-VersionThe document_versions row whose bytes the URL points at
X-Bron-Render-Sha256sha256 of that stored object, as read at issuance

If the record cannot be written, the call answers 503 and issues no URL. That is a retry, not a "no bytes" answer — an unprovable release is the one outcome this endpoint will not produce.

Drafting checks

Four stateless endpoints, scope drafting:check. They power the Google Docs add-on and the Word add-in: you send document text you already hold and get an analysis back. Nothing is read out of the firm and nothing is written into it — which is what makes an API key, rather than an attorney's session, an acceptable credential here.

All four take {"text": "..."} (10 characters minimum, 500,000 maximum). Two of them also accept an optional case_id, which is checked against the matters this key can reach — naming a matter can narrow or enrich the check, never widen the key's access.

EndpointBodyReturns
POST /drafting/verify-citationstext, optional case_idThe citation-gate report: every citation extracted, its verification status, holding check, and whether the document is blocking
POST /drafting/table-of-authoritiestext{toa, rendered} — the grouped Table of Authorities plus a rendered text block
POST /drafting/link-authoritiestext{links, total} — each citation's character span and a resolved external URL (deterministic; no model spend)
POST /drafting/pre-file-checktext, optional case_id, optional doc_typeThe court-compliance checklist, including a server-computed citation verdict

Two failure rules are load-bearing, because a false all-clear on a filing is the one outcome this API never ships:

  • A verifier or checklist failure returns 502. It is never reported as a pass.
  • In pre-file-check, a citation verdict that could not be computed renders as a block ("did not complete"), not as a clear.

The same libraries back the in-app routes, so an add-in can never get a different verdict than the app on identical text.


3. Webhooks

Configure subscriptions in Settings → API & Webhooks. A subscription has a target URL, a list of event types, and a secret (whsec_…, shown once at creation).

EventFires when
matter.resolvedA matter is marked resolved (settlement, verdict, dismissal)
hearing.debriefedA hearing debrief is recorded
document.ingestedA document finishes ingest and is retrievable
kg.updatedA knowledge-graph rebuild stored a new graph
webhook.testYou pressed Send test

Delivery, retries, and dead letters

An event is queued durably the moment it fires and delivered by a background drain, so it is not lost if your endpoint is redeploying or briefly unreachable. Each attempt has an 8-second timeout.

  • A failed attempt is retried with exponential backoff (≈30s, doubling, capped at 1h) up to 10 attempts, after which the delivery is dead-lettered — it stops retrying and is recorded with its last error. Inspect and re-send from Settings → API & Webhooks.
  • Retryable: 5xx, 408, 429, timeouts, connection errors. Not retried: other 4xx (a 400 or 410 will not become acceptable if we send the identical bytes again), and a subscription that has been deleted or deactivated.
  • A successful delivery resets the failure count.
  • Delivery never blocks the underlying action.

Targets are SSRF-guarded — internal, loopback, link-local, and private addresses are rejected at creation and re-validated on every attempt, with the socket pinned to the vetted IP and every redirect hop re-checked.

Request shape

POST <your target URL>
Content-Type: application/json
X-BRON-Event: kg.updated
X-BRON-Signature: sha256=<hex>
X-BRON-Delivery-Id: 9f2c…            # stable across retries — dedupe on this
X-BRON-Attempt: 2                     # 1 on the first try

Be idempotent, and here is what to key on. Because delivery is at-least-once, the same delivery can arrive more than once — a retry after your handler succeeded but your 200 never reached us, or a replay you triggered from Settings. Every attempt at a given delivery carries the same X-BRON-Delivery-Id and byte-identical body (so the signature verifies identically); record the id and ignore one you have already processed. X-BRON-Attempt is informational only — never treat attempt 1 as "definitely new".

{ "event": "kg.updated",
  "sent_at": "2026-07-22T10:14:02Z",
  "data": { "event": "kg.updated", "case_id": "…", "schema_version": "1.0",
            "occurred_at": "2026-07-22T10:14:01Z" } }

document.ingested adds data.document_id.

Graph-sync events carry pointers, not content — ids only, no document text, no entity names, no PII. A webhook target is an unauthenticated-at-rest URL, so the event tells you what changed and you come back through the authenticated API to read it.

Verifying the signature

HMAC-SHA256 over the raw request body bytes, keyed with the subscription secret. Compare in constant time, and verify before parsing.

const crypto = require("crypto");

function verifyBronWebhook(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = String(signatureHeader || "").replace(/^sha256=/, "");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body — re-serializing req.body changes the bytes and breaks the HMAC.
app.post("/hooks/bron", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyBronWebhook(req.body, req.get("X-BRON-Signature"), process.env.BRON_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // ... enqueue and return quickly; do the pull work asynchronously
  res.status(204).end();
});
import hmac, hashlib

def verify_bron_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    received = (signature_header or "").removeprefix("sha256=")
    return hmac.compare_digest(expected, received)

Reject anything that fails verification. Handlers should be idempotent — a delivery can repeat.


4. MCP server

An MCP server is mounted at POST /mcp (streamable HTTP, JSON-RPC 2.0), so an AI agent can query matters directly. It is a thin wrapper over the same functions the REST routes call, with the same scope checks, the same ethical-wall subtraction, and the same owner-scoping — it is not a side door.

It is stateless: every request re-presents its key and is re-authorized from scratch, so a revoked key stops working immediately.

Connecting

{
  "mcpServers": {
    "bron-law": {
      "type": "http",
      "url": "https://<your-host>/mcp",
      "headers": { "Authorization": "Bearer brk_..." }
    }
  }
}

Tools

ToolScopeWhat it does
list_mattersreadLists matters reachable by the key after owner scoping, ethical-wall screens, and the key's matter allow-list.
get_knowledge_graphkg:readReturns one matter's provenance-backed entities and relationships, with optional client_safe privilege filtering and explicit truncation for large graphs.
retrieveretrieval:querySearches one matter's reviewed, nonprivileged record with hybrid RAG and returns matching passages, citation labels, and pinpoint locators.
list_deadlinesreadReads one matter's calendared deadlines and recorded basis, marking dates without a basis ungrounded, and never creates a deadline.
get_tasksreadReturns the attorney-only triaged agenda derived from case_tasks, not a raw task dump, and refuses client-safe keys.
get_matter_briefreadReturns the attorney-only cached Matter Mind narrative and task-backed agenda, labeling model-polished prose ungrounded, and refuses client-safe keys.
propose_actionwriteExecutes nothing: it writes a suggest-capped row to pending_approvals, and the proposed action runs only if a human taps Approve in the app.

The per-key rate limits and the sensitivity class apply here identically: retrieve draws on the expensive lane, and a client_safe-class key gets the privilege-gated graph no matter what the model passes for client_safe. Over-limit calls come back as a tool error carrying the retry delay.

Each returns a single text content block holding the same stamped JSON as the REST twin. A tool-level failure — bad arguments, a case id this key cannot reach, a missing scope — comes back as isError: true with a readable message rather than a JSON-RPC error, so an agent can recover.

get_knowledge_graph truncates large graphs to fit a model's context (400 entities / 800 relationships) and says so explicitly via truncated: true and a truncation_note pointing at the REST export. It never silently returns a partial graph.

Raw handshake

curl -s -X POST https://<host>/mcp \
  -H "Authorization: Bearer brk_..." -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{}}}'

curl -s -X POST https://<host>/mcp \
  -H "Authorization: Bearer brk_..." -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

curl -s -X POST https://<host>/mcp \
  -H "Authorization: Bearer brk_..." -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
       "params":{"name":"retrieve","arguments":{"case_id":"…","query":"March invoice dispute","k":5}}}'

Supported methods: initialize, notifications/initialized, ping, tools/list, tools/call. Protocol revisions 2025-06-18 (default), 2025-03-26, and 2024-11-05 are accepted. GET /mcp returns 405 — there are no server-initiated messages, so there is no SSE stream to open.


5. Reconstructing the graph in your app

To hold a live mirror of a matter's graph in your own system:

1. Pin the schema version. Every payload stamps schema_version (currently 1.0). Treat an unrecognized version as a signal to re-check your mapping rather than to parse optimistically.

2. Backfill with an export. Pull GET /matters/:id/knowledge-graph?format=jsonld (or graphml). Identifiers are stable and derived from the underlying row ids — urn:bron:kg:entity:<id> and urn:bron:kg:rel:<id> — so re-pulls reconcile against what you already stored instead of duplicating it. Use these as your primary keys; do not key on entity names, which are merged and normalized over a matter's life.

3. Resolve provenance on demand. Entities and chunks cite source_ids rather than embedding source content. Dereference through GET /documents/:id for metadata plus a 300-second signed URL. Fetch immediately and store your own copy if you need one — the URL is a short-lived credential, not a permalink. Some sources will legitimately 404: privileged and work-product material is withheld from API keys by design, so treat a 404 as an expected outcome, not an error to retry.

4. Sync incrementally with webhooks. Subscribe to document.ingested and kg.updated:

EventDo this
document.ingestedRe-pull GET /documents/:id for the new document_id and reconcile that source
kg.updatedRe-pull the case graph export and reconcile against your stored urns

kg.updated fires only when a rebuild actually stored a new graph. A partial rebuild that preserved the prior graph emits nothing, so you are never sent on a no-op pull.

5. Reconcile, don't append. A rebuild can merge two entities that you hold separately (the same person named three ways in three documents collapses into one node) or split them apart. Diff the pulled urn set against yours and delete what is gone — appending only will drift.

6. Keep the gates on your side. What you receive has already passed the wall and privilege gates for that key's owner. If you re-expose it to your own users, apply your own authorization — inheriting one owner's view into a multi-user app would widen access.

Sketch

const HEADERS = { Authorization: `Bearer ${process.env.BRON_API_KEY}` };

async function syncMatterGraph(caseId) {
  const res = await fetch(
    `${BASE}/matters/${caseId}/knowledge-graph?format=jsonld`, { headers: HEADERS });
  if (!res.ok) throw new Error(`graph pull failed: ${res.status}`);
  const doc = await res.json();

  if (doc.schema_version !== "1.0") {
    console.warn(`unexpected schema_version ${doc.schema_version} — verify the mapping`);
  }

  const nodes = doc["@graph"].filter((n) => n["@type"] !== "Relationship");
  const edges = doc["@graph"].filter((n) => n["@type"] === "Relationship");

  await upsertNodes(caseId, nodes.map((n) => ({ urn: n["@id"], type: n["@type"], name: n.name })));
  await upsertEdges(caseId, edges.map((e) => ({
    urn: e["@id"], from: e.source, to: e.target, kind: e.relationship,
  })));
  // Reconcile: anything we hold for this matter that the pull no longer contains is gone.
  await deleteMissing(caseId, [...nodes, ...edges].map((n) => n["@id"]));
}

6. Versioning

Every stamped payload carries schema_version, currently 1.0. Here is exactly what that promises.

Within a version we may add, never remove or repurpose. New fields can appear on any payload at any time — client_safe_forced_by_key arrived this way. Parse permissively: ignore fields you do not recognize rather than rejecting the response. A field documented here will not disappear, change type, or change meaning while schema_version reads 1.0.

A breaking change bumps the number. Removing a field, renaming one, changing its type, or changing what it means is a new schema_version. Both versions run side by side for a deprecation window announced before the change, so you can pin, test, and migrate rather than being cut over.

What the version does not cover. It describes the payload shape, not the data. The graph itself is rebuilt as a matter develops: entities merge, split and change confidence, and the same export pulled twice can legitimately differ. Version your mapping against schema_version; reconcile your content against the stable urn: identifiers (see §5).

Check it, and act on a surprise. Treat an unrecognized schema_version as a signal to re-check your mapping — log it, alert, and keep parsing only what you already understand. It is not an error to retry.

Two other things are stable and safe to depend on: the urn:bron:kg:entity:<id> / urn:bron:kg:rel:<id> identifier formats, and the error contract ({"detail": "..."} plus the status codes in §1). Scopes and sensitivity classes are additive — new ones may appear; existing ones keep their meaning.