Basis Stream API — v1

Basis Stream API

Basis turns spreadsheets into the shape your LLM already wants: a typed cell graph, a formula edge list, embedded images with cell anchors, and a certificate that the whole thing matches what Excel would compute.

POST /api/v1/stream application/x-ndjson Bearer auth · gzip on the wire

Why Basis, not a document parser

Most tools in this space treat a workbook as a document to extract text from — bounding boxes, text spans, tables that look 80% right and hide the 20% that are wrong. Basis treats it as what it actually is: a computation graph. Anyone can write an xlsx cell reader; nobody else hands back the formula graph, a verified-against-Excel trust signal, and cell-anchored media in one deterministic call.

BasisLlamaParse / Textractopenpyxl / xlsx-js
Output shapecomputation graphdocument extractionraw cells only
Formula edges
Verified vs. Excel
Cell-anchored imagespartial
Deterministic✗ (LLM-based)
LLM-consumable✓ (semantic JSON)needs post-process✗ (too low-level)

No competitor emits “we re-ran every formula and here’s where we diverged from Excel’s cache, with concrete cell coordinates.” If a workbook is feeding an underwriting model, an ETL pipeline, or an AI agent, that’s the trust signal that actually matters — see the audit record.

Performance

Numbers below, not adjectives — measured against openpyxl (Python, cell-only extraction, no verification) and LlamaParse (cloud, LLM-based document extraction), on a mix of a controlled synthetic fixture and real multi-megabyte workbooks. Where Basis loses, we say so and explain why.

vs. a document-extraction cloud API

This comparison is the least ambiguous: parsing locally — whether that’s Basis or openpyxl — beats a network round-trip to a cloud model by a wide margin, on every file size tested.

FixtureBasis (hosted API)LlamaParseBasis advantage
Small workbook (single sheet, a few formulas)~165–260 ms7.5–9.1 s~30–55x
bench10k.xlsx — 723 KB, 70k cells1.7 s (median)36.0 s~21x
Large real-world workbook, 6–15 MB3–37 s (local engine)22–190 s~5–20x

LlamaParse’s time is dominated by upload, cloud queueing, and an agentic parse pass — not comparable to local CPU work, but real latency a caller waits on regardless of where it comes from.

vs. openpyxl — and the tradeoff that explains the gap

This is the honest, more interesting comparison, because it isn’t a clean win. On bench10k.xlsx (a controlled 70k-cell fixture, 3 runs, min/median reported), Basis’s native engine is slower than openpyxl:

Enginebench10k.xlsx (70,006 cells)Throughput
openpyxl412.9 / 425.8 ms (min/median)~164 cells/ms
Basis, local engine (napi/wasm)739.6 / 750.0 ms~93 cells/ms
Basis, hosted API (network round trip)1,422.5 / 1,696.2 ms~41 cells/ms

The gap widens further on real, formula-heavy workbooks. Across eight real-world files (6–15 MB, single run each, directional not statistically tight):

File profileSizeBasis (local)openpyxlResult
Financial model, 15 sheets, ~14,400 formula cells (2 variants of the same model)13.9–14.2 MB3.0–3.1 s0.37–0.59 sopenpyxl 5.3–8.1x faster
Operations tracker, 62 sheets, few formulas6.1 MB36.5 s6.5 sopenpyxl 5.6x faster
Deal analytics workbooks, 2–3 sheets, formulas mostly decorative (HYPERLINK)9.3–11.5 MB7.8–12.7 s7.1–11.9 sopenpyxl 6–11% faster
Flat data export, zero formulas14.0–14.8 MB6.3–12.1 s7.3–15.5 sBasis 1.16–1.29x faster
Why openpyxl wins on formula-heavy files: openpyxl reads a cell’s cached value and formula text, and stops there. Basis does that too, but also builds a formula-dependency edge for every reference — on the financial-model fixture above, ~14,400 formula cells produce 287,759 edges, about 20 per formula — and re-evaluates every formula to verify it against Excel’s cached value. openpyxl never attempts either. The slower number isn’t wasted time; it’s the dependency graph and trust certificate the rest of this page is about. On files with few or no formulas, there’s no graph to build and Basis is at parity or faster.
Read this as directional, not a guarantee. The real-world table is a single run per file on one sample set — not a statistically-controlled benchmark. Formula density, not file size or sheet count, is the dominant variable: expect openpyxl to win on formula-dense models and Basis to be roughly at parity on flat data exports. The hosted API additionally carries network round-trip cost (~150–260 ms fixed overhead on small payloads, roughly 2x on bench10k.xlsx) on top of local-engine numbers — if raw parse speed matters more than one HTTP call, embed the engine directly via the native binding instead of the hosted endpoint.

Quickstart

The fastest path in is the TypeScript SDK. One call, no NDJSON parsing or job polling to write yourself:

# npm install @basis/parse

import { Basis } from "@basis/parse";

const basis = new Basis({ token: process.env.BASIS_TOKEN });
const records = await basis.parse("./workbook.xlsx");

records is a fully-typed array of the same record catalog documented below — workbook_meta, sheet, cell, edge, image, image_summary, audit. Small files return inline in one round trip; large or slow ones are transparently submitted as a job and polled to completion behind the scenes — parse()’s signature and return type never change. You never need to know or care which path a given file took.

Not using TypeScript, or want the wire format directly? The rest of this page documents the raw HTTP contract — see Request & response for the curl equivalent.

Overview

Basis parses xlsx/xlsm workbooks with a zero-dependency Rust codec and emits the parsed contents as newline-delimited JSON. Each line is a self-contained record: a sheet header, a cell, a formula reference, an embedded picture, or an audit finding. The whole workbook is described in a single response — there are no pagination cursors, no polling, no callbacks. If the response completes, you have the entire workbook.

The API is designed for pipelines that need a stable, low-ambiguity view of a workbook: model verifiers, underwriting graphs, LLM tool surfaces, migration tools. It is not a general xlsx render / edit surface — consumers pick the fields they care about and ignore the rest.

What you get in one call

Sample output

A real two-sheet workbook — a purchase-price model with named ranges, a data table, and threaded comments, plus a hidden scratch sheet holding two raw inputs — run through POST /api/v1/stream. This is the complete, unedited response body for that file (only whitespace added for readability; the wire format is one compact object per line):

{
  "type": "workbook_meta",
  "date_system": "1904",
  "calc_iterative": true,
  "calc_count": 100,
  "has_vba": false,
  "sheet_count": 2,
  "core_properties": {
    "creator": "Brandon K",
    "lastModifiedBy": "bk",
    "created": "2024-01-02T03:04:05Z",
    "title": "Test Model"
  },
  "defined_names": [
    { "name": "Purchase_Price", "refers_to": "Model!$B$2", "scope": null, "is_builtin": false, "is_broken": false, "usable": true },
    { "name": "_xlnm.Print_Area", "refers_to": "Model!$A$1:$D$9", "scope": 0, "is_builtin": true, "is_broken": false, "usable": false },
    { "name": "Dead_Link", "refers_to": "#REF!", "scope": null, "is_builtin": false, "is_broken": true, "usable": false }
  ]
}
{
  "type": "sheet", "index": 0, "title": "Model", "state": "visible",
  "nrows": 5, "ncells": 6, "merged": ["A1:D1"],
  "hidden_rows": [2], "hidden_cols": [3, 4],
  "comments": [
    { "cell": "B2", "author": "Old Timer", "text": "Legacy note: verify with broker", "ts": null, "kind": "legacy" },
    { "cell": "A1", "author": "Jane Doe", "text": "Imported from gsheets", "ts": "2024-03-01", "kind": "gsheets" },
    { "cell": "A1", "author": null, "text": "Second entry", "ts": "2024-03-02", "kind": "gsheets" },
    { "cell": "C2", "author": "Ana Lender", "text": "Fee basis confirmed with lender", "ts": "2024-05-01T10:00:00Z", "kind": "threaded" }
  ],
  "data_tables": [
    { "cell": "E4", "ref": "E4:G6", "row_input": "B1", "col_input": "B2", "two_dimensional": true }
  ]
}
// six cell records for sheet 0: A1 "Price", A3 99, A5 =Scratch!B1+Scratch!B2, B2 1250000, C2 =Purchase_Price*0.075, E4 =TABLE(B1,B2)
{ "type": "cell", "sheet": 0, "ref": "A5", "kind": "formula_number", "num": 30, "str": null, "formula": "'Scratch'!B1+Scratch!B2", "fmt": null, "err": null }
{ "type": "cell", "sheet": 0, "ref": "C2", "kind": "formula_number", "num": 93750, "str": null, "formula": "Purchase_Price*0.075", "fmt": "0.00%", "err": null }
{
  "type": "sheet", "index": 1, "title": "Scratch", "state": "veryHidden",
  "nrows": 2, "ncells": 2, "merged": [], "hidden_rows": [], "hidden_cols": [], "comments": [], "data_tables": []
}
// two cell records for sheet 1: B1 10, B2 20
{ "type": "edge", "src_sheet": "Model", "src_cell": "A5", "tgt_sheet": "Scratch", "tgt_ref": "B1", "raw": "'Scratch'!B1", "named_ref": null, "kind": "cell", "is_range": false, "is_cross_sheet": true, "is_external": false, "is_ref_error": false }
{ "type": "edge", "src_sheet": "Model", "src_cell": "A5", "tgt_sheet": "Scratch", "tgt_ref": "B2", "raw": "Scratch!B2", "named_ref": null, "kind": "cell", "is_range": false, "is_cross_sheet": true, "is_external": false, "is_ref_error": false }
{ "type": "edge", "src_sheet": "Model", "src_cell": "C2", "tgt_sheet": "Model", "tgt_ref": "$B$2", "raw": "Purchase_Price", "named_ref": "Purchase_Price", "kind": "cell", "is_range": false, "is_cross_sheet": false, "is_external": false, "is_ref_error": false }
{ "type": "edge", "src_sheet": "Model", "src_cell": "E4", "tgt_sheet": null, "tgt_ref": "B1", "raw": "B1", "named_ref": null, "kind": "data_table_input", "is_range": false, "is_cross_sheet": false, "is_external": false, "is_ref_error": false }
{ "type": "edge", "src_sheet": "Model", "src_cell": "E4", "tgt_sheet": null, "tgt_ref": "B2", "raw": "B2", "named_ref": null, "kind": "data_table_input", "is_range": false, "is_cross_sheet": false, "is_external": false, "is_ref_error": false }
{ "type": "image_summary", "total_images": 0, "total_bytes": 0, "inlined": 0 }
{ "type": "audit", "finding": "verification", "formula_cells": 3, "compared": 2, "mismatches": 0, "coverage_pct": 66.67, "errors": 0, "unsupported": 1, "mismatch_samples": [] }
{ "type": "audit", "finding": "plug_summary", "emitted": 0, "total": 0, "cap": 200, "truncated": false }
{ "type": "audit", "finding": "cycles", "count": 0, "iterative_calc": true, "sanctioned": false, "members": [] }
{ "type": "audit", "finding": "staleness", "volatile_cells": 0, "external_refs": 0, "hidden_sheets": ["Scratch"], "hidden_rows": 1, "hidden_cols": 2 }

A few things worth noticing before you build the rest of your integration:

Authentication

Bearer token in the Authorization header. Tokens are per-workspace and revocable; contact your account rep to rotate.

Authorization: Bearer <your-token>

Requests without a valid token receive 401 unauthorized with an error envelope.

Request & response

Request

FieldValue
MethodPOST
Path/api/v1/stream
Content-Typeapplication/octet-stream (raw xlsx bytes) or multipart/form-data with a file field
Accept-Encodinggzip recommended — response body compresses ~15× for text records
BodyThe xlsx/xlsm file bytes

Response

FieldValue
Status200 on parse success, 4xx otherwise
Content-Typeapplication/x-ndjson
Content-Encodinggzip if requested by the client
x-basis-api-version1
BodyOne JSON object per line, no trailing newline required, deterministic order (see Emission order)

Minimal example

# curl example
curl -sS -X POST https://api.basis.co/api/v1/stream \
  -H "authorization: Bearer $BASIS_TOKEN" \
  -H "content-type: application/octet-stream" \
  -H "accept-encoding: gzip" \
  --data-binary "@workbook.xlsx" \
  --compressed \
  | head -3

# {"type":"workbook_meta","date_system":"1900","calc_iterative":false,...
# {"type":"sheet","index":0,"title":"Assumptions","state":"visible",...
# {"type":"cell","sheet":0,"ref":"A1","kind":"string","num":null,"str":"Rent",...

Emission order

Records arrive in a fixed order. Consumers can rely on this to build indexes incrementally without buffering the whole response:

1 workbook_meta (once, first) 2 for each sheet in workbook-declared order: sheet (once per sheet) cell (0..n; column-major within sheet) image (0..n; anchored to this sheet only) 3 edge (0..n; formula references) 4 image_summary (once, always emitted) 5 audit verification, plug*, plug_summary, broken_ref*, cycles, staleness

Guarantees

Not guaranteed

Jobs

Every workbook can be parsed through POST /v1/jobs instead of the plain /api/v1/stream route above. The decision between an inline response and an async job is made for you, per request, based on size and how fast the parse actually finishes — not a fixed cutoff you have to reason about ahead of time. The SDK’s parse() hides this entirely; this section documents what it’s doing under the hood, for callers integrating over raw HTTP.

Submitting a job

FieldValue
MethodPOST
Path/v1/jobs
BodySame as /api/v1/stream: raw xlsx/xlsm bytes or multipart/form-data.
Query — modesync forces a blocking inline response regardless of size; job forces a 202 regardless of size. Omit to let the server decide.
Query — webhook_urlOptional. Delivered a signed POST when the job finishes or fails — see Webhooks.

The server parses inline (200, identical NDJSON body to /api/v1/stream) when the upload is small and the parse finishes quickly. Either condition failing falls back to 202 — a slow-to-parse small file degrades gracefully into a job instead of holding the connection open indefinitely.

// 200 — parsed inline, small + fast enough
x-basis-job-id: 7e2f1c3a-...
x-basis-job-status: completed
content-type: application/x-ndjson

{"type":"workbook_meta",...}
{"type":"sheet",...}
...

// 202 — submitted as a job
{
  "job_id": "7e2f1c3a-...",
  "status": "processing",
  "status_url": "/v1/jobs/7e2f1c3a-...",
  "stream_url": "/v1/jobs/7e2f1c3a-.../stream"
}

Polling status

GET /v1/jobs/:id returns the job’s current state. Jobs are visible only to the token that created them — a mismatched token or an unknown/expired id both return 404, indistinguishably, so no caller can probe for the existence of another workspace’s job.

{
  "job_id": "7e2f1c3a-...",
  "status": "completed",
  "created_at": "2026-07-13T12:00:00.000Z",
  "updated_at": "2026-07-13T12:00:01.400Z",
  "expires_at": "2026-07-13T12:15:00.000Z",
  "bytes_in": 10485760,
  "records_out": 4213,
  "stream_url": "/v1/jobs/7e2f1c3a-.../stream",
  "error": null
}

status is one of processing, completed, failed. On failed, error is {code, message} instead of null.

Jobs are not persisted. They live in memory only, evicted after expires_at (15 minutes after creation by default). Nothing is written to disk. Fetch results before the job expires — there is no durable retrieval after that.

Fetching the result

GET /v1/jobs/:id/stream returns the same NDJSON body /api/v1/stream would have produced. 409 job_not_ready if the job is still processing; 422 parse_failed if it failed.

This is a resumable fetch, not a live feed. stream only ever returns a finished result — there is no incremental delivery of records while a parse is still in flight. The since query parameter (a record index) lets a client that got disconnected mid-download resume from where it left off, without re-downloading records it already has:
# first 1,200 records already downloaded — resume from there
GET /v1/jobs/7e2f1c3a-.../stream?since=1200

Webhooks

Pass webhook_url when submitting a job to get a single best-effort POST when it finishes or fails — no retry queue if delivery fails, so treat it as a convenience notification, not a guaranteed at-least-once callback. Poll status_url if you need certainty.

POST {webhook_url}
content-type: application/json
x-basis-signature: sha256=<hex>

{
  "job_id": "7e2f1c3a-...",
  "status": "completed",
  "records_out": 4213,
  "error": null
}

The signature is an HMAC-SHA256 over the raw JSON body, keyed with your STREAM_API_TOKEN — the same token you authenticate requests with. Verify it with a constant-time comparison before trusting the payload:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, signatureHeader, token) {
  const expected = createHmac("sha256", token).update(rawBody).digest("hex");
  const given = signatureHeader.replace("sha256=", "");
  return given.length === expected.length
    && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

What you can build

Fields don’t sell — outcomes do. The record catalog below is the exhaustive reference; this section is the map from a thing you’re trying to build to the exact records that get you there.

A formula-graph lineage tool

Every formula reference in the workbook — after shared-formula expansion and named-ref resolution — is an edge record: src_sheet/src_celltgt_sheet/tgt_ref. Build a directed graph by treating each edge as an arc; is_cross_sheet, is_range, and named_ref tell you what kind of arc it is without re-parsing formula text.

Layer trust on top with audit.verification.mismatch_samples: cells where Basis’s re-evaluation diverged from Excel’s cached value. Mark those nodes in the graph as suspect — a lineage tool that only shows structure is a diagram; one that also shows which nodes are unreliable is a debugging aid.

Extracting deal photos with cell context for a vision LLM

Each embedded picture is an image record anchored to a cell (from/to in A1 notation) with a content-addressed sha256. Because image records interleave with the cell records of their parent sheet, you already have the surrounding row’s data in hand when you see the image — pass a vision LLM the picture (data_base64, or blob-fetched by hash once past the inline cap) alongside the anchor cell’s neighboring cells as context: “this photo is attached to the row for Unit 204” instead of a bare bounding box with no idea what it’s a photo of.

Dedup on sha256 if the same photo is re-embedded across sheets or workbooks — it’s a stable hash of the raw decompressed bytes, not the xlsx entry.

A workbook-diff between two versions

Parse both versions and diff three record streams:

Combining all three tells a story a value-only diff can’t: “this cell’s number didn’t change, but the formula behind it now points somewhere else, and it no longer matches Excel’s cache.”

Verifying an ETL pipeline picked up the right cells

Before trusting a nightly extract, check the audit.verification finding: coverage_pct is the share of formula constructs Basis could independently re-derive, mismatches is how many of those diverged from Excel’s cached value, and mismatch_samples names the exact {sheet, ref} pairs so you don’t have to go hunting. A pipeline that depends on specific cells can hard-fail the run when any of those cells appear in mismatch_samples, instead of silently ingesting a stale or broken formula result.

Record types

Exhaustive field-by-field reference for every record the stream can emit. See What you can build above for outcome-oriented starting points.

workbook_meta

Workbook-level facts. Emitted once, first. Treat as the response header — if this line fails to parse, do not assume the rest of the stream is valid.

FieldTypeDescription
type"workbook_meta"Discriminator
date_system"1900" | "1904"Excel epoch. Misreading this shifts every date by ~4 years.
calc_iterativebooleanTrue if the workbook has iterative calc enabled — cycles are intentional.
calc_countnumber | nullMax iterations, when iterative calc is on.
has_vbabooleanTrue if xl/vbaProject.bin exists.
sheet_countnumberNumber of worksheets.
core_propertiesobjectKey-value pairs from docProps/core.xml: creator, modified, title, etc.
defined_namesarrayNamed references. Each entry: {name, refers_to, scope, is_builtin, is_broken, usable}

sheet

One per worksheet, in the tab-order the file specifies. The index field is what every other record’s sheet field references — sort by index, not by title.

FieldTypeDescription
type"sheet"Discriminator
indexnumber0-based sheet index. Referenced by every downstream record.
titlestringSheet display name (post-entity-decode).
state"visible" | "hidden" | "veryHidden"Tab visibility.
nrowsnumberRow count (max row with content).
ncellsnumberNon-empty cell count.
mergedstring[]Merged-cell ranges, A1 notation, e.g. "B2:D4".
hidden_rowsnumber[]0-based indices of hidden rows.
hidden_colsnumber[]0-based indices of hidden columns.
commentsarrayCell comments: {cell, author, text, ts, kind}, threaded and legacy.
data_tablesarrayWhat-if data tables: {cell, ref, row_input, col_input, two_dimensional}

cell

One per non-empty cell. Column-major within a sheet.

FieldTypeDescription
type"cell"Discriminator
sheetnumberParent sheet index.
refstringA1-notation cell reference, e.g. "AG441".
kindstringOne of number, string, bool, error, formula_number, formula_other.
numnumber | "NaN" | "Infinity" | "-Infinity" | nullNumeric value. Non-finite f64 values render as string sentinels since JSON forbids them.
strstring | nullString value or cached formula string result.
formulastring | nullFormula source (post shared-formula expansion). No leading =.
fmtstring | nullNumber format code, e.g. "#,##0.00". null = General.
errstring | nullExcel error literal for kind: "error": "#DIV/0!", "#REF!", etc.
Non-finite values. Consumers plotting num should branch on typeof: number values are safe to use directly; the four string sentinels ("NaN", "Infinity", "-Infinity", and null) indicate that the source cell held a divergent formula or was empty.

image new in v1

Every embedded picture from xl/drawings + xl/media. One record per drawing anchor. Emitted immediately after the parent sheet’s cells, before the next sheet begins.

FieldTypeDescription
type"image"Discriminator
sheetnumberParent sheet index.
indexnumberWorkbook-wide stable index (0..images.length−1). Suitable as a UI key.
anchor"two_cell" | "one_cell" | "absolute"Excel anchor mode — drives how the geometry fields are interpreted.
fromstring | nullA1 of top-left anchor cell. null when anchor === "absolute".
tostring | nullA1 of bottom-right anchor cell. Non-null only for two_cell.
ext_cx_emu, ext_cy_emunumber | nullExplicit width / height in EMU (English Metric Units; 914400/inch, 9525/px at 96 DPI).
col_off_emu, row_off_emunumber | nullSub-cell offset for the from anchor, or absolute x/y in EMU for anchor === "absolute".
mimestringSniffed by extension then magic bytes: image/png, image/jpeg, image/gif, image/bmp, image/webp, image/svg+xml, image/tiff, image/x-emf, image/x-wmf.
sha256stringSHA-256 hex (64 chars, lowercase) of the raw decompressed image bytes. Stable dedup key.
byte_lennumberRaw byte count.
altstring | nullAlt text from xdr:cNvPr/@descr.
data_base64string | nullStandard base64 of the raw bytes. Populated iff byte_len ≤ 1,500,000. See limits.

Interpreting the anchor

Branch on anchor before reading geometry fields:

anchorfromtoext_cx_emu / ext_cy_emucol_off_emu / row_off_emu
"two_cell"A1 top-leftA1 bottom-rightnullsub-cell offset from top-left
"one_cell"A1 top-leftnullexplicit size in EMUsub-cell offset from top-left
"absolute"nullnullexplicit size in EMUabsolute x/y in EMU from sheet origin

Consuming data_base64

Three states you’ll encounter:

Content addressing. The sha256 field is over the raw decompressed image bytes, not the xlsx entry CRC. Identical photos embedded in different workbooks — or embedded on multiple sheets within one workbook — produce the same hash. Consumers building an image store should key on this hash.

Anchor coordinates in EMU

Excel stores drawing geometry in English Metric Units: 914400 EMU per inch, or 9525 EMU per pixel at 96 DPI. We pass Excel’s coordinates verbatim rather than pre-converting to pixels, so the consumer can pick a rendering DPI. Convert with px = emu / 9525.

edge

Every formula reference, after shared-formula expansion and named-ref resolution. Emitted after all cells / images across every sheet.

FieldTypeDescription
type"edge"Discriminator
src_sheetstringSource sheet display name (not index).
src_cellstringA1 of the formula cell.
tgt_sheetstring | nullTarget sheet display name; null for same-sheet edges.
tgt_refstringA1 (or A1:A2) of the reference.
rawstringVerbatim reference text as it appears in the formula.
named_refstring | nullOriginal named-range identifier if resolution came from a defined name.
kindstringOne of cell, range, named, error, data_table_input.
is_rangebooleanTrue if the reference spans more than one cell.
is_cross_sheetbooleanTrue if the target is on a different sheet than the source.
is_externalbooleanTrue if the reference points at another workbook.
is_ref_errorbooleanTrue for #REF! references — also surfaced as an audit finding.

image_summary new in v1

Workbook-wide image roll-up. Emitted once, after all edges, before the first audit finding. Always present, including on workbooks with zero images.

FieldTypeDescription
type"image_summary"Discriminator
total_imagesnumberCount of image records emitted.
total_bytesnumberSum of raw byte length across all images.
inlinednumberCount where data_base64 was populated (payload fit under the inline cap). total_images − inlined = deferred count.

audit

Post-recalc trust signals. All audit records share a type: "audit" discriminator; the finding field discriminates the payload.

verification

Emitted once. How many formulas Basis re-evaluated matched Excel’s cached values.

FieldTypeDescription
formula_cellsnumberTotal formula cells in the workbook.
comparednumberCells where a comparable cached value existed.
mismatchesnumberCount of cells whose re-eval differed from cache.
coverage_pctnumberPercentage of supported formula constructs (i.e. those Basis knows how to evaluate).
errorsnumberRecalc errors.
unsupportednumberFormulas skipped due to unmodeled constructs.
mismatch_samplesarrayUp to 256 concrete cell mismatches: {sheet, sheet_name, ref, cached, computed}. Non-finite cached/computed values are the string sentinels.

plug, plug_summary

Zero or more plug findings (up to 200; the cap and total are reported in the single plug_summary). Each identifies a cell that breaks a fill-down or fill-right formula run — typically a hand-punched constant.

broken_ref, cycles, staleness

Zero or more broken_ref findings for cells with #REF! in their formulas. Exactly one cycles finding (with a bounded sample of cycle members). Exactly one staleness finding summarizing volatile cells, external refs, and hidden structure.

Contract. verification, plug_summary, cycles, and staleness are each emitted exactly once per successful response, even when their contents are zero. Consumers can rely on their presence as a completion signal.

Size & limits

Request body

LimitValueBehavior
Max request body200 MB (configurable per deployment)Larger requests receive 413 payload_too_large, on both /api/v1/stream and /v1/jobs — the cap is the same either way. For workbooks near the cap, prefer POST /v1/jobs anyway: it won’t hold your connection open for the duration of a slow parse.

Response body

LimitValueBehavior
Per-image inline cap1,500,000 bytesLarger images emit metadata + sha256 only. Retrieve raw bytes out-of-band (fetch-by-hash endpoint on the roadmap).
Plug findings200Above the cap, plug_summary.truncated is true and total reflects the real count.
Cycle members sample40Full count in cycles.count.
Broken-ref findings100Full count in broken_refs.total when aggregated.
Verification mismatch samples256Full count in mismatches.

Compression

The response gzips ~15× for text records (cells, edges, audit). Base64 image payloads compress much less (typically 1.2–1.5×) since they’re already dense. A workbook with 5–10 MB of embedded photos will produce a response of roughly the same size on the wire.

Always send Accept-Encoding: gzip. Browser fetch clients do this automatically.

Errors

Non-success responses use a stable envelope:

{
  "error": {
    "code": "payload_too_large",
    "message": "body exceeds 200000000 bytes; use signed upload for larger files"
  }
}
StatuscodeMeaning
400bad_requestMultipart form missing the file field.
400empty_bodyRequest body was empty.
401unauthorizedMissing or invalid bearer token.
413payload_too_largeBody exceeded the max request size.
422parse_failedThe bytes couldn’t be parsed as a supported xlsx / xlsm. Message contains the codec’s reason.
405method_not_allowedWrong HTTP method for this route.
500internal_errorUnexpected server error.

Consumer patterns

Streaming decoder

Read the response body line-by-line and dispatch on type. Most consumers don’t need to buffer the entire response — the emission order lets you close over per-sheet state as records flow.

for await (const line of ndjsonLines(response.body)) {
  const rec = JSON.parse(line);
  switch (rec.type) {
    case "workbook_meta": /* build workbook context */ break;
    case "sheet":        /* start new sheet */         break;
    case "cell":         /* index by (sheet, ref) */    break;
    case "image":        /* attach to current sheet */  break;
    case "edge":         /* build formula graph */      break;
    case "image_summary": /* rollup */                  break;
    case "audit":        /* rec.finding dispatches */   break;
  }
}

Filter by record type

If you only need the audit certificate, skip until you hit type: "audit". If you only need the workbook contents, drop everything after image_summary. Records are self-contained: filtering doesn’t invalidate the ones you keep.

Content-addressed image store

Insert image records into your storage keyed by sha256. Duplicate hashes across workbooks (same photo re-embedded) collapse to a single stored blob. Anchor coordinates remain per-record.

Out of scope

Basis is a workbook parser, not a full xlsx-fidelity engine. The following are not emitted in the stream:

If you have a use case that depends on any of the above, tell us — the parser roadmap is driven by consumer needs.

Versioning

The v1 contract is stable across additive changes:

The response header x-basis-api-version always reflects the served version. Clients can pin by path.