Base path /api. Same-origin only — no CORS. Every failure returns
{ "error": string, "code": string } with an appropriate status; internals and
stack traces never reach the client.
Optional. Send x-anthropic-key: sk-ant-... to enable AI features. Endpoints
that only compute deterministic results work without it. Endpoints that exist to
call the model (/ask, /explain) return 401 missing_api_key without one.
A malformed key is rejected with 400 invalid_api_key_format before any
provider call, rather than being silently ignored.
x-ridge-session: <8-64 url-safe chars> groups saved analyses. Only required
when opting into persistence or deleting a saved analysis.
{
"status": "ok",
"serverKey": false,
"models": [{ "id": "claude-sonnet-5", "label": "Claude Sonnet 5", "note": "balanced capability, speed and cost" }],
"defaultModel": "claude-sonnet-5"
}serverKey reports whether the deployment has a fallback key configured. It
never returns the key.
multipart/form-data, one file. Runs the deterministic pipeline; adds AI
interpretation only when a key is present.
| Field | Type | Notes |
|---|---|---|
file |
file | Required. See supported extensions in the README. |
question |
string | Optional, ≤ 2000 chars. |
model |
string | Optional; one of the ids from /health. |
sheet |
string | Optional worksheet name, ≤ 128 chars. |
target |
string | Optional column to focus evidence on. Must exist in the file. |
headerRow |
integer | Optional, 1-indexed. Overrides the detected header row. A row past the end of the file is not an error — detection runs as it would have, and the request is reported in meta.structure.unapplied with correction: "headerRow", which makes the reading uncertain. |
includeRows |
JSON array | Optional, 1-indexed source rows to put back after they were excluded as aggregates. A row that matches no aggregate exclusion is not an error — it is reported in meta.structure.unapplied with the reason, and makes the reading uncertain. |
save |
"true" | "false" |
Optional. Persistence happens only when explicitly true. |
Response:
Errors: no_file, unsupported_file_type, upload_too_large (413),
empty_file, invalid_sheet, invalid_target, unknown_target,
question_too_long, unsupported_model, invalid_api_key_format.
Every API response includes an X-Request-ID header. JSON error bodies echo it
as requestId so an operator can correlate a user-visible failure without
logging file contents, questions, or API keys.
application/json. Same response shape. Fetches the file server-side, so the
4 MB request cap does not apply — remote files may be up to 25 MB.
{ "url": "https://example.com/data.xlsx", "question": "", "model": "",
"sheet": "Q1", "target": "revenue", "save": false }Only https:// is accepted. Private, loopback, link-local and CGNAT addresses
are rejected before and after DNS resolution and at every redirect hop.
multipart/form-data, up to 10 files. The aggregate size of all files must
stay under 4 MB, enforced independently of the per-file limit.
{
"files": [ /* one analyze-shaped result per file, with `error` set on failures */ ],
"crossSummary": null, // AI cross-file synthesis; requires a key and 2+ AI-analyzed files
"totalFiles": 3, "successCount": 3,
"analysisId": null, "saved": false
}A file that fails to parse does not fail the batch; its entry carries error.
multipart/form-data, exactly two tabular files. The first file is the
baseline and the second is the current version. The endpoint is deterministic
and never invokes an AI provider; an API key is neither required nor used.
{
"kind": "comparison",
"meta": { "comparisonVersion": "1.1.0", "schemaVersion": "2.11", "saved": false },
"files": [ /* compact deterministic profile for each file */ ],
"comparison": {
"deterministic": true,
"labels": { "baseline": "june.csv", "current": "july.csv" },
"summary": { "rowDelta": 120, "healthScoreDelta": 4, "sharedColumns": 8 },
"schema": { "added": [], "removed": [], "shared": [], "typeChanges": [] },
"quality": { "baseline": {}, "current": {}, "deltas": {} },
"columns": [ /* descriptive deltas plus Welch mean and two-sample KS inference */ ],
"findings": [ /* thresholded, deterministic material changes */ ]
},
"analysisId": null
}Set save=true to opt into persistence. Errors include
comparison_requires_two_files and comparison_requires_tabular.
Adds AI interpretation to results the deterministic pipeline already produced, so a keyless analysis can be explained without re-uploading.
{ "model": "", "question": "",
"context": { "filename": "", "fileType": "spreadsheet", "columns": [],
"stats": {}, "correlations": [], "evidence": [], "profile": {},
"sampleRows": [], "rawText": "" } }Returns { "analysis": { ... }, "model": "claude-sonnet-5" }. Context is
size-capped per part; oversized parts degrade rather than inflating token spend.
Follow-up question against an already-analyzed dataset.
{ "question": "Why did June rise?", "model": "",
"priorQA": [{ "q": "...", "a": "..." }],
"context": { "columns": ["revenue"], "stats": {}, "correlations": [] } }Returns { "answer": string, "model": string }. Errors: missing_question,
invalid_context, missing_api_key.
| Endpoint | Behaviour |
|---|---|
GET /history?session=<id> |
{ enabled, items[] }. Returns { enabled: false, items: [] } when unconfigured. |
GET /analysis/:id |
One saved analysis by uuid. 404 not_found if absent, 400 invalid_id if malformed. |
DELETE /history/:id |
Deletes a saved analysis. Requires x-ridge-session matching the session that saved it; a share link alone does not grant deletion. |
Per IP, per warm instance, in memory.
| Endpoint group | Default | Override |
|---|---|---|
/analyze* |
10/min | RATE_LIMIT_POINTS |
/ask, /explain |
20/min | RATE_LIMIT_ASK_POINTS |
Exceeding a limit returns 429 rate_limited with a Retry-After header.
Every failure returns { "error": string, "code": string, "requestId": string }.
code is the stable identifier — match on it rather than on the message, which
is written for a person and may be reworded. The reference below is complete:
tests/api-docs.test.js fails if the server can emit a code this table does not
list.
| Code | Status | Meaning |
|---|---|---|
no_file |
400 | No file was attached to the upload. |
empty_file |
400 | The file parsed but held no rows and no text. |
unsupported_file_type |
400 | The extension is not one of the supported kinds. |
file_too_large |
413 | One file exceeds the 4 MB per-file limit. |
upload_too_large |
413 | The files together exceed the 4 MB request budget. |
too_many_files |
400 | More than 10 files in one multi-file request. |
unexpected_field |
400 | A file arrived under a field name the route does not accept. |
upload_error |
400 | The upload failed for another reason reported by the parser of the multipart body. |
invalid_json |
400 | A JSON request body did not parse. |
parse_failed |
422 | The file matched a supported extension but could not be read. |
pdf_timeout |
422 | A PDF took longer than 45 seconds to read and was abandoned. |
| Code | Status | Meaning |
|---|---|---|
invalid_question |
400 | question was not a string. |
question_too_long |
400 | question exceeds 2000 characters. |
invalid_sheet |
400 | sheet was not a string, or exceeds 128 characters. |
unknown_sheet |
400 | The workbook has no sheet of that name; the available names are listed in the message. |
invalid_target |
400 | target was not a string, or exceeds 200 characters. |
unknown_target |
400 | target names a column the file does not have. |
invalid_columns |
400 | columns was not a JSON array of column names, or was too long. |
unknown_column |
400 | columns names a column the file does not have. |
no_columns_selected |
400 | The selection resolved to nothing to analyze. |
invalid_header_row |
400 | headerRow was not a positive row number. |
invalid_include_rows |
400 | includeRows was not a JSON array of positive row numbers, or was too long. |
unsupported_model |
400 | model is not one of the ids /health returns. |
invalid_context |
400 | /ask or /explain was called without usable dataset context. |
missing_question |
400 | /ask was called without a question. |
comparison_requires_two_files |
400 | /compare needs exactly two files, baseline first. |
comparison_requires_tabular |
400 | A file given to /compare could not be read as a table. |
A headerRow past the end of the file is not an error: detection runs as it
would have, and the request is reported in meta.structure.unapplied.
| Code | Status | Meaning |
|---|---|---|
missing_api_key |
401 | An AI endpoint was called with no key configured or supplied. |
invalid_api_key_format |
400 | The key does not begin with sk-ant-. Rejected before any provider call. |
invalid_api_key |
401 | Anthropic rejected the key. |
model_not_allowed |
403 | The key has no access to the selected model. |
| Code | Status | Meaning |
|---|---|---|
upstream_rate_limited |
429 | The Anthropic account hit a rate limit. |
upstream_bad_request |
400 | Anthropic rejected the request; its reason is included. |
upstream_unreachable |
502 | The Anthropic API could not be reached. |
upstream_error |
502 | Anthropic returned an error. |
analysis_refused |
422 | The model declined to analyze the content. |
answer_refused |
422 | The model declined to answer the question. |
analysis_truncated |
502 | The analysis hit the output limit before finishing. |
analysis_unparseable |
502 | The model's structured output did not parse. |
empty_answer |
502 | The model returned no text. |
| Code | Status | Meaning |
|---|---|---|
invalid_url |
400 | Not a valid URL, or one carrying embedded credentials. |
unsupported_protocol |
400 | Only https:// is fetched. |
private_host |
400 | The URL resolves to a private, loopback, link-local or CGNAT address. Re-checked after DNS and after every redirect. |
dns_failed |
400 | The host did not resolve. |
too_many_redirects |
400 | More than 3 redirects, or a redirect with no location. |
fetch_failed |
502 | The fetch failed, or the URL returned a non-2xx status. |
fetch_timeout |
502 | The fetch exceeded 20 seconds. |
| Code | Status | Meaning |
|---|---|---|
history_disabled |
404 | Supabase is not configured on this deployment. |
history_unavailable |
502 | The store could not be reached. |
invalid_id |
400 | The analysis id is not a uuid. |
not_found |
404 | No such analysis — also returned for one that has passed its retention window, so a share link to an expired analysis discloses nothing about what was once there. |
missing_session |
400 | Deleting requires x-ridge-session. |
| Code | Status | Meaning |
|---|---|---|
rate_limited |
429 | Per-IP limit exceeded. Carries Retry-After. |
internal_error |
500 | An unexpected failure. The message is deliberately generic; correlate with requestId in the server log. |
{ "meta": { "filename": "team-sales.csv", "totalRows": 91, "columns": 7, "fileType": "spreadsheet", "isTabular": true, "sheetName": "Sheet1", "sheets": ["Sheet1"], "model": "claude-sonnet-5", "aiIncluded": false, // whether an AI interpretation is present "target": "revenue", // echoed back, or null "saved": false, // whether this response was persisted "requestId": "92a...", // also returned in the X-Request-ID header "generatedAt": "2026-08-01T18:30:00.000Z", "processingMs": 42, // How the file was read, before anything was computed. Absent on analyses // saved before schema 2.7 — which means the question was never asked, not // that nothing was excluded. "structure": { "headerRow": 3, // 1-indexed, as the user sees it in Excel "headerSource": "detected", // or "specified", when headerRow was sent "confidence": "confident", // "none" | "confident" | "uncertain" "observations": 4, // rows the statistics were computed over "excluded": [ // never empty-by-omission; every set-aside row is here { "row": 1, "reason": "preamble", "confidence": "confident", "detail": "above the header row", "cells": ["Q3 Regional Revenue Report"] }, { "row": 9, "reason": "aggregate", "confidence": "certain", "label": "TOTAL", "detail": "units and revenue equal the sum of rows 4–7", "cells": ["TOTAL", "443", "183750"] } ], "restored": [], // rows put back via includeRows, still shown "unapplied": [], // corrections that did not apply, with why: // includeRows that matched no exclusion, and a // headerRow past the end of the file "alternatives": [], // other candidate header rows, when uncertain "version": "1.1.0" }, "schemaVersion": "2.11", "evidenceEngine": "1.4.0" }, // A numeric column carries `formats` only when it was written in a // spreadsheet's own notation — "currency", "thousands", "percent", // "negated parentheses". Absent means none were needed. // // `outliers.rows` (schema 2.10) are the flagged observations themselves: // `row` is the 1-based data-row number in the same row space provenance // source rows use, `side` says which fence, `beyond` how far past it. The // list is ordered by distance, capped at `rowsCap`; `count` is always the // true total. A payload without `rows` predates 2.10 — the rows were // identified but not shipped, which is not a claim that none exist. "stats": { "revenue": { "type": "numeric", "formats": ["currency", "thousands"], "validCount": 90, "missing": 1, "invalid": 0, "coverage": 98.9, "min": 100, "max": 4980, "mean": 0, "median": 0, "std": 0, "meanConfidence95": { "lower": 0, "upper": 0 }, "quantiles": {}, "histogram": { "method": "iqr-tail-aware", "bins": [] }, "outliers": { "count": 2, "method": "iqr", "applied": true, "lowerFence": 0, "upperFence": 460, "rows": [ { "row": 11, "value": 900, "side": "above", "beyond": 440 } ], "rowsCap": 200 } } }, "correlations": [ { "columnA": "revenue", "columnB": "spend", "method": "spearman", "coefficient": 0.9068, "pearson": -0.0895, "spearman": 0.9068, "n": 90, "coverage": 98.9, "strength": "very strong", "smallSample": false, "caveat": "..." } ], "profile": { "healthGrade": "B", "healthScore": 87, "issues": [] }, "evidence": [ { "claim": "...", "metric": "spearman_rho", "value": 0.9068, "columns": ["revenue","spend"], "method": "...", "sampleSize": 90, "coverage": 98.9, "strength": "very strong", "caveat": null, "statistics": null, "engineVersion": "1.4.0", "provenance": { "formula": "ρ = ...", "inclusionRule": "...", "inputRows": 91, "includedRows": 90, "excludedRows": 1, "exclusionReasons": [], "sourceRowsPolicy": "...", "sourceRows": [{ "rowNumber": 1, "values": { "revenue": 100 } }] } } ], "analysis": null, // null unless a key was supplied "chartData": [], // up to 100 rows for client-side charts "columns": ["date","region"], "rawText": null, // document excerpt for non-tabular files "analysisId": null // uuid when saved, else null }