TabulariaDocs

DocumentationIntegrations

Tabularia API

For whoever connects another system to Tabularia: a scheduler such as Airflow, a CI job, a department script. Everything the interface does goes through this API; the complete, always-current reference is the OpenAPI page the gateway serves itself, at https://<your host>/api/docs (http://localhost:8000/docs in development). This guide covers the part an integration needs and promises to keep stable. The other integrations are listed in Integrations.

Signing in: API tokens

A script authenticates with a token in the Authorization header:

curl -H "Authorization: Bearer tab_…" https://tabularia.example.com/api/auth/me

Tokens start with tab_. They come in two kinds:

  • Personal tokens, which anyone creates in Settings → API tokens for their own scripts. They act with that person's permissions. An administrator can turn them off for the whole installation (AUTH__PERSONAL_TOKENS=false): from then on, personal tokens already given out stop working too, while service account tokens keep working. Observer accounts (such as a shared demo guest) have no personal tokens: an account made an observer loses the ones it had, and a personal token never works for an account that is only an observer, however it became one (for example by losing its administrator role).
  • Service account tokens, created by an administrator in Administration → Service accounts. A service account is an integration rather than a person: it has no password and cannot sign in through SSO. It gets permissions like anyone else, from the groups it belongs to and from the folders it is granted. Use one per integration, so that revoking one integration's access does not affect the others.

Every token has:

  • An access level. Read only refuses every request that changes something (any method other than GET/HEAD/OPTIONS) with 403; the POSTs that only read, POST /tasks/export (with POST /tasks/exports), POST /tasks/preview, POST /reports/{id}/render, POST /dashboards/{id}/data and POST /dashboards/{id}/render, are allowed. Read and write can do whatever its account can do. A token never adds permissions: it is at most as powerful as its account.
  • An expiry of at most AUTH__API_TOKEN_MAX_DAYS days, one year by default.
  • A secret that is shown once, at creation. Tabularia keeps only its SHA-256 fingerprint and its first characters, so you can recognise it in a list.

Tokens are revoked from the same screens: the owner revokes their own, an administrator revokes any token from Administration → Service accounts → All API tokens. A revoked or expired token gets 401 on the next request, and so does any token of a deleted account. Deactivating an account or setting a new password for it revokes its tokens along with its sessions; reactivating the account does not bring them back. Every action records in the audit log the name of the token that did it, never the token itself. Tokens travel only in the header; one placed in a cookie is ignored.

A token cannot create other tokens, create users, set passwords, reactivate an account, or make an account an administrator or an observer (by its flag, a group's flag or joining such a group). So revoking a leaked token is enough to close the door: it cannot have left behind another way in. What it did with its permissions stays done, as with any account: after revoking a leaked administrator token, look in the audit log for the actions carrying its name (a webhook it created, a schedule it changed).

Example: a token for a scheduler

An external scheduler (Airflow, a CI job) runs the sample flow «Top 20 Products by Revenue» every night. As an administrator:

  1. Administration → Service accounts, name Airflow production, Create account. The account gets an address of its own (airflow-production@service.invalid) and no password.
  2. Give it what the job needs, as to a person: RUN on Sample / Flows, where the flow is, and EDIT on Sample / Private tables, where its Output writes (in each folder's Folder settings, or through a group). Without EDIT there the run starts and then fails with «Missing 'edit' permission on folder …».
  3. Open the account, name the token nightly runs, Access Read and write, Expires 90 days, Create token. Copy the secret now: it is shown once.

The service account Airflow production opened, with the form for a new token: name, access Read and write, expiry 90 days

A token is created on the account it acts as; its access and its expiry are chosen here.

The account with its token nightly runs, read and write, expiring in 90 days, never used yet

After Create token the list keeps only the first characters of the token, to recognise it, and when it was last used.

Then, from the scheduler (the flow's id is in its editor address, /editor?flow=6):

curl -X POST -H "Authorization: Bearer tab_…" \
  https://tabularia.example.com/api/flows/6/run-now
# 202 {"status": "started", "flow_id": 6, "run_id": 1318}

curl -H "Authorization: Bearer tab_…" https://tabularia.example.com/api/runs/1318
# {"id": 1318, "status": "SUCCESS", "flow_id": 6, "launched_by": 206, …}

The run appears in Runs started by Airflow production, and the audit log names the token. To cut the job off, revoke the token: the next call gets 401.

Stable endpoints

The paths below are a contract: within a major version they keep their meaning and their fields. New fields can appear, so ignore the ones you do not know. Anything else in /docs may change between minor versions. Breaking changes are announced in CHANGELOG.md one minor release in advance.

WhatRequestNotes
Who am IGET /auth/methe account behind the token
FoldersGET /projectsthose you can see
FlowsGET /flows, GET /flows/{id}
Run a flowPOST /flows/{id}/run-now202 with {"run_id": …}; 409 if it is already running. ?mode=production uses the flow's production engine, as a schedule would
A runGET /runs/{id}status: PENDING, STARTED, SUCCESS, FAILURE; error when it failed
RunsGET /runs?q=…&status=…&limit=…&offset=…a page: {"items": […], "total": n}
DatasourcesGET /datasourceseach with bucket and key, which the export needs; GET /datasources/{id}/runs for its refreshes
Refresh a datasourcePOST /datasources/{id}/refresh202 with the run; database datasources only
ExportPOST /tasks/exportCSV (streamed) or Excel; see below
Download a reportGET /reports/{id}/render?format=pdf&<parameter>=<value>the file, waited for; see Download a report
ReportsGET /reports, GET /reports/search?q=…&sort=trending, GET /reports/{id}/parametersthose you can see (all, or a page searched and ordered as the Reports tab: trending, name, updated); a report's parameters with their defaults and choices
DashboardsGET /dashboards, GET /dashboards/search?q=…&sort=trending, GET /dashboards/{id}, GET /dashboards/{id}/parametersthose you can see, and as the Dashboards tab; see Dashboards
A dashboard's numbersPOST /dashboards/{id}/datathe numbers of its tiles, ready to draw
Download a dashboardGET /dashboards/{id}/render?format=pdf&<parameter>=<value>&click.<tile>=<value>the file, waited for
LineageGET /lineagethe graph of flows, datasources, connections, scripts, SQL scripts, notebooks and flow groups; ?type=flow|datasource|connection|script|sql|notebook|group&id=… centres it

All paths are relative to the gateway: https://<host>/api behind the reference ingress or compose file, http://localhost:8000 in development.

Recipes

Run a flow and wait for the result:

import time, requests

API = "https://tabularia.example.com/api"
H = {"Authorization": "Bearer tab_…"}

run_id = requests.post(f"{API}/flows/42/run-now", headers=H).json()["run_id"]
while True:
    run = requests.get(f"{API}/runs/{run_id}", headers=H).json()
    if run["status"] in ("SUCCESS", "FAILURE"):
        break
    time.sleep(5)
if run["status"] == "FAILURE":
    raise SystemExit(run["error"])

Polling is fine for a script. For anything long-lived, have Tabularia call you instead (webhooks, below).

Refresh a database datasource:

curl -X POST -H "Authorization: Bearer tab_…" https://tabularia.example.com/api/datasources/7/refresh
# 202 {"id": 1234, "status": "PENDING", …}: then GET /runs/1234 as above

Download a datasource as CSV:

ds = next(d for d in requests.get(f"{API}/datasources", headers=H).json() if d["id"] == 7)
body = {"bucket": ds["bucket"], "input_key": ds["key"], "operations": [], "format": "csv", "filename": f"{ds['name']}.csv"}
with requests.post(f"{API}/tasks/export", headers=H, json=body, stream=True) as r:
    r.raise_for_status()
    with open(body["filename"], "wb") as f:
        for chunk in r.iter_content(1 << 20):
            f.write(chunk)

CSV exports stop at EXPORT__MAX_ROWS (5,000,000 rows by default) and Excel exports at 1,048,575 rows; the response says so instead of cutting silently.

A person, or a service account, has at most EXPORT__MAX_RUNNING_PER_PERSON (3 by default) exports and documents being prepared at once. A request with an API token past it does not fail: it waits for one of them to finish, up to REPORTS__API_WAIT_SECONDS (POST /tasks/export as long as it waits for a worker), and answers 429 only when none did. A script that sends ten reports by email, one after the other or a few at a time, gets them all.

A worker writes the file, and POST /tasks/export keeps the request open until it has. For a long export, start it and come back for it instead, as the web app does: POST /tasks/exports with the same body answers {"task_id": …}; GET /tasks/exports/{task_id} says running, ready or failed (with detail); once ready, GET /tasks/exports/{task_id}/file sends the file, once: it is deleted as soon as it has been sent, and one never taken is deleted after an hour. Only the account that started an export can see it.

Errors

CodeMeaning
401no token, unknown token, expired or revoked token, deactivated account
403the account lacks the permission, or the token is read only
404not found, or not visible to this account
409conflict: the flow is already running, the datasource is already refreshing
422the request is malformed: the body says which field
503the engine or the database is not answering: try again in a moment

Error bodies are JSON with a detail field. Its language follows the Accept-Language header where a translation exists.

Download a report

A pixel-perfect report (a page designed in the report designer, kept in a folder) can be downloaded from any script, with its parameters, as the web app renders it:

GET /reports/{id}/render?format=pdf&region=North&since=2026-09-01
  • format: pdf (the default), xlsx (the report's tables, groups and subtotals as rows) or html (the summary an email carries, as an .mhtml file: the HTML with its charts as images).
  • The parameters go in the query string by name. A parameter that takes several values repeats its name: ?region=North&region=South. One left out, or empty, takes the report's default; {none} says no value for an optional one, whatever its default (region=%7Bnone%7D), and the viewer's links do the same. A value is text, as typed in the report's form: a number with a dot for decimals (1234.5), a date as yyyy-mm-dd, true or false. A date may also be a placeholder, resolved in the report's time zone when it is rendered: since={start_of_month-1m} is the first day of last month. Encode the values: in a query string + is a space, so {today+7} is written %7Btoday%2B7%7D (requests' params and curl's --data-urlencode do it for you). A value repeated for a parameter that takes several counts once.
  • An AI comment in the report: a token that may write makes the call it needs, on its account; a read only one causes none, and gets what is kept, or a line in its place. xlsx has no place for one.
  • The token: read only is enough. The report reads with the rights of the token's account: it needs VIEW on the report's folder, and every dataset the report reads must be one that account may read (the datasources, the views, the SQL scripts, and CONNECT on a database connection).
  • The answer is the file, with its name in Content-Disposition (Monthly sales.pdf; a name with accents comes twice, as written and without them, so curl -OJ keeps it). The request stays open while a worker renders it, up to REPORTS__API_WAIT_SECONDS (120 seconds by default); past that the render is stopped, and so it is when the script stops waiting first (its own timeout): a script that tries again does not pile renders up. Every download is in the audit log (report.render, from api).

The report's id is in its address (/report?id=12), and Copy API link in the report's toolbar gives the address with the values on screen. GET /reports lists the reports the account sees; GET /reports/{id}/parameters lists a report's parameters, with their types, their defaults as they read today and their choices.

Example: last month's PDF, every month

A script run on the first of each month downloads the PDF of the month before for one region. As an administrator, create a service account (see Example: a token for a scheduler) with VIEW on the report's folder and on the data it reads, and give it a token with access Read only. The report here has the parameters region (text) and since and until (dates):

import datetime as dt
import os

import requests

API = "https://tabularia.example.com/api"
TOKEN = os.environ["TABULARIA_TOKEN"]  # never in the script itself
REPORT_ID = 12

first_of_this_month = dt.date.today().replace(day=1)
last_day = first_of_this_month - dt.timedelta(days=1)
first_day = last_day.replace(day=1)

response = requests.get(
    f"{API}/reports/{REPORT_ID}/render",
    params={"format": "pdf", "region": "North", "since": first_day.isoformat(), "until": last_day.isoformat()},
    headers={"Authorization": f"Bearer {TOKEN}"},
    timeout=180,  # longer than REPORTS__API_WAIT_SECONDS
)
if response.status_code == 400:
    detail = response.json()["detail"]
    raise SystemExit(f"{detail['parameter']}: {detail['message']}")
response.raise_for_status()
with open(f"sales_north_{first_day:%Y-%m}.pdf", "wb") as f:
    f.write(response.content)

The same with curl, two regions in Excel; -G with --data-urlencode puts the values in the query string, encoded:

curl -fG -H "Authorization: Bearer $TABULARIA_TOKEN" -o sales.xlsx \
  --data-urlencode format=xlsx --data-urlencode region=North --data-urlencode region=South \
  --data-urlencode "since={start_of_month-1m}" \
  https://tabularia.example.com/api/reports/12/render

When it fails

CodeMeaning
400a value does not fit a parameter: {"detail": {"message": "Parameter «since»: '2026-02-30' is not a date: write it as yyyy-mm-dd", "parameter": "since"}}. The same for a required parameter without a value, a value outside its choices, a name the report does not have
404no report with that id, or one in a folder the account cannot see
422the report cannot be rendered: a dataset the account may not read, said with its name as one that does not exist (The dataset «orders»: there is no datasource «Salaries» you can read), a dataset over the row limit (REPORTS__MAX_ROWS, 3,000,000 by default: aggregate in SQL, or filter), too many pages
502the engine could not render it (the chart service down, for a report with charts)
503the engine is not answering (restarting, its node drained): try again in a moment
504not ready within REPORTS__API_WAIT_SECONDS: the render was stopped

To send a report by email instead, on a schedule and right after the data is refreshed, put it in the Pushmail cell of a flow group. In a marimo notebook, tabularia.report() does the same as this API.

Dashboards

A dashboard answers scripts with its numbers and with its files. A token that may only read is enough for all of this. The reads are with the rights of the token's account: VIEW on the dashboard's folder, and the datasets it reads must be ones that account may read. A tile on a dataset it may not read says so in its place, and the others answer. The examples read «Sales overview» of the sample data; its id is in its address (/dashboard?id=12).

Its parameters

GET /dashboards/12/parameters?area=Nord-Ovest

The parameters as the form shows them: types, defaults as they read today, choices. The values in the query string are those the other lists follow: with area=Nord-Ovest, agent has 13 choices instead of 90.

Its numbers

curl -s -H "Authorization: Bearer $TABULARIA_TOKEN" -H "Content-Type: application/json" \
  -d '{"params": {"area": "Nord-Ovest"}, "tiles": ["revenue"]}' \
  https://tabularia.example.com/api/dashboards/12/data
{"tiles": {"revenue": {"type": "kpi", "label": "Revenue", "value": 2818062.7190681,
                       "text": "€ 2,818,063", "change": null, "spark": null, "title": "Revenue",
                       "computed_at": "2026-11-01T20:03:24.145747+00:00", "cached": false}},
 "truncated": {"agents": false}}
In the request
paramsthe parameters' values, by name, as text
clickswhat is chosen on a tile: [{"tile": "by_area", "values": ["Centro"]}], and "on": "columns" for a matrix's columns. A tile's id and values, never an expression: the expression is the saved one
tilesonly these tiles; none, every one
pagea table's page, from 0: {"table": 1}; 50 rows a page
statea table's sort and closed groups, as on the screen: {"items": {"table": {"sort": {"column": 1, "dir": "asc"}, "collapsed": [["Centro"]]}}}
refreshtrue reads the source again, at most once every 10 seconds per dashboard, for everyone: past that it answers with what is kept, without an error
In the answer
tilesby tile id. The numbers: type, title, what the type draws (value and text; data; rows), computed_at (when the rows were read from their source) and cached (this answer did not read it). A tile clicked adds chosen, what stayed of its values. A tile that cannot answer: only {"error": "a sentence", "code": …}
truncatedby dataset read, whether it is past DASHBOARDS__MAX_ROWS. Of a dataset you may not read, nothing
codeMeans
no_accessit reads a dataset the account may not read
truncatedit reads a dataset past DASHBOARDS__MAX_ROWS: the sentence says how to aggregate. No number on part of the rows
datasetits dataset cannot be read for another reason (no data yet, a parameter its SQL needs)
erroranything else in this tile

A value of a click is the raw one the tile gave in keys (or path and key in a table): the number or the date, not the text on the screen; null for an empty group; a list for several levels, ["Nord", 2023].

Its files

curl -fG -H "Authorization: Bearer $TABULARIA_TOKEN" -o sales.pdf \
  --data-urlencode format=pdf --data-urlencode area=Nord-Est --data-urlencode click.by_area=Nord-Est \
  https://tabularia.example.com/api/dashboards/12/render
  • format: pdf (the default) or png, the same document on one long page.
  • The parameters go in the query string by name, as for a report: one left out takes the default, {none} says no value.
  • The clicks are written as in the viewer's address: click.by_area=Nord-Est, repeated for several values; click.cross.columns=… for the columns of a matrix cross; {none} for an empty group; \ before a text that would read as something else; JSON for several levels.
  • The answer is the file, with its name in Content-Disposition, as a report's, and the same wait (REPORTS__API_WAIT_SECONDS). Every download is in the audit log (dashboard.render, from api).

POST /dashboards/{id}/render, with {"format": "pdf", "params": {…}, "clicks": […], "state": {…}}, is what the viewer's button does: it answers {"task_id", "filename"} at once, followed at GET /tasks/exports/{task_id} until status is ready, and taken at GET /tasks/exports/{task_id}/file.

StatusMeans
400a value does not fit a parameter: {"detail": {"message": "Parameter «area»: 'Atlantis' is not one of its choices", "parameter": "area"}}. The same for a name the dashboard does not have
404no dashboard with that id, or one in a folder the account cannot see
422a click that cannot be: The dashboard has no tile «nope», The tile «revenue» cannot filter the others, The click on «by_area» has 101 values: at most 100; an unknown format; a file that cannot be made, saying why

A link to the viewer with a click on a tile that is gone opens without it; the API says 422. To send a dashboard by email, on a schedule, put it in the Pushmail cell of a flow group.

Webhooks

Instead of polling, an administrator can have Tabularia call an address when something finishes (Administration → Webhooks).

Events

EventWhen
flow.run.succeeded / flow.run.faileda flow execution finished: from a schedule, from Run in the interface, from run-now, or inside a flow group (the group itself sends no event). A run closed because its process died, because the gateway shut down, or because it waited in the queue too long is a flow.run.failed too
datasource.refresh.succeeded / datasource.refresh.failedthe refresh of a database datasource finished
pingthe Send a test call button

A webhook can be limited to one folder and its subfolders.

The call

A POST with a JSON body:

{
  "id": "5f0c1c8e-8a2e-4b8f-9a55-2a8f4b1d2c3e",
  "event": "flow.run.failed",
  "created_at": "2026-10-12T08:30:12.401Z",
  "data": {
    "run": {
      "id": 2212, "kind": "orchestration", "status": "FAILURE", "trigger": "schedule",
      "started_at": "2026-10-12T08:29:50.112Z", "finished_at": "2026-10-12T08:30:11.700Z",
      "duration_seconds": 21.6, "rows_written": null, "error": "ClickHouse: no response from …"
    },
    "flow": {"id": 14, "name": "Margin by Category and Month"},
    "folder": {"id": 3, "name": "Reports"}
  }
}

For datasource events, data carries datasource in place of flow. Headers:

Header
X-Tabularia-Eventthe event
X-Tabularia-Deliverythe delivery id, the same as id in the body
X-Tabularia-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Verifying the signature

Each webhook has a secret (whsec_…), shown when it is created or regenerated. v1 is the HMAC-SHA256 of "<t>.<raw body>" keyed with that secret. Check it against the raw body, before parsing it, and reject old timestamps:

import hashlib, hmac, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1) and abs(time.time() - int(t)) <= tolerance

Regenerating the secret takes effect at once, including for calls already queued.

Delivery

  • Any 2xx answer counts as delivered. Anything else, or no answer within WEBHOOKS__TIMEOUT_SECONDS (10), is retried after 30 s, 2 min, 10 min, 1 h and 6 h; after the sixth attempt the delivery is marked failed. Redirects are not followed, and only the first few kilobytes of the answer are read.
  • At least once. A call can, rarely, arrive twice: deduplicate on X-Tabularia-Delivery. A delivery sent again by hand from the history gets a new id.
  • Order is not guaranteed across events: use created_at and the run's finished_at.
  • One slow receiver does not hold up the others. Each Tabularia process keeps up to 16 calls in flight, at most 2 of them to the same webhook, and starts the next as soon as one finishes. A webhook whose receiver is down retries on its own schedule while the others go out as usual.
  • HTTPS. The certificate is checked against the name in the URL. Tabularia trusts the usual public certificate authorities; for a receiver with a certificate from your company's own authority, give the gateway its bundle with SSL_CERT_FILE (or SSL_CERT_DIR).
  • Allowed addresses. Only http/https. Never loopback, link-local (including the cloud metadata address 169.254.169.254), multicast or reserved addresses, nor the other clouds' metadata addresses (100.100.100.200, 168.63.129.16, fd00:ec2::254). The name is resolved on every call, checked, and the connection goes to the address that was checked, so a name cannot resolve to something else in between. Private networks are allowed, since an integration on the same network is the normal case. A forbidden address fails at once and is not retried; a name that does not resolve is retried like any other failure. An address that cannot be read (http://0177.0.0.1/, an empty label, a port above 65535) is refused when the webhook is saved.
  • The history of every call (status, attempts, HTTP code, error, the body sent) is kept for WEBHOOKS__RETENTION_DAYS (30) and shown under each webhook, where a call can be sent again.

Edit this page on GitHub