basis1 Stream API — v1 Quickstart Overview Jobs Records Limits Consuming #### Getting started Quickstart Why basis1 Performance Overview Sample output Authentication Request & response #### Jobs Async jobs Webhooks #### What you can build Formula-graph lineage Deal photos for vision LLMs Workbook diff ETL verification #### Records Emission order workbook_meta sheet cell image edge image_summary audit Supported functions #### Wire details Size & limits Compression Errors #### Consuming Patterns Out of scope Versioning Pricing, SLA & compliance # 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. QUERY /api/v1/stream application/x-ndjson Bearer auth · gzip on the wire 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 Why openpyxl wins on formula-heavy files: openpyxl reads a cell’s cached value and formula text, and stops there. basis1 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 basis1 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 basis1 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 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_names keeps 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 reads Purchase_Price*0.075; the matching edge record resolves that name to its real target (Model!$B$2) while still reporting the original name in named_ref. - Hidden and cross-sheet references are still first-class. A5 pulls two inputs from Scratch, a veryHidden sheet — two edge records, is_cross_sheet: true, resolved exactly as Excel would compute them. - Data tables surface as ordinary edges. The what-if table at E4 becomes two data_table_input edges (row input, column input) — a consumer that already switches on edge.kind needs 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 as unsupported rather than silently marked correct, with unsupported_breakdown naming exactly which construct (unknown function TABLE) so you don’t have to guess. staleness separately calls out the hidden sheet by name. - This particular workbook has no embedded pictures, so no image records appear above — see the image record for that shape. image_summary is still emitted (all-zero) either way, as a completion signal. ## Authentication Per-customer API key in the Authorization header. Keys look like basis_live__, 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__ ``` 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_failed rather than a confusing conversion error. - Still unsupported: xlsb, ods, and encrypted .xls are 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, staleness #### Guarantees - workbook_meta is always line 1. - Every cell for sheet N arrives before the first image for sheet N. - Every image for sheet N arrives before the first cell for sheet N+1. - All sheet / cell / image records arrive before the first edge. - image_summary is always emitted, even when the workbook contains zero images. - Audit findings emit in the order shown above. verification, plug_summary, cycles, and staleness are each emitted exactly once. #### Not guaranteed - Cell order within a sheet is column-major (A1, A2, A3, B1, B2, ...) — sort by parsing ref if 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. 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= { "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 ``` Job-level records aren’t available per-sheet. 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: - cell records by (sheet, ref) — value and formula changes, the obvious diff. - edge records 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 — compare coverage_pct and mismatches between 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. 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. 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_base64 populated, mime is browser-safe (png/jpeg/gif/bmp/webp/svg). Render directly with src="data:{mime};base64,{data_base64}". - Renderable, deferred — data_base64 is null because byte_len exceeded the inline cap. Fetch bytes out-of-band by sha256, wrap in a blob, and render via URL.createObjectURL (or equivalent). - Not browser-renderable — mime is image/x-emf, image/x-wmf, or image/tiff. Preserve the hash + anchor for reference; render a placeholder. 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. 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. 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. ## 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 Not seeing a function you need? 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: true is set but the target isn’t fetched). - xlsb (binary), ods, and encrypted xls files — use the xlsx/xlsm variants only. Legacy (unencrypted) .xls and 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 type values (or new audit.finding values); 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/stream is unchanged; POST /v1/jobs is a new, separate surface. Its 200 inline response carries two additive headers, x-basis-job-id and x-basis-job-status: completed, alongside the same x-basis-api-version header — existing NDJSON consumers can ignore both. - QUERY support on /api/v1/stream is additive. POST /api/v1/stream keeps working, unchanged, as a deprecated compatibility fallback — no consumer is forced to switch. - The Sheets endpoints are additive. GET /v1/jobs/{id}/sheets and GET /v1/jobs/{id}/sheets/{index}/stream are new, optional surfaces alongside the existing whole-job stream_url; sheets_url is 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. basis1 Stream API v1 · deterministic xlsx parsing as a service · contact your account rep for tokens