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.
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.
| Basis | LlamaParse / Textract | openpyxl / xlsx-js | |
|---|---|---|---|
| Output shape | computation graph | document extraction | raw cells only |
| Formula edges | ✓ | ✗ | ✗ |
| Verified vs. Excel | ✓ | ✗ | ✗ |
| Cell-anchored images | ✓ | partial | ✗ |
| 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.
| Fixture | Basis (hosted API) | LlamaParse | Basis advantage |
|---|---|---|---|
| Small workbook (single sheet, a few formulas) | ~165–260 ms | 7.5–9.1 s | ~30–55x |
bench10k.xlsx — 723 KB, 70k cells | 1.7 s (median) | 36.0 s | ~21x |
| Large real-world workbook, 6–15 MB | 3–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:
| Engine | bench10k.xlsx (70,006 cells) | Throughput |
|---|---|---|
| openpyxl | 412.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 profile | Size | Basis (local) | openpyxl | Result |
|---|---|---|---|---|
| Financial model, 15 sheets, ~14,400 formula cells (2 variants of the same model) | 13.9–14.2 MB | 3.0–3.1 s | 0.37–0.59 s | openpyxl 5.3–8.1x faster |
| Operations tracker, 62 sheets, few formulas | 6.1 MB | 36.5 s | 6.5 s | openpyxl 5.6x faster |
Deal analytics workbooks, 2–3 sheets, formulas mostly decorative (HYPERLINK) | 9.3–11.5 MB | 7.8–12.7 s | 7.1–11.9 s | openpyxl 6–11% faster |
| Flat data export, zero formulas | 14.0–14.8 MB | 6.3–12.1 s | 7.3–15.5 s | Basis 1.16–1.29x faster |
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
- Every non-empty cell with its type, cached value, formula, and number format.
- Every formula reference edge (post shared-formula expansion, with named-ref resolution).
- Every embedded picture from
xl/drawings+xl/media, with anchor coordinates, mime, content hash, and inline base64 payload (up to a per-image cap). - An audit certificate: how many formulas re-evaluated matched Excel’s cached values, any pattern-break plugs, broken refs, circular references, and staleness signals.
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:
- Broken named ranges are flagged, not dropped.
defined_nameskeeps a working range (Purchase_Price), a builtin one Excel manages itself (_xlnm.Print_Area,usable: false), and a dangling one (Dead_Link→#REF!,is_broken: true) side by side — nothing silently disappears. - Named refs resolve, not just pass through as text.
C2’s formula readsPurchase_Price*0.075; the matchingedgerecord resolves that name to its real target (Model!$B$2) while still reporting the original name innamed_ref. - Hidden and cross-sheet references are still first-class.
A5pulls two inputs fromScratch, averyHiddensheet — twoedgerecords,is_cross_sheet: true, resolved exactly as Excel would compute them. - Data tables surface as ordinary edges. The what-if table at
E4becomes twodata_table_inputedges (row input, column input) — a consumer that already switches onedge.kindneeds no special case for this. - The audit certificate quantifies trust, not just presence. 3 formula cells, 2 comparable, 0 mismatches, 66.67% coverage — the one gap is
TABLE(), an unmodeled construct reported asunsupportedrather than silently marked correct.stalenessseparately calls out the hidden sheet by name. - This particular workbook has no embedded pictures, so no
imagerecords appear above — see theimagerecord for that shape.image_summaryis still emitted (all-zero) either way, as a completion signal.
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
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/v1/stream |
| Content-Type | application/octet-stream (raw xlsx bytes) or multipart/form-data with a file field |
| Accept-Encoding | gzip recommended — response body compresses ~15× for text records |
| Body | The xlsx/xlsm file bytes |
Response
| Field | Value |
|---|---|
| Status | 200 on parse success, 4xx otherwise |
| Content-Type | application/x-ndjson |
| Content-Encoding | gzip if requested by the client |
| x-basis-api-version | 1 |
| Body | One 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, stalenessGuarantees
workbook_metais always line 1.- Every
cellfor sheet N arrives before the firstimagefor sheet N. - Every
imagefor sheet N arrives before the firstcellfor sheet N+1. - All
sheet/cell/imagerecords arrive before the firstedge. image_summaryis always emitted, even when the workbook contains zero images.- Audit findings emit in the order shown above.
verification,plug_summary,cycles, andstalenessare each emitted exactly once.
Not guaranteed
- Cell order within a sheet is column-major (A1, A2, A3, B1, B2, ...) — sort by parsing
refif you need row-major. - Image order within a sheet follows the xlsx’s drawing part, which is not spatially sorted.
- Edges are grouped by source sheet but not otherwise ordered.
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
| Field | Value |
|---|---|
| Method | POST |
| Path | /v1/jobs |
| Body | Same as /api/v1/stream: raw xlsx/xlsm bytes or multipart/form-data. |
Query — mode | sync forces a blocking inline response regardless of size; job forces a 202 regardless of size. Omit to let the server decide. |
Query — webhook_url | Optional. 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.
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.
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_cell → tgt_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:
cellrecords by(sheet, ref)— value and formula changes, the obvious diff.edgerecords by(src_sheet, src_cell)— catches dependency rewiring even when the visible formula text is unchanged, e.g. an inserted row shifting what a range actually points at.audit.verification— comparecoverage_pctandmismatchesbetween the two runs to see whether the edit introduced (or fixed) a discrepancy against Excel’s own calc, not just whether the numbers moved.
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.
| Field | Type | Description |
|---|---|---|
type | "workbook_meta" | Discriminator |
date_system | "1900" | "1904" | Excel epoch. Misreading this shifts every date by ~4 years. |
calc_iterative | boolean | True if the workbook has iterative calc enabled — cycles are intentional. |
calc_count | number | null | Max iterations, when iterative calc is on. |
has_vba | boolean | True if xl/vbaProject.bin exists. |
sheet_count | number | Number of worksheets. |
core_properties | object | Key-value pairs from docProps/core.xml: creator, modified, title, etc. |
defined_names | array | Named 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.
| Field | Type | Description |
|---|---|---|
type | "sheet" | Discriminator |
index | number | 0-based sheet index. Referenced by every downstream record. |
title | string | Sheet display name (post-entity-decode). |
state | "visible" | "hidden" | "veryHidden" | Tab visibility. |
nrows | number | Row count (max row with content). |
ncells | number | Non-empty cell count. |
merged | string[] | Merged-cell ranges, A1 notation, e.g. "B2:D4". |
hidden_rows | number[] | 0-based indices of hidden rows. |
hidden_cols | number[] | 0-based indices of hidden columns. |
comments | array | Cell comments: {cell, author, text, ts, kind}, threaded and legacy. |
data_tables | array | What-if data tables: {cell, ref, row_input, col_input, two_dimensional} |
cell
One per non-empty cell. Column-major within a sheet.
| Field | Type | Description |
|---|---|---|
type | "cell" | Discriminator |
sheet | number | Parent sheet index. |
ref | string | A1-notation cell reference, e.g. "AG441". |
kind | string | One of number, string, bool, error, formula_number, formula_other. |
num | number | "NaN" | "Infinity" | "-Infinity" | null | Numeric value. Non-finite f64 values render as string sentinels since JSON forbids them. |
str | string | null | String value or cached formula string result. |
formula | string | null | Formula source (post shared-formula expansion). No leading =. |
fmt | string | null | Number format code, e.g. "#,##0.00". null = General. |
err | string | null | Excel error literal for kind: "error": "#DIV/0!", "#REF!", etc. |
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.
| Field | Type | Description |
|---|---|---|
type | "image" | Discriminator |
sheet | number | Parent sheet index. |
index | number | Workbook-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. |
from | string | null | A1 of top-left anchor cell. null when anchor === "absolute". |
to | string | null | A1 of bottom-right anchor cell. Non-null only for two_cell. |
ext_cx_emu, ext_cy_emu | number | null | Explicit width / height in EMU (English Metric Units; 914400/inch, 9525/px at 96 DPI). |
col_off_emu, row_off_emu | number | null | Sub-cell offset for the from anchor, or absolute x/y in EMU for anchor === "absolute". |
mime | string | Sniffed 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. |
sha256 | string | SHA-256 hex (64 chars, lowercase) of the raw decompressed image bytes. Stable dedup key. |
byte_len | number | Raw byte count. |
alt | string | null | Alt text from xdr:cNvPr/@descr. |
data_base64 | string | null | Standard base64 of the raw bytes. Populated iff byte_len ≤ 1,500,000. See limits. |
Interpreting the anchor
Branch on anchor before reading geometry fields:
| anchor | from | to | ext_cx_emu / ext_cy_emu | col_off_emu / row_off_emu |
|---|---|---|---|---|
"two_cell" | A1 top-left | A1 bottom-right | null | sub-cell offset from top-left |
"one_cell" | A1 top-left | null | explicit size in EMU | sub-cell offset from top-left |
"absolute" | null | null | explicit size in EMU | absolute x/y in EMU from sheet origin |
Consuming data_base64
Three states you’ll encounter:
- Renderable, inline —
data_base64populated,mimeis browser-safe (png/jpeg/gif/bmp/webp/svg). Render directly withsrc="data:{mime};base64,{data_base64}". - Renderable, deferred —
data_base64isnullbecausebyte_lenexceeded the inline cap. Fetch bytes out-of-band bysha256, wrap in a blob, and render viaURL.createObjectURL(or equivalent). - Not browser-renderable —
mimeisimage/x-emf,image/x-wmf, orimage/tiff. Preserve the hash + anchor for reference; render a placeholder.
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.
| Field | Type | Description |
|---|---|---|
type | "edge" | Discriminator |
src_sheet | string | Source sheet display name (not index). |
src_cell | string | A1 of the formula cell. |
tgt_sheet | string | null | Target sheet display name; null for same-sheet edges. |
tgt_ref | string | A1 (or A1:A2) of the reference. |
raw | string | Verbatim reference text as it appears in the formula. |
named_ref | string | null | Original named-range identifier if resolution came from a defined name. |
kind | string | One of cell, range, named, error, data_table_input. |
is_range | boolean | True if the reference spans more than one cell. |
is_cross_sheet | boolean | True if the target is on a different sheet than the source. |
is_external | boolean | True if the reference points at another workbook. |
is_ref_error | boolean | True 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.
| Field | Type | Description |
|---|---|---|
type | "image_summary" | Discriminator |
total_images | number | Count of image records emitted. |
total_bytes | number | Sum of raw byte length across all images. |
inlined | number | Count 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.
| Field | Type | Description |
|---|---|---|
formula_cells | number | Total formula cells in the workbook. |
compared | number | Cells where a comparable cached value existed. |
mismatches | number | Count of cells whose re-eval differed from cache. |
coverage_pct | number | Percentage of supported formula constructs (i.e. those Basis knows how to evaluate). |
errors | number | Recalc errors. |
unsupported | number | Formulas skipped due to unmodeled constructs. |
mismatch_samples | array | Up 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.
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
| Limit | Value | Behavior |
|---|---|---|
| Max request body | 200 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
| Limit | Value | Behavior |
|---|---|---|
| Per-image inline cap | 1,500,000 bytes | Larger images emit metadata + sha256 only. Retrieve raw bytes out-of-band (fetch-by-hash endpoint on the roadmap). |
| Plug findings | 200 | Above the cap, plug_summary.truncated is true and total reflects the real count. |
| Cycle members sample | 40 | Full count in cycles.count. |
| Broken-ref findings | 100 | Full count in broken_refs.total when aggregated. |
| Verification mismatch samples | 256 | Full 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"
}
}
| Status | code | Meaning |
|---|---|---|
| 400 | bad_request | Multipart form missing the file field. |
| 400 | empty_body | Request body was empty. |
| 401 | unauthorized | Missing or invalid bearer token. |
| 413 | payload_too_large | Body exceeded the max request size. |
| 422 | parse_failed | The bytes couldn’t be parsed as a supported xlsx / xlsm. Message contains the codec’s reason. |
| 405 | method_not_allowed | Wrong HTTP method for this route. |
| 500 | internal_error | Unexpected 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:
- Charts, chart-embedded images, sparklines.
- OLE objects, form controls, ActiveX, ink annotations.
- WPS/Kingsoft
xl/cellimages.xml(cell-embedded images) — a proprietary extension. - Pivot table state (source data + cached results are surfaced as regular cells; pivot definitions are preserved for round-trip but not streamed).
- Conditional formatting rules, data validation rules, dynamic array spill behavior.
- Named-range definitions that reference external workbooks (
is_external: trueis set but the target isn’t fetched). - xls (BIFF), xlsb (binary), and ods files — use the xlsx/xlsm variants only.
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:
- Additive fields are non-breaking. A future release may add fields to existing records; consumers must ignore unknown fields.
- Additive record types are non-breaking. A future release may add new
typevalues (or newaudit.findingvalues); consumers must default-case-ignore them. - Field removals or type changes require a new major version. The path becomes
/api/v2/stream; v1 remains available in parallel. - The Jobs API is additive, not a replacement.
/api/v1/streamis unchanged;POST /v1/jobsis a new, separate surface. Its200inline response carries two additive headers,x-basis-job-idandx-basis-job-status: completed, alongside the samex-basis-api-versionheader — existing NDJSON consumers can ignore both.
The response header x-basis-api-version always reflects the served version. Clients can pin by path.