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 URL —
https://<your-host>/api/v1 - Machine-readable description —
GET /api/v1/openapi.json(no auth; shapes only, never data) - MCP endpoint —
POST /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:
| Class | What it sees | Who can mint it |
|---|---|---|
attorney (default) | Everything described above, including the full attorney-grade knowledge graph with privileged provenance | A firm administrator |
client_safe | The 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 off | Any 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_withlist, 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:
| Lane | Endpoints | Limit |
|---|---|---|
| Read | Everything below except the two lanes named next | 120 requests / minute |
| Expensive | POST /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:
| Backstop | Applies to | Default |
|---|---|---|
| Server-wide | Every request, including POST /mcp and the unauthenticated GET /openapi.json | 1000 requests / 15 min |
| Drafting | POST /drafting/verify-citations, /drafting/table-of-authorities, /drafting/pre-file-check — the three that spend on a model. /drafting/link-authorities is deterministic and exempt | 40 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
| Scope | Grants |
|---|---|
read | Matter records, deadlines, and every granular read scope below |
write | Everything — full superset |
kg:read | Knowledge graph + firm graph, including the JSON-LD / GraphML exports |
retrieval:query | Hybrid RAG retrieval |
docs:read | Provenance dereference (GET /documents/:id) |
drafting:check | The four stateless drafting checks (/drafting/*) the editor add-ins use |
tasks:read | The 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
| Status | Meaning |
|---|---|
400 | Malformed request (missing content, query over 2000 chars, unsupported format) |
401 | Missing, invalid, or revoked key |
403 | The key lacks the scope this endpoint requires |
404 | Not found or not reachable by this key — deliberately indistinguishable |
413 | Document text over 500,000 characters (drafting checks) |
429 | Rate limited — either this key's per-minute lane budget or a per-IP backstop; honour Retry-After |
502 | An upstream check (citation verification, ToA, pre-file) could not complete — not a pass |
503 | The 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 param | Values | Effect |
|---|---|---|
client_safe | 1 | Narrow to the producible client view — apply the client-portal privilege gate (fail-closed). Omitted / not 1 returns the full attorney view (see below). |
format | json (default), jsonld, graphml | Export 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:
| Header | Meaning |
|---|---|
X-Bron-Payload-Id | The receipt id for this release |
X-Bron-Pinned-Version | The document_versions row whose bytes the URL points at |
X-Bron-Render-Sha256 | sha256 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.
| Endpoint | Body | Returns |
|---|---|---|
POST /drafting/verify-citations | text, optional case_id | The citation-gate report: every citation extracted, its verification status, holding check, and whether the document is blocking |
POST /drafting/table-of-authorities | text | {toa, rendered} — the grouped Table of Authorities plus a rendered text block |
POST /drafting/link-authorities | text | {links, total} — each citation's character span and a resolved external URL (deterministic; no model spend) |
POST /drafting/pre-file-check | text, optional case_id, optional doc_type | The 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).
| Event | Fires when |
|---|---|
matter.resolved | A matter is marked resolved (settlement, verdict, dismissal) |
hearing.debriefed | A hearing debrief is recorded |
document.ingested | A document finishes ingest and is retrievable |
kg.updated | A knowledge-graph rebuild stored a new graph |
webhook.test | You 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
| Tool | Scope | What it does |
|---|---|---|
list_matters | read | Lists matters reachable by the key after owner scoping, ethical-wall screens, and the key's matter allow-list. |
get_knowledge_graph | kg:read | Returns one matter's provenance-backed entities and relationships, with optional client_safe privilege filtering and explicit truncation for large graphs. |
retrieve | retrieval:query | Searches one matter's reviewed, nonprivileged record with hybrid RAG and returns matching passages, citation labels, and pinpoint locators. |
list_deadlines | read | Reads one matter's calendared deadlines and recorded basis, marking dates without a basis ungrounded, and never creates a deadline. |
get_tasks | read | Returns the attorney-only triaged agenda derived from case_tasks, not a raw task dump, and refuses client-safe keys. |
get_matter_brief | read | Returns the attorney-only cached Matter Mind narrative and task-backed agenda, labeling model-polished prose ungrounded, and refuses client-safe keys. |
propose_action | write | Executes 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:
| Event | Do this |
|---|---|
document.ingested | Re-pull GET /documents/:id for the new document_id and reconcile that source |
kg.updated | Re-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.