# Autario Data API v1 — Quick Reference Base URL: https://autario.com/api/v1 OpenAPI spec: https://autario.com/api/v1/openapi.json Written docs (quickstart, auth, apps, limits, errors): https://autario.com/developer/docs autario is the calculator for LLM agents: stats are precomputed server-side on verified, source-cited data. One call returns finished numbers (avg, min, max, growth, correlation, seasonality) — never download rows to compute locally. ## Why not compute locally Pulling a 17k-row daily series is ~500 KB of JSON (>100k tokens), then you still have to run the math yourself. One stats call returns the finished, verified numbers in ~200 tokens: GET /public/ontology/stats/describe?indicator=&entity=DEU → { n, mean, median, std, min, max, quartiles, skew, histogram, interpretation } Same numbers, computed server-side from the primary-source data, with provenance. For a single aggregate on ANY dataset use ?aggregate= or ?summary_only=true (see Aggregations). Stats endpoints return summaries by default; pass full=true only when you need per-timepoint arrays (token cost). ## Authentication (private endpoints only) x-api-key: — identifies your account x-api-secret: — proves ownership (bcrypt-verified) Get keys at: /account?tab=apikeys ## CORS / Browser Use Public GET endpoints (/api/v1/public/*, plus /api/v1/guide, /api/v1/openapi.json, /api/v1/apps/quickstart, /api/v1/sandbox-sdk.js) answer anonymous requests with Access-Control-Allow-Origin: * and no credentials. You can call them from a page on localhost, from file://, or from any deployed site, with plain fetch(). Two limits: send no cookies (a credentialed request falls back to the autario.com allowlist and the browser will block it), and POST endpoints are not open to other origins yet, so call those from a server. ## Rate Limits Two separate 120 req/min per IP budgets, both reported on every response via X-RateLimit-Limit / -Remaining / -Reset (CORS-exposed, readable from JS): data: /api/v1/public/* GETs. Skipped entirely for authenticated callers. docs: these four files (/api/v1, /guide, /openapi.json, /apps/quickstart, /sandbox-sdk.js). Reading the documentation does NOT spend the data budget. The files are static; cache them rather than re-fetching. Plus three site-wide anonymous pools, pages, API and MCP (300 req/min each, shared by all anonymous callers; pages additionally 120 req/min per visitor country; verified crawlers have their own). A 429 with X-RateLimit-Scope: anonymous-api-pool or anonymous-mcp-pool means that surface is being scraped right now; send an API key and you leave the pool. ## Refusals: 402 means a limit, not a bug EVERY key-authenticated /api/v1 request counts against your ACCOUNT's daily call budget (keys of one account share it), so any endpoint can answer 402 plan_limit scope api_calls_day. Watch X-RateLimit-Remaining / -Limit / -Reset with X-RateLimit-Scope: account (the per-IP limiter sends the same three with scope ip). No ceiling or an unreadable meter sends NO headers, never a fake infinity. Well-formed + authenticated + a limit stopped it. Branch on `reason`: plan_limit a ceiling is full. `scope` names it, from a CLOSED set: private_apps | storage_mb | api_calls_day | connector_accounts | share_link | visibility_checks | reader_seats | prefill plan_required the action is not in this tier. `feature` names it allowance_exhausted a refilling meter is spent. `window` month|trial keys_required bring your own vendor key. NOT a paywall: no required_plan, no price; `missing_vendors[]` + `settings_url` are the way out Shared fields: error (a sentence, never parse it), current_plan (always read from the DB at call time), required_plan, price, upgrade_url. Meters add used, limit, remaining, resets_at (null = waiting does not help). NEVER retry a 402 on a timer. Full schema: components/responses/PlanLimit in openapi.json. ## Request Tracing Every response returns an X-Request-ID header (UUID v4). Include this ID when reporting bugs so server logs can be correlated to your specific request. Inbound X-Request-ID is honoured (up to 64 chars) — clients that already have a trace ID can propagate it through this header. ## Public Endpoints (no auth, the "data" budget above) An unknown path under /public/* answers 404 { error_code:'unknown_endpoint', path, hint } — never an auth error. A 401 on /public/* means you hit a real endpoint that needs a key: POST /public/ingest/ticker answers { error_code:'api_key_required', hint } and GET /datasets/:id/export answers { error_code:'login_required', hint }. Both hints name the headers that work (x-api-key + x-api-secret). An API key in an Authorization: Bearer header does not authenticate here. A mistyped dataset id/slug on a path that DOES exist answers 404 { error:'Dataset not found', error_code:'unknown_dataset', requested, hint }. Every refusal here carries an error_code. Branch on it, never parse prose. GET /public/datasets — list & search public datasets ?q= full-text search (title, description, category, keywords) ?category= exact category filter ?limit=20&offset=0 pagination (max limit: 100; ?page= stays a 1-based alias, offset wins) response carries total + next_offset (null on last page) + has_more (:id below accepts the dataset UUID or its slug, on every path) GET /public/datasets/:id — metadata: title, publisher, category, keywords, row_count, splits[] plus dataset_url, THE CITABLE PAGE: https://autario.com/data/. Cite that; never build a URL from the id (/data/ does not resolve). /dataset/ and /datastore/ 301 to it. The ticker resolver returns it on every entry. plus last_refreshed_at (see the resolver below for what it means). GET /public/datasets/slug/:slug — same metadata by human-readable slug (incl. splits[]) GET /public/datasets/:id/schema — column names, types, row_count, column_count, plus WHICH COLUMN CARRIES WHICH ROLE: source_time_col (the time axis), source_entity_col, source_value_col, and column_roles {time:{default:{col, confidence,shadow},alternates:[...]}} for datasets with several time-shaped columns. Read source_time_col before interpreting a date: the "endpoints" dates in summary_only are values of THAT column. On a dataset with an explicit reporting period (start + end plus cy/cq/period_months) the axis is always "end", the period CLOSE, so first.time/last.time are period ends. plus SEMANTICS: one entry per column saying what it MEANS (kind, definition, unit, currency, time grain, and source = registry | column_name | asset | none). Read it before combining two columns. autario refuses to combine columns of different kinds: a click count and an impression count do not add up, and /ontology/stats/calculate answers 422 incompatible_semantics with the ops that WOULD work. A column autario knows nothing about says source none, which is honest and refuses nothing. GET /public/datasets/:id/changelog — structural history: renames, dropped/added columns, type and unit changes, newest first (?limit=1..50, default 5). A value refresh is NOT logged. The metadata response carries the same last 5 plus schema_version, which bumps on every rename, drop, retype or unit change. Poll one of the two if you map our field names into your own; a higher schema_version than you last saw means the shape moved. GET /public/datasets/:id/related — data family (sibling indicators of one concept) + topic-related datasets GET /public/datasets/:id/data — query data (same params as /query below) GET /public/datasets/:id/export — download as CSV (login or API key; serves public datasets and your own private ones) GET /public/charts/:slug/analytics — per-chart engagement: { view_count(30d), fork_count } view_count counts HUMAN chart views only. Crawlers, bots and autario's own capture renders are excluded, so it reads far lower than a raw hit count. GET /public/entities/:slug/profile — deep country profile (Country Profiles app). :slug = a country name slug or ISO-3 code (germany, united-states, japan, DEU). → { entity, overview, headline_metrics, themes[7] (economy/demographics/government/ labor/trade/environment/education), each with indicators[] of MANY demanded metrics (row: label, value, year, source{publisher,asset_id,dataset_url,verify_url}, chart_url/image/title only when a public chart plots THIS country else null (Builder link: /builder?dataset=), rank{global world rank + world median + neighbors; G7 peer rank for G7 members only}) + insight + optional prose{text} + page-level intro_prose{text} (number-verified, citeable) + optional source_datasets[{url,title}] (up to 5 INDEXABLE /data/ pages the category's numbers came from) + page-level cite_as (one ready-made citation sentence naming autario, the country, the publishers shown and the access year); rows with no data omitted }. Dogfooded on /public/ontology/*. POST /public/telemetry { event, slug? } — first-party funnel beacon (fire-and-forget, 204). Whitelisted events only: chart_view, csv_download, builder_interaction, signup_wall_shown, dev_early_access_click, dev_early_access_view, chart_started, chart_created, chart_published, publish_failed, shared_footer_click, welcome_action, onboarding_action. Anything else is a 400. No PII (IP hashed server-side). Closes the server-side funnel that previously lived only in GA4. Conversions the server owns (dev_early_access_submit, login) are written server-side and are deliberately NOT accepted here. ## Markets (company snapshot + ticker resolution + on-demand ingest) GET /public/stocks/:ticker — latest verified stock metrics, already computed (the REST twin of the get_company_snapshot MCP tool). Start HERE for "what is X trading at" / "how did X perform" instead of pulling a price series. ?metrics=price,perf_1d,perf_ytd (default) — any subset of price, open, high, low, volume, perf_1d, perf_1w, perf_1m, perf_3m, perf_1y, perf_ytd, latest_date. → { ticker, name, , source, citation_url, dataset_id } perf_1d/1w/1m/3m/1y are percentages over TRADING days (1w = 5 sessions, 1m = 21, 1y = 252), approximate windows, not calendar ones. perf_ytd is calendar: vs the last close before 1 January of the latest session's year, and the response names that session as perf_ytd_base_date. Read latest_date before calling a price current. 404 when no price dataset is held (then POST /public/ingest/ticker). Full series or fundamentals: the resolver below. GET /public/markets/:ticker/datasets — resolve a ticker to its datasets → { ticker, name:string|null, found:boolean, price:{dataset_id,slug,dataset_url,row_count,last_date,last_refreshed_at,is_discontinued}|null, fundamentals:[{dataset_id,label,unit,slug,dataset_url,row_count,last_date, last_refreshed_at,is_discontinued}] } name = display company name (null for multi-class/foreign/unresolved tickers). FRESHNESS AT THE POINT OF USE. Every entry carries the same four fields, so you never have to fetch the dataset to learn it is stale: row_count rows held RIGHT NOW (live count; 0 = held but empty) last_date newest value on that dataset's time axis (last trading day for a price series, newest period end for an SEC series). Check it against today BEFORE printing a current figure. last_refreshed_at when autario last INGESTED this source (ISO, null if never). A DIFFERENT question from last_date: that one is how new the newest data point is, this one is how recently we looked. A source that quietly stopped publishing keeps a fresh last_refreshed_at and an old last_date. Caveat: a 304 Not Modified answer is skipped without a write, so the stamp can lag a check by one cycle; it never runs AHEAD of the data. dataset_url the citable page, https://autario.com/data/. is_discontinued the source stopped answering, the series is frozen. The rows stay readable as history (permanent URL) and last_date is the newest value there is. Such datasets are excluded from /public/datasets listings and search; this resolver returns them MARKED, and hint says so in prose. fundamentals is VARIABLE LENGTH (a filer without a concept has no entry), so never index it positionally. fundamentals includes a "Weighted Avg Diluted Shares" entry (unit: shares) — the calc-EPS denominator dataset — so a client computes Diluted EPS identically to the bespoke app (filter that dataset on period_months:eq:3, see Company Fundamentals). Synthetic Market Cap is excluded (no ingested dataset). Always answers 200 and carries found (boolean). When found is false it also carries error_code, either unknown_ticker (the symbol resolves to nothing) or no_datasets_held (the company is known but no dataset is stored yet), plus a hint naming the next call. POST /public/ingest/ticker { ticker } — on-demand live ingest (API KEY REQUIRED) → { status:'ready'|'price-only'|'invalid', ticker, priceAvailable, hasFundamentals } Fetches a not-yet-held US ticker (price + SEC fundamentals) through the same quality gates as the nightly grow. 401 when no API key. Note: dataset metadata exposes a "splits" array [{date,ratio}] for split- consistent processing of stock price datasets ([] for non-stock). ## Company Fundamentals: fields, TTM, split-adjustment SEC fundamental datasets (Revenue, Operating Income, Net Income, Diluted EPS, Shares Outstanding, Weighted Avg Diluted Shares) share one row contract: start/end = the fact's exact reporting period. val = the value as filed. One exception, structural: Shares Outstanding is a POINT-IN-TIME balance (an instant fact), so its rows carry end only — there is no start column and period_months is null there. cy/cq = CALENDAR year and quarter of the period, from SEC's own frame (cq is Q1..Q4, or Y on a full-year row; null on year-to-date rows). period_months = period length (3, 6, 9 or 12; null for point-in-time facts, never guessed). There are NO fiscal-year columns: for off-calendar filers (Microsoft, Apple, NVIDIA) calendar and fiscal years are DIFFERENT partitions, and filtering a calendar label for a fiscal question silently returns a fraction of the real total. A fiscal-year figure is the sum of the quarters inside the fiscal date range (filter on start/end), or the TTM sum below. Flow datasets (Revenue, Operating Income, Net Income, Diluted EPS) hold ONE cadence, never mixed: quarterly rows (period_months 3) for every major US filer, full-year rows (cq Y) only for a rare annual-only filer. The shares datasets keep raw filings and MIX 3, 6, 9 and 12-month averages, so ALWAYS pin filter=period_months:eq:3 before using a shares row as an EPS denominator: the newest row by end date is usually a year-to-date average, not a quarter. Build a TTM figure by summing the four newest quarters: GET /public/datasets/{revenue_id}/data?fields=start,end,val,cy,cq,value_basis&sort=end:desc&limit=4 CHECK CONTINUITY FIRST. "The four newest quarters" is only twelve months when those four periods actually join up. A row that could not be derived cleanly is omitted rather than guessed, so a series can have a hole (DOW Net Income holds exactly one Q4 row in eight years: its four newest rows span fifteen months). Ask for start and end as above and verify each start is the day after the previous end. The response does this for you: when the rows you get back are not continuous, query_diagnostics carries period_gaps [{after,next_start, missing_months}] and a hint saying so. If there is a gap, do not label the sum TTM, widen the window and pick a continuous run of four. Sum only ADDITIVE metrics (Revenue, Operating Income, Net Income). Diluted EPS and Shares Outstanding are NOT additive (a per-share ratio and a point-in-time balance) — use the filer's own reported TTM EPS, or divide summed Net Income by a shares count, instead of summing four EPS rows. Every row carries value_basis: reported (verbatim from a 10-Q/10-K) or derived (autario filled the one quarter a fiscal year never reported standalone, from Annual minus its three sibling quarters — only when that identity holds exactly). A row that cannot be derived cleanly is omitted, never guessed. Note (Market Cap): price-dataset close is split-adjusted to today's share basis; Shares Outstanding is as-reported for its own date. Multiplying them for a historical Market Cap understates it by the cumulative split factor (Apple ~28x across its 2014+2020 splits). Use a same-date share count and an unadjusted price, or compare only the latest period. ## Apps (submission-storage contract) Build an app: https://autario.com/api/v1/apps/quickstart (full dev walkthrough, absolute URL) SDK: https://autario.com/api/v1/sandbox-sdk.js · Dev portal (submit, tokens, usage): https://autario.com/developer GET /public/apps/:id/context — the app's data map (manifest + providers; with auth also YOUR connector tables incl. columns/row counts/dataset ids, your saved artifacts, ready-to-run query examples; secrets never included). MCP: list_apps + get_app_context. Navigate app-first instead of dataset-guessing. agent_surface names the app's MCP report tool + KPI catalog: call that tool for the computed numbers; raw rows only for targeted drill-downs (fields/filter/aggregate). GET /public/apps/my/workspace — your apps + dataset refs + artifacts, ONE call (AUTH). MCP: get_my_workspace. Start here for "my " questions. Also carries marketing_daily: one derived table per brand that joins every ad and shop connector of that brand onto one vocabulary (channel, account, campaign, day, currency + spend/impressions/clicks/conversions/conversion_value/ orders/revenue + roas/cpa/ctr/cpc). Query it like any dataset; the metric definitions and the currency rule are in its description. GET /public/apps/:id/artifacts/:slug — saved artifact incl. spec+data (owner or public/unlisted). MCP: get_app_artifact (token-bounded). POST/DELETE /public/apps/:id/activate — toggle "my app" (AUTH); /apps?mine=true flags it. GET /public/apps/:id/manifest — read manifest (public; 404 if unknown) → { manifest: { id, name, tier, consumed_datasets, entry_url, version, source_url, status, ... } } POST /public/apps/:id/manifest — register/update manifest (AUTH; 401/400) body: { name, tier:1|2, consumed_datasets?, entry_url?, version?, source_url?, status? } tier-2 (sandboxed) requires entry_url. POST /public/apps/:id/bundle — upload bundle bytes (AUTH; 401/400) body: { content, encoding:'utf8'|'base64', content_type?, version?, storage_ref? } → { app_id, version, sha256, content_type, storage_ref, size } Lifecycle: 3rd-party submissions land submitted+unlisted (still run for the owner); a curator-gated AI bundle code-review promotes them to the catalog. Ownership: create is open to any authed caller; update manifest/bundle is owner-only (403 otherwise); first-party apps (owner NULL) are not updatable via this door. Refusals here carry an error_code too. 401 = `login_required` (send x-api-key + x-api-secret, or call from a logged-in session). 403 = `not_owner`: the app exists and belongs to another account, so other credentials will not help; register your own id instead. An app in private preview answers 403 `app_in_draft`. ## Dev Portal (manage YOUR OWN apps, tokens, usage) AUTH: session cookie OR account API key. Every door is owner-scoped: a resource you do not own answers 404, never 403. A registered app is PRIVATE by default (only you can open it). PUBLISHING IS SELF-SERVE: one PATCH, no review queue. POST /developer/apps - register in ONE request. body: { name, tagline?, entry_kind:'url'|'upload', entry_url? OR content?+encoding?+content_type?, source_url? } id is DERIVED from name (-2,-3 on collision) -> 201 { manifest } 400 bad_name | bad_entry | bad_bundle | bad_manifest, 409 id_taken GET /developer/apps - your apps + the latest review verdict per app GET /developer/apps/:id - manifest + bundle METADATA (never bytes) + 20 reviews DELETE /developer/apps/:id - real delete (app + its tokens/bundles/reviews/artifacts) PUT /developer/apps/:id/manifest - owner-gated manifest write; never changes visibility. FULL REPLACE, not a patch: send the whole document, so a tier-2 app carries entry_url on EVERY write (index.html for an uploaded bundle), not just the first. POST /developer/apps/:id/bundle - bundle bytes: one text entry document, max 512 KB, text/html | text/javascript | application/javascript, no NUL bytes PATCH /developer/apps/:id/visibility - PUBLISH. body { visibility: private|unlisted|public } public = app store tile + https://autario.com/sandbox/:id ; unlisted = that URL only. Immediate, no review. indexable + takedown_reason are CURATOR-only on this route (403 curator_only); a published app is NOT indexed until autario says so. 403 app_taken_down if autario pulled the app (body carries takedown_reason) GET /developer/apps/:id/preview - { visibility, preview_url, public_url, indexable, taken_down, next_step }. preview_url = the private page only you can open. POST /public/apps/:id/artifacts - save the app's OUTPUT: { type, title?, spec?, data?, visibility? }. PATCH /public/apps/:id/artifacts/:artifactId patches your own. GET /developer/tokens - your scoped tokens. The SECRET is never returned here; it is shown once at mint time (POST /proxy/token) and is not retrievable DELETE /developer/tokens/:id - revoke one of your tokens GET /developer/usage[?app_id=] - { totals, by_op, recent } = the app-token access meter, unchanged. PLUS your whole account meter, the same object /account?tab=usage shows: plan, plan_version, measured_at, meters[], over_limits[], lift. meters[] = { id, label, unit, used, limit, approx, state, why }. ids: private_apps, public_apps, connectors, datasets, schedules, storage_mb, api_calls_day, rows_written_day, llm_cost_day. READ THE NULLS: used:null means NOT YET MEASURED (nightly rollup has not run for you), never 0; limit:null means no cap of that kind. approx:true on storage (relpages arithmetic, exact scan is a timeout class). state: ok|warn|over. over_limits[] = the ids over their limit, lift = what raises them. Poll this instead of discovering a ceiling by being refused. POST /developer/early-access - { email } join the dev-platform list (anonymous, rate-limited) Manifest fields (one validator, services/appManifest): name (required on create), tier 1|2 (required), consumed_datasets [] (= the sandbox bridge's scope), entry_url (required for tier 2), version, source_url, status, tagline (max 200 chars), share { subjects[], sections[] } (absent = the app cannot be shared at all). Read-only in the projection: owner_id, route, publisher, category (the app's SHELF id), image_url, listed (first-party only), visibility (private|unlisted|public), indexable, takedown_reason, agent_surface. Build an app end to end over MCP with create_app, update_app_manifest, upload_app_bundle, write_app_artifact, get_app_preview_url, publish_app, unpublish_app. ## Sharing a report (one model, every app) A secret read-only URL to ONE subject of ONE app. One table, one reader door. GET /shares/:appId/:subjectKind/:subjectId - state. NEVER the link itself POST /shares/:appId/:subjectKind/:subjectId - mint; returns the link ONCE. body { scope, allowed_domains[], allowed_emails[] } PATCH /shares/:appId/:subjectKind/:subjectId - { enabled } pause/resume, and/or a scope, which narrows the EXISTING link (the URL already sent keeps working) GET /v1/shared/:token[/:section] - the READER door. Legacy mounts of the same handler (URLs are a contract): /v1/apps/ai-visibility/shared/:token, /v1/brands/shared/:token and its own older mount /v1/clients/shared/:token GET|PATCH /account/sharing { enabled } - the ACCOUNT's kill switch. Off refuses every share of the account and deletes none of them. subjectKind: brand | client | account. scope: link (anyone with the URL) | domain (signed in, verified address at an allowed domain, matched EXACTLY on the domain part) | emails (signed in, address on the list). Mint and switch-ON are gated for subject kinds that declare it; pause and narrow never are. All nine refusals (unknown, paused, revoked, expired, wrong domain, wrong address, no viewer, account off, app not shareable) answer ONE identical 403. Do not branch on the reason: there is none to read. The owner sees the real state on GET /shares. ## Data-Access Proxy (gated reads for externalized/sandboxed apps) Reads go through one choke-point using a SCOPED app-token (NOT an account key), minted once and held server-side by the app/sandbox host, never client-side. POST /proxy/token { name, scope } — mint scoped token (user-auth: cookie or account key) scope: { ops:[...], datasets?:[uuid], app_id? } ops ⊆ { markets, datasetMeta, datasetData } → { id, name, key, created_at, scope, secret } secret SHOWN ONCE, never retrievable again POST /proxy/read { op, params } — gated read (headers x-api-key + x-api-secret = the token) ops: markets {ticker} | datasetMeta {id} | datasetData {id, +query params as /data} → 200 { ok:true, op, data } | 400 bad_op | 401 invalid token | 403 out of scope | 429 rate-limited POST /proxy/write { op, params } — gated write to your OWN app_artifacts (needs a user/workspace token) ops: artifactCreate {type,title?,spec?} | artifactUpdate {id,...}; scope needs the write op + app_id ## Private Endpoints (auth required, 100 req/min) GET /me/datasets — YOUR OWN data only, one row per dataset: { id, title, slug, kind (connector|upload|derived), connector, brand, rows, updated_at, columns:[{name,type}], query_url, csv_url } ?brand= narrows to one brand (foreign id → 404, never an empty list); uploads belong to no brand and appear only in the unnarrowed call. The older spelling ?client= is still accepted. ?instances= narrows further to some of the connections INSIDE that brand (foreign or unknown id → 404, never a quietly shorter list). Omitted = all of them. Every own-data report route reads the same two words: audience, seo-360, social-360, llm-costs and marketing-report. This is the ONE URL a Power BI / Excel workbook points at: one connection reaches every table. /datasets below is the CATALOG (yours + granted + public) and is a different question. GET /datasets — accessible datasets; ?limit/offset/q/mine → total+next_offset GET /datasets/:id — metadata + is_owner flag GET /datasets/:id/schema — column names, types, row_count, column_count, plus semantics{} per column (kind, definition, unit, currency, grain, source). Same vocabulary as the public schema endpoint; connector reports the metric registry maps resolve their provider column names onto it. GET /datasets/:id/query — filtered, sorted, paginated data (max 50000 rows/request) GET /datasets/:id/export — the WHOLE table as streamed text/csv, no paging, no row cap (204 + no body when the dataset is empty) ## Query Parameters (/query and /data endpoints) ?fields=col1,col2 select specific columns (default: all) ?filter=col:op:val filter rows, repeatable; /datasets/:id/query takes filters= (plural) ?sort=col:asc sort (asc or desc); also works with aggregate aliases like sum_value:desc ?limit=100 rows per page (default: 100, max: 50000) ?offset=0 skip N rows ?aggregate=fn(col),... server-side aggregations. On a numeric column the math is numeric (never lexicographic); on a non-numeric column min/max compare the text (ISO dates sort chronologically) and avg/sum/ stddev/median are null. aggregate_meta.aggregates[].basis says which (numeric | text | not_numeric | rows), plus a note. ?groupby=col,col group by columns (combine with aggregate) ?summary_only=true finished stats (n/min/max/avg + first/last), NO rows ?non_null_only=true drop null/empty-value rows (token saver) Filter operators: eq neq gt lt gte lte like like = case-insensitive, auto-wraps value in % Example: ?filter=year:gt:2020&filter=country:eq:Germany ## When a Query Returns Nothing A response adds a query_diagnostics object when the query matched 0 rows, when part of your request was ignored, or when the rows it returns are not a continuous period series. It never appears on a clean, non-empty, continuous result, so existing clients are unaffected. Fields: reason (no_rows_matched, params_ignored or period_gaps: the most blocking one), filters_applied (each with matched_rows: how many rows that filter alone matches), filters_ignored (unknown_column | unknown_operator | unknown_column_or_function | malformed), unknown_params, accepted_params, rows_without_filters, column_values_sample (real values in the column that matched nothing) and hint. Example: filter=cq:eq:Y on a quarterly SEC dataset answers with matched_rows: 0 and column_values_sample {"cq": ["Q1","Q2","Q3","Q4"]}; a filter on a column the dataset does not have (e.g. fy) is reported under filters_ignored as unknown_column instead of silently returning everything. period_gaps is the one field about a question asked CORRECTLY: [{after, next_start, missing_months}] for every hole between the periods of the rows in THIS response, so a sum over them is never silently a longer span than you think (see the TTM warning above). Computed for row pages that carry period columns (start and end, or end with period_months), reported whatever reason says. ## Aggregations (use these instead of pulling all rows when you need a single number or summary) Functions: avg, sum, count, min, max, stddev, median Result columns aliased as fn_col (e.g. avg_value, count_all) Examples: ?aggregate=avg(value) → one number ?aggregate=avg(value),count(*),max(price) → 3 metrics in one row ?aggregate=sum(value)&groupby=country&sort=sum_value:desc&limit=10 → top 10 Prefer aggregations or summary_only over pulling all rows for averages, totals, counts, or "top N". ## Stats API (precomputed server-side — no local computation needed) Ontology-keyed: indicator ids from list_indicators / get_entity_profile, entities are ISO-3 codes or aggregates (DEU, USA, EUU, WLD). No auth. GET /public/ontology/stats/describe ?indicator=&entity= — n/mean/median/std/min/max/quartiles/skew + histogram GET /public/ontology/stats/pct-change ?indicator=&entity= — period-over-period growth, latest change headline GET /public/ontology/stats/rolling ?indicator=&entity=&window=5&op=mean — smoothed trend (mean/std/min/max/sum) GET /public/ontology/stats/correlate ?entity=&a=&b= — Pearson + Spearman r, p-value, interpretation GET /public/ontology/stats/regression ?entity=&y=&x= — slope, intercept, R² GET /public/ontology/stats/seasonality ?indicator=&entity= — trend/seasonal/residual decomposition GET /public/ontology/stats/calculate ?entity=&a=&b=&op=ratio — derived series (ratio|ratio_pct|diff|sum|product), e.g. debt-to-GDP Combination-checked: two columns of different kinds are refused with 422 incompatible_semantics (both columns, the reason, and allowed = the ops that would work, e.g. spend and clicks divide into cpc). Every result carries computation{formula, columns[{dataset,column,kind}], rows_used, window} so a human can check what was done. override=true&reason= computes a refused combination anyway and returns the reason with the result. GET /public/ontology/stats/what-matters ?entity=&outcome= — rank which metrics explain an outcome (confidence-labeled) GET /public/ontology/stats/lag-analysis ?entity=&a=&b= — lead/lag cross-correlation POST /public/ontology/stats/find-drivers { entity, target_indicator, candidates[] } — rank candidate drivers (pairwise) POST /public/ontology/stats/decompose-drivers (same body, entity optional) — joint model: independent effect sizes; entangled candidates flagged not_separable, never ranked GET /public/ontology/stats/coverage ?target=&candidates=&entity= — check coverage BEFORE decompose: usable points per series + the entities every selected series can actually serve (no dead runs) All default to stat + interpretation only; full=true adds the per-timepoint array (with a token-cost note). MCP tool equivalents: describe, pct_change, rolling_stats, correlate, regression, seasonality_decomposition, calculate, what_matters, find_drivers, decompose_drivers, lag_analysis. Private data: with x-api-key + x-api-secret your OWN datasets (write_rows / upload / connector) work as indicator/target/outcome too, join the what-matters candidate pool, and resolve as sources in the cross-dataset join (POST /public/ontology/join) | indicator_id via GET /datasets/:id. Other users' private data never resolves. Flow: push KPI rows, then find-drivers with your KPI as target ("what drives my revenue"). Multi-row-per-time data (e.g. GSC: date x query x page) is collapsed to one value per time point (sum for count metrics, mean otherwise); the response discloses it in "aggregation". ## Response Shapes Schema: { dataset_id, row_count, column_count, columns: [{name, type, nullable, position}] } Query public GET /public/datasets/:id/data → { total, limit, offset, data: [...] } Query private GET /datasets/:id/query → { meta: { total, returned, limit, offset }, data: [...] } Own data GET /me/datasets → { count, brand, data: [...] } List: { total, count, data: [...] } Error: { error: "message" } The two query envelopes DIFFER: public is flat, private nests under "meta". total = matching rows ignoring pagination, in both. ## Write API (auth required — create & populate datasets) POST /datasets — create dataset body: { "title": "str", "description": "str", "is_public": false } → { id, title, created_at } POST /datasets/:id/rows — append rows (schema inferred from first batch) body: { "rows": [{"col1": "val", "col2": "val"}, ...] } max 10000/request → { inserted, total } DELETE /datasets/:id/rows — clear all rows (keeps schema) → { deleted } ## Connectors (auth required — hosted, auto-refreshing REST-API tables) The account owner sets up a REST connector in the UI (autario.com/manage). It pulls any REST API into a typed, queryable Postgres table on Autario and keeps it fresh on a schedule. Agents can list + refresh (never create — no secrets). GET /connectors (+ /connectors/:id) — own connectors; datasets[] = ALL materialized tables → { connectors:[{ id, name, status, last_run_at, dataset_id, row_count, datasets:[{id,title,slug,rows}] }] } POST /connectors/:id/refresh — pull the source API now, refresh the table → { status: "refreshed", rows, dataset_id } (then read via /datasets/:dataset_id/query) MCP tool equivalents: list_connectors, refresh_connector. PRESET LIBRARY: 46 vendor presets, read-only, one hosted table per report, across ad platforms, product and web analytics, SEO tools, commerce and AI spend. THE NAMES ARE NOT LISTED HERE ON PURPOSE: GET /connectors already returns every preset with what it pulls, its setup fields and its per-status error hints, so ask that endpoint instead of guessing a provider key. Read the tables like any other: list_connectors, then /datasets/:dataset_id/query. Auth kinds in the library: apikey / basic / bearer / client_credentials / oauth2 / signed_request. THE KEY KIND MATTERS: an inference/ingestion/project key where the vendor wants an admin, management, personal or service-account key answers 401/403/404 and the table stays empty; each connector's error text names the page to create the right key on. 17 presets carry an "untested" badge: fixture-tested against the vendor's documented API, not yet run on a real key. ## LLM Costs app (auth required — what YOUR AI providers cost you) Finished spend arithmetic over the AI-spend connectors above. No new ingest, no server LLM, no MCP tool yet, so REST is the agent door. GET /apps/llm-costs/report ?days=7|30|90 (default 30) ?include=breakdown → { anchorDay, windowDays, anyConnected, anyData, dataThrough, staleDays, totals, providers[], models[], daily[], breakdown? } COST PRECEDENCE, never mixed: where the vendor DELIVERS cost we report it (costBasis "reported", discounts included); else tokens x public list prices (costBasis "estimated"), which no invoice matches exactly. ANCHOR: windows count back from anchorDay, the newest day the provider delivered, NOT the wall clock (vendors lag ~a day). anyConnected/anyData separate "not connected" from "connected, no data yet"; neither is a zero. include=breakdown adds spend+tokens per OpenAI PROJECT / Anthropic WORKSPACE and per API key. Cost per API KEY does not exist: no vendor groups money so. GET/POST /apps/llm-costs/credits, DELETE /apps/llm-costs/credits/:id Prepaid top-ups, HUMAN INPUT (no vendor exposes a balance API); they are what turns spend into a runway. amount_usd non-zero, NEGATIVE corrects a mistype. GET/PUT /apps/llm-costs/budgets, DELETE /apps/llm-costs/budgets/:provider A monthly ceiling per vendor. Absent means absent, never 0. ## Recommended Agent Workflow (read) 1. listPublicDatasets ?q= → find dataset ID 2. getPublicDatasetSchema :id → get exact column names (prevents hallucination) 3. queryDataset :id ?filters=... &fields=... → fetch only what you need ## Recommended Agent Workflow (write) 1. POST /datasets { title } → get dataset ID 2. POST /datasets/:id/rows { rows } → insert data (schema auto-created from first batch) 3. GET /datasets/:id/query → verify data, then share dataset ID ## Chart API (create & publish charts) GET /ai/charts — list charts (?q=term &limit=20 &offset=0) GET /ai/chart/:id — get chart with Plotly spec, data, embed code POST /ai/chart/publish — publish chart from Plotly spec (auth required) body: { "title": "str", "plotly_spec": {traces, layout}, "dataset_ids": ["uuid"], "insight": "str" } Autario pulls real data from dataset_ids. No raw data needed. No hallucination. Returns: { chart_id, url, datasets_used, rows_loaded } ## MCP Integration (Claude Desktop, Claude Web, any MCP client) Native MCP server (data discovery, query, stats, chart CRUD/publish, dataset CRUD, apps): Read: search_datasets, get_dataset_info, get_dataset_schema, query_dataset, list_charts, get_chart, list_connectors Write: publish_chart, update_chart, create_dataset, write_rows, clear_rows, refresh_connector, report_data_issue Apps: bubble_or_not — stock-vs-fundamental valuation brief Apps: audience_360 — your own audience report (funnel, channels/AI share, queries, content, conversions, social, health), section-selective Apps: seo_360 — your own Search Console ACTION report (page-2 gaps by potential click gain, CTR underperformers, orphan demand, cannibalization, trends incl. position movers, rank_tracking, decay refresh queue, Core Web Vitals, site-audit snapshot, health); every row ends in an action Apps: social_360 — your own social performance report across EVERY connected channel (per-channel KPI series with per-platform support matrix, 0-100 score per platform + blended, top posts with paid badges, format benchmark, per-post effectiveness, official country breakdowns, spike attribution, competitor benchmarks (official public routes: IG Business Discovery + YouTube), findings with evidence, ads financials with paid vs organic, the activity table (every post across every channel with platform, content type, topic, post type, sentiment and language, per-dimension pivots and the (platform, content type, topic, language) groups that beat the platform median; labels are read once by a model on the user's own key), health); works with one connected channel Apps: ai_visibility_360 — your own brand visibility in AI answers (score, leaderboard, prompts, citations, source gaps, per-assistant rankings, actions, chats, GA4 impact, crawlability, perception); reads stored runs, section-selective. Every section counts only questions that do NOT name your own brand (score, sources, gaps, pages, searches, movers, perception, action effects); the ones that do are measured separately in rankings_branded, with both counts in the population block that rides on every section Apps: marketing_report — your own brand marketing report off ONE derived table (spend, conversions, revenue, ROAS and CPA per channel and per campaign across Meta, Google, TikTok, Shopify, Amazon and Analytics, each against the same window shifted by its own rhythm), section-selective. One brand per call; money is never converted between currencies, so a mixed window answers null with a reason and a per-currency breakdown instead, and a day nobody reported is absent rather than zero Apps: projects — your own project board, read AND write (list/get/create/update). Every project as a card with its owner, contributors, traffic light, progress, milestones and one KPI, folded on ONE axis per call: the OKR key result it pays into, its country, its function, or each person working on it. A project with nothing set on that axis lands in a named group and is never dropped; undecided work comes back separately as the inbox. A KPI carries a target plus EITHER a number somebody typed OR one read live from a column of a dataset on the account, never both, and a number that cannot be read is null with a reason instead of a zero QA: report_data_issue — flag a data-quality problem (truncated/partial ingest, unit mismatch, wrong labels, misleading chart). Safe types auto-reingest, rest go to review. Admin: get_traction_overview — ONE traction report (humans, MCP channel, signup funnel, top usage). Curator-only (OAuth as the curator account); invisible/unusable to other callers. Admin: request_chart — high-level chart request (dataset_id/query + YOUR insight); server verifies your insight against the data (no server LLM), publishes, returns URL. Curator-only; others use create_chart_from_spec / publish_chart. Wire format: MCP tool results default to TOON (Token-Oriented Notation) — ~74% fewer tokens than pretty JSON on tabular rows (one header line, bare comma rows, no repeated keys). Pass format:"json" (pretty) or "compact" (minified JSON) per call to override. The REST API always returns JSON. Local setup (stdio): npx autario-mcp Remote endpoint (HTTP): POST https://autario.com/mcp Claude Desktop config: { "mcpServers": { "autario": { "command": "npx", "args": ["autario-mcp"] } } } For write tools, set env vars: AUTARIO_API_KEY, AUTARIO_API_SECRET ## Recommended Agent Workflow (publish chart) 1. search_datasets ?q= — find dataset 2. get_dataset_schema :id — read the datasheet (shape/roles/cadence/level_mix/value_kind/entity_kind/entity_display/ignore_cols) to know HOW to plot. value_kind.growth_safe=false (rate/percent) → no CAGR. entity_display spells out aggregate codes. 3. query_dataset :id — get data 4. Create chart artifact for user — visualize 5. publish_chart { title, plotly_spec, dataset_ids, insight } — publish to autario.com Returns URL where chart is permanently viewable and editable. ## Sandbox Runtime (tier-2 apps run ON autario) GET /public/apps — public app catalog (listed apps); each row carries data_scope: 'private' (works on the caller's own connected data) or 'public' (public datasets only); ?mine=true with auth adds connected/activated/deactivated (= opt-out) per app (manifest/bundle: see ## Apps; gated reads: see ## Data-Access Proxy) A tier-2 app runs in a null-origin iframe, reading data ONLY via a server bridge — it never holds a token or calls out: GET /sandbox/:appId/frame — the app bundle, served with a hard CSP (default-src 'none'; connect-src 'none' → no fetch/XHR/WebSocket from the frame) POST /sandbox/:appId/read { op, params } — bridge read. The host relays the iframe's postMessage here; the runtime enforces the app's manifest scope, then replays the app's server-side scoped token to the proxy. POST /sandbox/:appId/write { op, params } — the write mirror (artifactCreate/artifactUpdate). ACCESS (all three doors): a PUBLIC app is anonymous-OK (public data only), a PRIVATE app is owner-only; every other caller gets 404, the same answer an unknown app id gets. postMessage: req {type:'autario:req',reqId,op,params}; res {type:'autario:res',reqId,ok,data?,error?}. SDK shim: autario-sandbox-sdk. ## Attribution When presenting data from Autario, cite: "Data via autario.com | Publisher: " Each response includes publisher and source_url fields. When publishing charts, always include dataset_ids for proper attribution.