basis1 Stream API
basis1 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.
For AI agents: /llms.txt · /llms-full.txt · /openapi.yaml
Why basis1, 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. basis1 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.
| basis1 | 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 basis1 loses, we say so and explain why.
vs. a document-extraction cloud API
This comparison is the least ambiguous: parsing locally — whether that’s basis1 or openpyxl — beats a network round-trip to a cloud model by a wide margin, on every file size tested.
| Fixture | basis1 (hosted API) | LlamaParse | basis1 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), basis1’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 |
| basis1, embedded engine | 739.6 / 750.0 ms | ~93 cells/ms |
| basis1, 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 | basis1 (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 | basis1 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
basis1 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 QUERY /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": "Sample Author",
"lastModifiedBy": "sample",
"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, "unsupported_breakdown": [{ "reason": "unknown function TABLE", "count": 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, withunsupported_breakdownnaming exactly which construct (unknown function TABLE) so you don’t have to guess.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
Per-customer API key in the Authorization header. Keys look like basis_live_<id>_<secret>, are issued and revoked independently, and scope every job to the issuing customer. A revoked key is rejected immediately. Contact your account rep to issue or rotate one.
Authorization: Bearer basis_live_<id>_<secret>
Requests without a valid key receive 401 unauthorized with an error envelope. Each key carries usage quotas; a request that would exceed the current period's quota receives 402 quota_exceeded (see errors) with X-basis1-Quota-Limit, X-basis1-Quota-Used, and X-basis1-Quota-Reset headers.
Request & response
Request
| Field | Value |
|---|---|
| Method | QUERY (preferred) or POST (deprecated compatibility fallback for clients/proxies that don’t yet support QUERY — identical request/response contract) |
| Path | /api/v1/stream |
| Content-Type | application/octet-stream (raw file bytes) or multipart/form-data with a file field |
| Accept-Encoding | gzip recommended — response body compresses ~15× for text records |
| Body | xlsx/xlsm file bytes, parsed natively. Legacy .xls and CSV-shaped plain text are also accepted on a best-effort basis — see Legacy .xls & CSV below. |
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 |
| Accept-Query | application/octet-stream, multipart/form-data — per RFC 10008, advertises which body media types this resource accepts via QUERY |
| Body | One JSON object per line, no trailing newline required, deterministic order (see Emission order) |
Same bytes in, same NDJSON out, no side effects — that determinism is exactly what HTTP QUERY (RFC 10008) is for: safe, idempotent, cacheable, body-carrying. POST /api/v1/stream still works identically as a deprecated fallback for clients or proxies that don’t yet support QUERY.
Minimal example
# curl example (curl 8.4+ supports -X QUERY natively; older curl can send it via --request)
curl -sS -X QUERY https://api.basis1.tech/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",...
# still works, deprecated: -X POST instead of -X QUERY
Legacy .xls & CSV
The native codec only reads xlsx/xlsm directly. For legacy .xls (BIFF/OLE2) and CSV-shaped plain text, the server falls back to a server-side conversion: it runs a headless LibreOffice to convert the upload to .xlsx, then re-parses the converted bytes through the same pipeline. This happens transparently — there's no separate endpoint or content-type to opt in with, and successful requests look identical to a native xlsx parse.
- Best-effort, not lossless. LibreOffice's xlsx writer doesn't guarantee byte-identical output to what Excel itself would produce — formatting, some formula edge cases, and Excel-specific extensions can differ after the round-trip. Treat conversion output as "close enough to parse," not a fidelity guarantee.
- Added latency. Conversion only runs when the native parser first rejects the format, and adds roughly 1-3s (bounded, ~15s worst case) on top of normal parse time. xlsx/xlsm uploads never pay this cost.
- CSV detection is heuristic. There's no CSV magic byte, so plain-text uploads that look delimiter-shaped (commas/tabs/semicolons, no binary content) are tried; genuinely corrupt or unrecognized uploads still fail with the original
422 parse_failedrather than a confusing conversion error. - Still unsupported:
xlsb,ods, and encrypted.xlsare not attempted — see Out of scope. - Google Sheets: no special handling needed — use Sheets' own File > Download > Microsoft Excel (.xlsx), which produces a file the native codec already reads directly.
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",
"sheets_url": "/v1/jobs/7e2f1c3a-.../sheets",
"error": null
}
status is one of processing, completed, failed. On failed, error is {code, message} instead of null. sheets_url is populated once status is completed — see Sheets below.
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 WEBHOOK_SECRET — a value scoped to webhook verification only, separate from the bearer token you authenticate requests with, so rotating one doesn't rotate the other. Verify it with a constant-time comparison before trusting the payload:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const given = signatureHeader.replace("sha256=", "");
return given.length === expected.length
&& timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Sheets
A large multi-sheet workbook doesn’t have to be buffered whole to get at one sheet. Once a job is completed, its sheets_url lists each sheet, and each sheet has its own resumable NDJSON stream — the same shape as the job stream, just scoped to one sheet.
# GET /v1/jobs/7e2f1c3a-.../sheets
[
{
"index": 0,
"title": "Assumptions",
"state": "visible",
"cell_count": 312,
"image_count": 0,
"stream_url": "/v1/jobs/7e2f1c3a-.../sheets/0/stream"
},
{
"index": 1,
"title": "Model",
"state": "visible",
"cell_count": 3901,
"image_count": 2,
"stream_url": "/v1/jobs/7e2f1c3a-.../sheets/1/stream"
}
]
# GET /v1/jobs/7e2f1c3a-.../sheets/1/stream — only sheet 1's own records
{"type":"sheet","index":1,"title":"Model","state":"visible",...}
{"type":"cell","sheet":1,"ref":"A1",...}
...
{"type":"image","sheet":1,"anchor":{...},...}
# disconnected after 800 records — resume without re-downloading them
GET /v1/jobs/7e2f1c3a-.../sheets/1/stream?since=800
workbook_meta describes the whole workbook, not one sheet. edge is excluded because formula references can cross sheets — an edge doesn’t belong to a single sheet's stream. image_summary and audit are workbook-wide rollups. Fetch all four from the full job stream if you need them; the per-sheet streams only ever contain that sheet’s own sheet, cell, and image records.
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 basis1’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 basis1 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 basis1 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 basis1 knows how to evaluate). |
errors | number | Recalc errors. |
unsupported | number | Formulas skipped due to unmodeled constructs. |
unsupported_breakdown | array | Reason-bucketed breakdown of unsupported: {reason, count}, sorted by count descending. Capped at 20 entries — the remainder rolls into a single {"reason": "other", ...} bucket. Empty when unsupported is 0. Cross-reference against Supported functions before integrating a formula-heavy model. |
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.
Supported functions
Every function basis1 can compile and evaluate — the same list the calc engine dispatches against, plus the parser-level forms LET and LAMBDA. A formula using anything outside this list compiles to no program and is counted in audit.verification.unsupported, with the specific construct named in unsupported_breakdown. 276 functions across 14 categories:
Aggregates (36)
AGGREGATE, AVERAGE, AVERAGEIF, AVERAGEIFS, COUNT, COUNTA, COUNTBLANK, COUNTIF, COUNTIFS, LARGE, MAX, MAXIFS, MEDIAN, MIN, MINIFS, PERCENTILE, PERCENTILE.EXC, PERCENTILE.INC, PRODUCT, QUARTILE, QUARTILE.EXC, QUARTILE.INC, RANK, RANK.AVG, RANK.EQ, SMALL, STDEV, STDEVP, SUBTOTAL, SUM, SUMIF, SUMIFS, SUMPRODUCT, SUMSQ, VAR, VARP
Logic (18)
AND, CHOOSE, IF, IFERROR, IFNA, IFS, ISBLANK, ISERR, ISERROR, ISLOGICAL, ISNA, ISNUMBER, ISTEXT, NA, NOT, OR, SWITCH, XOR
Math & trig (50)
ABS, ACOS, ARABIC, ASIN, ATAN, ATAN2, BASE, CEILING, CEILING.MATH, CEILING.PRECISE, COMBIN, COMBINA, COS, DECIMAL, DEGREES, EVEN, EXP, FACT, FACTDOUBLE, FLOOR, FLOOR.MATH, FLOOR.PRECISE, GCD, INT, ISO.CEILING, LCM, LN, LOG, LOG10, MOD, MROUND, MULTINOMIAL, NORM.DIST, ODD, PERMUT, PI, POWER, QUOTIENT, RADIANS, RAND, RANDBETWEEN, ROMAN, ROUND, ROUNDDOWN, ROUNDUP, SIGN, SIN, SQRT, TAN, TRUNC
Statistical distributions (20)
BETA.DIST, BETA.INV, BINOM.DIST, CHISQ.DIST, CONFIDENCE.NORM, CONFIDENCE.T, EXPON.DIST, F.DIST, GAMMA, GAMMA.DIST, GAMMA.INV, LOGNORM.DIST, LOGNORM.INV, NORM.INV, NORM.S.DIST, NORM.S.INV, POISSON.DIST, T.DIST, T.INV, WEIBULL.DIST
Text (31)
ASC, BAHTTEXT, CHAR, CLEAN, CODE, CONCAT, CONCATENATE, DBCS, DOLLAR, EXACT, FIND, FIXED, LEFT, LEN, LOWER, MID, NUMBERVALUE, PHONETIC, PROPER, REPLACE, REPT, RIGHT, SEARCH, SUBSTITUTE, TEXT, TEXTJOIN, TRIM, UNICHAR, UNICODE, UPPER, VALUE
Lookup & reference (11)
ADDRESS, COLUMN, HLOOKUP, HYPERLINK, INDEX, LOOKUP, MATCH, OFFSET, ROW, VLOOKUP, XLOOKUP
Date & time (22)
DATE, DATEDIF, DATEVALUE, DAY, DAYS, DAYS360, EDATE, EOMONTH, HOUR, MINUTE, MONTH, NETWORKDAYS, NETWORKDAYS.INTL, NOW, SECOND, TIME, TODAY, WEEKDAY, WORKDAY, WORKDAY.INTL, YEAR, YEARFRAC
Financial (46)
ACCRINT, ACCRINTM, COUPDAYBS, COUPDAYS, COUPDAYSNC, COUPNCD, COUPNUM, COUPPCD, CUMIPMT, CUMPRINC, DB, DDB, DISC, DOLLARDE, DOLLARFR, DURATION, EFFECT, FV, INTRATE, IPMT, IRR, MDURATION, MIRR, NOMINAL, NPER, NPV, ODDFPRICE, ODDLPRICE, PMT, PPMT, PRICE, PRICEDISC, PRICEMAT, PV, RATE, RECEIVED, SLN, SYD, TBILLEQ, TBILLPRICE, TBILLYIELD, XIRR, XNPV, YIELD, YIELDDISC, YIELDMAT
Regression & forecasting (13)
CORREL, COVARIANCE.P, COVARIANCE.S, FORECAST, FORECAST.LINEAR, GROWTH, INTERCEPT, LINEST, LOGEST, RSQ, SLOPE, STEYX, TREND
Information (8)
CELL, FORMULATEXT, INFO, N, SHEET, SHEETS, T, TYPE
Dynamic arrays (15)
CHOOSECOLS, CHOOSEROWS, DROP, EXPAND, FILTER, HSTACK, SEQUENCE, SORT, TAKE, TOCOL, TOROW, UNIQUE, VSTACK, WRAPCOLS, WRAPROWS
Lambdas & higher-order (6)
BYCOL, BYROW, LAMBDA, LET, MAP, REDUCE
SUMPRODUCT, INDEX/MATCH, VLOOKUP/HLOOKUP/XLOOKUP, SUMIFS/COUNTIFS/AVERAGEIFS, and the amortization/date-math set (YEARFRAC, RATE, NPER, MIRR, XNPV, CUMIPMT/CUMPRINC, SUBSTITUTE/FIND/SEARCH/TEXTJOIN) are all supported — check this list before assuming a gap, then check unsupported_breakdown on your actual parse to see precisely what (if anything) fell outside it. Dynamic arrays (FILTER, UNIQUE, SORT, SEQUENCE) spill, and LET/LAMBDA with MAP/BYROW/BYCOL/REDUCE evaluate as written. Still out of scope: INDIRECT (on our roadmap). One LAMBDA limitation: a LAMBDA nested inside another cannot reference the outer one's parameters — such a formula is reported in unsupported_breakdown rather than evaluated incorrectly.
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 API key. |
| 402 | quota_exceeded | The key’s usage quota for the current period is exhausted. The body names the exceeded dimension (requests, bytes_in, or cells_out); X-basis1-Quota-* headers carry the limit, used, and reset time. |
| 413 | payload_too_large | Body exceeded the max request size. |
| 422 | parse_failed | The bytes couldn’t be parsed as xlsx/xlsm, and either weren’t a format the .xls/CSV fallback attempts or the fallback conversion also failed. 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
basis1 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). - xlsb (binary), ods, and encrypted xls files — use the xlsx/xlsm variants only. Legacy (unencrypted)
.xlsand CSV are supported via a best-effort conversion fallback; see Legacy .xls & CSV.
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. QUERYsupport on/api/v1/streamis additive.POST /api/v1/streamkeeps working, unchanged, as a deprecated compatibility fallback — no consumer is forced to switch.- The Sheets endpoints are additive.
GET /v1/jobs/{id}/sheetsandGET /v1/jobs/{id}/sheets/{index}/streamare new, optional surfaces alongside the existing whole-jobstream_url;sheets_urlis a new field on job status that existing consumers can ignore.
The response header x-basis-api-version always reflects the served version. Clients can pin by path.
Pricing, SLA & compliance
basis1 is pre-GA. The following is the current state, stated plainly rather than implied:
Pricing
1 credit = 1 sheet parsed, minimum 1 credit per job. The rate is the same whether the workbook hits the native xlsx/xlsm codec or falls back to the legacy .xls/CSV conversion path — cells, formulas, edges, images, and the audit are always included; there's no separate metering for record types or an "audit mode" toggle.
| Tier | Price | Notes |
|---|---|---|
| Free | 15,000 credits / month | No card required. Enough for a real evaluation against your own workbooks, not just a demo. |
| Standard | $20 / 1,000 credits | Pay-as-you-go beyond the free tier. Billed monthly in arrears. |
| Volume | $14 / 1,000 credits | Applies automatically once usage exceeds 1,000,000 credits in a calendar month. |
This is list pricing, not a live self-serve checkout — provisioning is still through your account rep during early access. Ask for these terms in writing before building a production integration against them.
SLA
No formal SLA. The service runs best-effort with no contractual uptime or support-response-time commitment. Jobs are held in memory with a 15-minute TTL and no disk persistence — retrieve results (or rely on the webhook) before the window closes. Treat this as appropriate for evaluation and non-critical workloads until a written SLA exists.
Compliance
No SOC2 process is underway. If a compliance attestation is a gating requirement for your integration, say so — tell your account rep, since that's the kind of signal that shapes what gets prioritized next.