Skip to content

API reference

This page is the lookup summary. For the end-to-end walkthrough with examples, see Send data via the API; for how to push data efficiently and safely, see Push data to Ronja. The interactive endpoint-by-endpoint API reference is served at /docs/api on your Ronja domain. For AI agents, the machine-readable entry point is a lean index at /llms.txt (also at /docs/api/llms.txt and /.well-known/llms.txt); it links to the compact machine-readable list of every endpoint (with the scope each needs) at /docs/api/endpoints.md, alongside the full OpenAPI schema at /docs/api/openapi.json.

Tokens are created by an Admin on the API Tokens page: click Create token.

Create field Details
Token name A label, e.g. “CI pipeline”
Role Every token is bound to a role (default User); the role bounds everything the token can do. An admin cannot mint a token at or above their own role level
Access Limited to scopes (recommended default — grant at least one scope) or Full access (unrestricted within its role; a warning is shown)
  • The token secret is shown exactly once at creation (“Copy this token now. You won’t be able to see it again.”). It cannot be re-displayed — only revoked.
  • Send it as a bearer token: Authorization: Bearer <token> (the Bearer prefix is optional).
  • Tokens are bound to your organization and can be deleted at any time from the token list or detail page — the same for a token sealed inside a Ronja API credential, which is deleted by deleting that credential. A deleted token stops working within 30 seconds.
  • A token records the Admin who created it, and for the self-approval policy it counts as that person: where your organization has turned self-approval off, the token cannot approve or commit work that Admin authored, whoever is using it. Its role and scopes are unaffected. A token created long ago, or one whose creator has since left the organization, may no longer be recognized as that person’s — so the policy may not stop it. A deleted token goes the other way: while self-approval is disallowed, a draft, proposal or move request it filed can no longer be approved by anyone, because there’s no record left of who filed it — and a token you are approving with can’t be used either, within 30 seconds of being deleted. Re-creating a token doesn’t restore either link — a new token is a new credential — so publish or withdraw what a job has filed before you rotate its token, and file anything already stranded again from the account that should own it.

Scopes narrow a token within its role — they never widen it. Each scope is granted at None, Read, or Write. A scoped token is denied on anything outside its grants.

Scope Covers
Data Tables, files, tags, connectors & search — but search results for managed databases also need Structure: Read, and without it they are simply absent from the response rather than refused
Structure Features, access groups, Spaces, people properties (read) & managed databases — reading a database, provisioning one, and applying migrations to it. Running SQL against it needs Data instead, and minting or revoking a connection login needs Secrets
Analytics Apps, notes, knowledge & policies
Automation Workflows, schedules & scripts
Agents Saved Agents (create, edit, run), chat & connected MCP servers
Secrets Secrets & OAuth connections
Admin Members, billing, governance, settings & stations

Connecting an AI assistant to Ronja over MCP needs Data — see what each tool needs.

A token can later be restricted to scopes, or widened back to full access (confirm-gated), from its detail page. A scope or expiry edit reaches requests made with the token within 30 seconds; an expiry date itself is exact once it passes.

A personal access token (PAT) is a token that acts as you — it uses your live permissions rather than a role fixed at creation. If your role changes, the token’s access changes with it; if you lose access, the token stops working. This makes a PAT the simplest way to call the API as yourself, for example to build resources owned by you.

Because a PAT is personal, you create and manage it on your own Account page: open Account, go to the Access tokens tab, and click Create token.

From a terminal, ronja token create opens the same form with the name, scopes and expiry filled in; you still review it and click Create token yourself.

Create field Details
Name A label, e.g. “My laptop”
Access Limited to scopes (recommended default — grant at least one scope) or Full access (a warning is shown). A full-access PAT carries your full live role
Expires Optional — the token stops authenticating after this date

A PAT has no Role picker — it has no role of its own; it inherits yours. Whether it is limited to scopes or full access, a PAT never exceeds what you can already do — and does slightly less, since it is an automated caller and a few actions need a person signed in — and its access follows your live role, so it auto-limits if you’re demoted or offboarded. You can restrict a PAT to scopes, or widen it back to full access (confirm-gated), from its detail page; like any token edit, it reaches requests within 30 seconds.

  • The secret is shown exactly once at creation, cannot be re-displayed, and can be revoked any time from the Access tokens tab. A revoked token stops working within 30 seconds.
  • Send it as a bearer token: Authorization: Bearer <token> (the Bearer prefix is optional).
  • Everything a PAT does is audited as you, with the token noted as the mechanism (“via PAT <name>”) — so your activity stays complete whether you used the app or a PAT.
  • The same is true when Ronja herself calls the API for you in a chat. That entry is audited as you and carries two chips: agent (“Performed by an agent on this user’s behalf”) for who did it, and via Ronja (“Performed by Ronja calling its own API on this user’s behalf”) for how the request arrived. All three chips are additive, so a row can carry any of them, all of them, or none — see Activity log.

Creating a PAT on the Account page is Admin-only; the token always acts as the Admin who created it. Any member, User Read-Only included, also gets a full-access PAT that expires after 90 days by signing in with the Ronja CLI (ronja login) or from an AI assistant; it acts as them and is listed on their Access tokens tab. An Admin can also see and revoke any personal access token across the organization from the API Tokens page, where each PAT is marked Personal token and shows its owner.

A Ronja API credential is a scoped API token sealed inside a secret, pointed back at your own Ronja organization, so Ronja’s own agents and workflows can call your Ronja API for operations the built-in tools don’t cover. An Admin can create one either with the endpoint below or simply by asking Ronja in a chat (“create a Ronja API credential”), which mints it after an approval step. See Let Ronja call your API for the walkthrough.

Field Details
Endpoint POST /api/v2/secret/ronja-api — Admin only, and only from Ronja itself: the endpoint refuses automated callers (see Actions the API can’t take). Ask Ronja in a chat instead
name A label for the secret
featureID Required — the Feature the credential lives in, and it must be a personal Feature you own. A shared Feature is refused (403), Admins included: its members would be able to see the credential
scopeGrants Optional map of scope → read/write (same scopes as a token). Omitted → the read-oriented default (Data, Structure, Analytics at Read; Agents and Automation excluded)
role Optional — the minted token’s role (default Admin, so the credential can reach role-gated read endpoints such as listing every table in the organization; the read-only scope default still bounds writes). Pass User for a narrower credential. Capped to the caller’s own role
expiresInDays Optional — the credential’s lifetime in days. Omitted → it never expires. With a number, the credential stops authenticating after that many days and must be re-created. Delete its secret to retire it

The response is the created secret’s metadata. The token is never returned — it is sealed in the secret and can’t be read back. An agent or workflow references the secret and Ronja injects the Authorization header automatically.

A Ronja API credential can’t create another Ronja API credential, and each organization can hold only a limited number at once — delete an unused one before creating another if you reach the limit.

Using a credential is Admin-only too. Creating one is Admin-gated, and so is spending one in a chat — a non-Admin who can see the secret still cannot call the API with it. To let a non-Admin’s work use it, an Admin attaches the credential to an automation or a saved Agent, which then runs for whoever triggers it; a run that does not declare the credential among its references is refused.

Reachability caveat. A scoped credential (like any scoped token) reaches only routes that carry a scope annotation; anything outside its grants is denied. The interactive reference at /docs/api lists the set of public endpoints, which is not identical to the scope-reachable set — some listed endpoints aren’t reachable by a scoped credential, and some reachable ones aren’t listed. Treat /docs/api as a guide, not a guarantee, and grant scopes to match the routes you actually call.

A small set of actions require a person signed in to Ronja. They refuse every automated caller — an API token, a personal access token, the CLI, a Workflow, an Automation, a Saved Agent, and Ronja itself — with:

{"code": "human_required", "error": "this action can only be taken by a person signed in to Ronja, not through the API (…). <where to do it instead>"}

This is a property of the action, not of your credentials. A wider scope, a higher role or a different token will not change the answer, and retrying will not either. The message names where to do it in Ronja. For most of the rows below the whole endpoint is closed; a few are decided by an argument, so the same endpoint answers normally for every other request. One is taking a dev copy with the production data — 202 for a structure-only copy, human_required for that one. Another is opening an email trigger to any sender — allowAnySender: true on POST /api/v2/automations or PUT /api/v2/automations/{jobID}, which is refused for every automated caller while everything else about the same automation stays scriptable, allowAnySender: false included.

Five things earn it:

What it covers Why
Irreversible at organization scale Deleting the organization, running a cascade delete, and every billing change (buying credits, saving a card, top-ups, auto-refill, and opening Manage billing) No preview, count or confirmation field substitutes for a person agreeing. Manage billing is included because the page it opens is one from which billing can be changed
Changing who can get in Inviting and removing organization members, assigning and removing roles, registering or editing an email domain for auto-join, adding someone to an Access Group, attaching a Feature to an Access Group and granting an Access Group access to a Feature’s tables, making a person or an Access Group a maintainer of a Feature (POST /api/v2/feature/:id/maintainers), approving an access request from a bridged Slack or Microsoft Teams channel, granting access to a folder or file in Files, adding Slack members to an Access Group or inviting people to Slack, connecting a Slack or Microsoft Teams channel to Ronja (one at a time, several at once, or by completing a setup someone started from Slack) and the Slack setting that bridges every public channel, choosing who may ask Ronja on a bridged channel — adding people to its Selected members list, or switching it to Anyone in access group — clearing an Automation’s webhook Signing secret, opening an Automation’s Email trigger to any sender (Open to any sender, the allowAnySender argument above), and changing a Mailbox’s settings These expand who can reach your data, or who can set Ronja working. A bridged channel is a door of its own: Ronja links a Slack sender to their Ronja account from their Slack profile email, so bridging a channel at all — and widening who may ask her there — widens who is acting in Ronja — and because one control moves a bridge between Anyone in access group and Selected members, it needs a person in either direction. Clearing a webhook Signing secret leaves the Receiver URL alone as the authorization, and emptying a mailbox’s Compose recipients list removes the only bound on where an unattended run may send new mail as your own address. Opening an email trigger is the widest of them: it lets anyone on the internet who learns the address set Ronja working with no account at all, and whoever emails it writes the instructions the run follows — so it needs Admin and a person, and refuses even a personal access token bound to your own admin account. Closing one is narrowing and stays scriptable, as does muting its refused-sender alert. The mailbox settings page also carries the Watched label and the Daily send limit, which are not about who can get in and are browser-only only because they share the one Save — the whole mailbox lifecycle already needs a person in Ronja, since connecting one is a sign-in with your mail provider. Note the asymmetry: cutting off access is a security action you should be able to script, so removing someone from an Access Group, detaching a Feature from one, removing a folder’s access grant, revoking a maintainer (DELETE /api/v2/feature/:id/maintainers/:grantID), taking one person off a bridge’s Selected members list, disconnecting a bridged channel, declining an access request, declining a setup started from Slack, removing an auto-join domain, rotating a webhook signing secret and revoking a token are all not blocked. The one remove that is: Give access and Remove access on several features at once (POST /api/v2/workspace/:id/features/bulk) are browser-only both ways, so a script removes a group’s access one feature at a time (DELETE /api/v2/workspace/:id/features/:featureID). Removing someone from the organization or taking away their role are — those end their access entirely, rather than narrowing it
One person’s own notifications Reading, and clearing, the personal notification feed behind the bell in the sidebar — all three /api/v2/notification/inbox endpoints (list, mark one read, mark all read) The only closed READ on this page, and the reason is what marking read means. A script that quietly cleared someone’s alerts would suppress a warning no human ever saw — an automation breaking, say — and it would look exactly like nothing having gone wrong. A loud failure is better than a silent suppression, so the read is closed alongside the writes rather than leaving a feed a script can page through on someone’s behalf. The organization-wide Notification routing settings, which decide who gets emailed about what, stay fully scriptable
Disconnecting production to copy it — the one row decided by an argument, not by the endpoint Making, or refreshing, a dev copy of a managed database with the production data in it. The mode you send decides: "data" is refused, "schema" (or no mode at all) is not — and on a refresh, which sends no mode, the kind of the copy you already have decides Taking that copy terminates every connection to the production database while it runs, aborting whatever was mid-transaction. There is no field a script can send to be shown that and agree to it. A structure-only copy does nothing to production and stays fully scriptable, as does using a copy and promoting one
Permanent deletion Emptying an item from the Trash — every …/purge endpoint — plus permanently deleting a Slack or Microsoft Teams connection, disconnecting a Mailbox, Delete user on the Users & Access page, and Delete this version in a document’s History An automated caller may never make something unrecoverable. Ordinary deletion still works from the API and puts the item in the Trash, where it stays recoverable for 30 days. See Restore from trash. Delete user is the one with no Trash at all — the account and everything keyed to it go immediately and nothing brings them back, which is why it is here rather than in the row above. To end someone’s access without destroying their account, remove them from the organization (also browser-only). A deleted document version has no Trash either: its content cannot be recovered

Restoring an earlier version of a document is browser-only too, though it fits none of the rows above: Restore this version in a document’s History changes what every live share link to that document serves, and putting back content someone edited out is exactly what an injected instruction would ask for — so a person does it in Ronja. Listing a document’s versions and getting a link to one stay open to the API.

Turning web search back on is browser-only as well: webSearchEnabled: true on PUT /api/v2/organization/settings/governance (or PUT /api/v2/tenant) answers human_required, because it lets Ronja send searches to Perplexity, a US service, again. An Admin turns it on under Web search on the Organization rules page. Turning it off, webSearchEnabled: false, stays scriptable. While Ronja has turned web search off under your agreement’s EU/EEA-only terms, turning it on answers a plain 403 with no code field, saying Ronja turned it off, instead of human_required. See Turn web search on or off.

Changing people properties is browser-only as well: creating, renaming or deleting a property or a value, giving people values, and Import from file all answer human_required. An Admin manages them on People properties or a person’s page under Users & Access. Reading them stays open: GET /api/v2/people-property and its two read routes answer any member’s token that can read Structure.

Setting or removing your organization’s daily AI safety limit is browser-only too: POST /api/v2/model-access/daily-limit answers human_required, because the limit is there to stop a runaway agent or loop and a script must never lift it. An Admin changes it in Control Center › Rules. GET /api/v2/model-access/daily-limit stays readable.

Raising or lowering your organization’s monthly budget is browser-only for the same reason: POST /api/v2/model-access/monthly-budget answers human_required. An Admin changes it in Control Center › Rules. GET /api/v2/model-access/monthly-budget stays readable: whether a budget applies, the budget, this billing period’s percentage used and dates, and whether it is used up. While it is used up, a request that starts paid work answers 400 with the code monthly_budget_reached.

What is deliberately still available: creating Secrets, creating users on a Managed database, and making or refreshing a structure-only dev copy of one are ordinary automation and stay fully scriptable. Only Ronja’s own credentials are closed — creating an API token, changing an existing token’s scopes, approving a CLI sign-in, approving an AI assistant’s sign-in, creating a Ronja API credential, and creating a station or regenerating its link (each shows a long-lived link once). An automated caller that could create Ronja credentials could hand itself a standing one that outlives whatever it was set running to do, so a person creates each one. Disconnecting a station stays scriptable, so a leaked station link can be cut off from a script during an incident.

Reading is almost never affected. Nearly every one of these has a readable counterpart that stays open — you can list members, preview a cascade delete, and read your billing and usage from the API as before. The one exception is the personal notification feed behind the bell: it is closed to automated callers in both directions, reading included, and has no open counterpart.

Asking Ronja in a chat is not a way round it

Section titled “Asking Ronja in a chat is not a way round it”

Several rows above point you at a chat instead — “ask Ronja to create a Ronja API credential”, “or ask Ronja in a chat”. That is a real answer, and it is an answer for a person. When Ronja is about to take one of these actions she stops and asks; approving that request also requires a person signed in to Ronja, so a chat you are driving from the CLI, from a coding agent, or with any API token gets the same human_required 403 on the approval that the endpoint would have given on the call. A forwarded approval link cannot decide one either.

The chat list is its own list, and it overlaps this page rather than matching it. It covers: changing the organization policy, creating a Ronja API credential, adding someone to an Access Group, attaching a Feature to one or setting its table access, granting access to a folder or file in Files, opening an Automation’s email trigger to any sender, making a dev copy with the production data, and refreshing a dev copy of either kind. Some of those have no API row above at all, and some rows above are refused only at the endpoint — the organization policy is the clearest case: PUT /policy/org still accepts an admin’s personal access token (but not a token from the API Tokens page), while asking Ronja to make the same change needs a person to approve it. Every other approval Ronja asks for — sending mail, deleting something, running a workflow — can still be approved by whoever is driving the chat, so an automated caller is not locked out of the product; it is locked out of one short list.

The way through is the same one the endpoint names: do it in Ronja, signed in. Rejecting is always allowed, so a script that hits one of these can close the request and carry on rather than leaving the conversation waiting.

Store an API key or credential from the API with POST /api/v2/secret, then read it from a Workflow. The value is write-only — it goes to secure storage and is never returned by any endpoint (reads expose metadata only). Requires a token with Secrets: Write (a User, or Data Admin when the secret is bound to a shared Feature).

Field Notes
name A label for the secret
secretType api_key (default), oauth, client_credentials, database, or url_scope
credentials Required — JSON object of field → value, e.g. {"token":"sk-..."}
agentFields The field names a Workflow is allowed to read, e.g. ["token"]. Must list every field a Workflow will read — a field absent from it is not readable at run time and the run fails with an error like Field '<name>' not available on secret
allowedURLs Egress allowlist, e.g. ["*.example.com"]. Required unless dialect is set
dialect Database secrets only, e.g. postgres. Without a fieldSchema of your own, Ronja applies the database’s own list of fields, and credentials must include every field it needs to connect — a 400 names any that are missing
featureID Required — the Feature the secret lives in. A private Feature must be one you own

To rotate or replace a secret’s value later, send the new credentials with PUT /api/v2/secret/{secretID}; that also runs an automatic health check and returns its result. For an OAuth secret whose stored sign-in does not renew itself, the same call restores one that has expired and is being refused.

A Workflow reads a stored field with the two-argument secret marker in its source — {{ secret('secret-...', 'token') }}, where the second argument is a field name from credentials that is also listed in agentFields.

Sync tables out of your own PostgreSQL, MySQL, SQL Server or BigQuery into a Feature on a schedule, from the API — see Connect a data source for what a connection is. The end-to-end recipe (store a secret → create → discover → choose tables → run) is served on your Ronja domain at /docs/api/guides/import-a-database.md.

Every endpoint below requires the Admin role, reads included. A connection holds credentials for a system outside Ronja, so who may look at one is the same question as who may point it somewhere else. The whole group is the Data scope — Data: Read for the reads, Data: Write for the writes.

Method and path Notes
GET /api/v2/connector/types The connector types a new connection may use. ?includeLegacy=true also returns retired types and engine-internal ones — useful only for resolving a connection you already have to a display name, never for picking
POST /api/v2/connector/connection Create. Send secretId (an existing database secret) with featureID and name; the type is derived from the secret’s dialect, so you don’t name one. The response comes back status: "pending" — nothing syncs until tables are chosen
GET /api/v2/connector/connection List. featureID narrows to one Feature; onlyMarked=true returns the connections in the Trash instead of the live ones. Both optional
GET /api/v2/connector/connection/{id} Read one — configuration, schedule, and syncedStreams, the per-table picture. This is the endpoint to poll after a sync; a table failing there does not fail the connection, so read both. It cuts the other way after a failure: a failed sync preserves these records rather than clearing them, so they describe the last cycle that reached each table, not the attempt that just failed — a table can read done: true while the connection reads status: "error". lastError is what describes the failure
PUT /api/v2/connector/connection/{id} Change the name, schedule or credentials — and choose the tables, in config. config replaces the stored object rather than merging into it, so send the whole thing
POST /api/v2/connector/connection/{id}/discover List the tables the connection’s credentials can actually see. An empty body lists every schema with table names; pass datasetId for one schema in detail. Reads the source live and changes nothing
POST /api/v2/connector/connection/{id}/sync Run the sync now, without waiting for the schedule. Returns as soon as the run is queued — it does not wait
POST /api/v2/connector/connection/{id}/stop Ask an in-flight run to stop at its next checkpoint. Stopping loses progress, never data
DELETE /api/v2/connector/connection/{id} Move the connection to the Trash and pause syncing. Reversible, and the tables it has already filled are left in place
POST /api/v2/connector/connection/{id}/restore Bring it back out of the Trash — and re-enable syncing, on the schedule it already had
  • Scheduling is either cronExpression (5 fields, read in syncTimezone, UTC by default) or syncIntervalSeconds. Send neither and it runs once a day. A schedule faster than hourly is refused — this syncs whole tables, not events.
  • A run has a 10-hour budget. Hitting it is not a failure: status comes back partial, the bookmark is kept, and the next run continues from there. On PostgreSQL, MySQL and SQL Server, a table set to re-read in full every run is capped at 10,000,000 rows, and one table’s re-read has 2 hours — a table that takes longer fails. Give such a table a cursor column instead. That re-read is one long query against your database, so point it at the primary rather than a PostgreSQL read replica, which on default settings can cancel a long query when the primary cleans up rows it is reading; on the replica, raising max_standby_streaming_delay or setting hot_standby_feedback = on makes that rarer, at the cost of holding back cleanup on the primary while the read runs.
  • Polling. POST …/sync only queues the work. Poll GET …/{id} until status leaves syncing; with the CLI, ronja api /api/v2/connector/connection/<id> --wait-until '.status != "syncing"' --wait-timeout 2h.

Push data files into a manual-integration table — Integration tables fed by files instead of a connector. Requires a token whose role has Admin rights and (if scoped) Data: Write; listing files only needs read access to the table — a table you can’t read answers 404, the same as one that doesn’t exist.

Format: Parquet only. Filenames must match [a-zA-Z0-9._-]+.parquet; convert CSV to Parquet client-side first.

Step Endpoint Notes
1. Create the table (once) POST /api/v2/feature/model with {"name":"...","featureID":"...","kind":"manual_integration"} featureID goes in the body, not the path. Returns the table ID (table-…), also visible in the table’s URL
2. Upload a file PUT /api/v2/feature/model/{tableID}/file/{filename} Raw body (not multipart), max 1 GiB per file. Re-uploading the same filename replaces it; different filenames accumulate and build together
3. List files GET /api/v2/feature/model/{tableID}/files Paginated (200 per page, nextCursor)
4. Delete a file DELETE /api/v2/feature/model/{tableID}/file/{filename} 404 if the file doesn’t exist
5. Build POST /api/v2/feature/model/{tableID}/build Required — uploads alone are not queryable. Async: poll GET /api/v2/feature/model/{tableID} and branch on buildVerdict (see below). Once the build lands, tables derived from this one start rebuilding on their own (unless you built with ?cascade=defer — see Sending several tables at once) — that cascade runs on its own clock, and a quick rebuild can go from ok back to ok between two polls of its buildVerdict. If a downstream read has to reflect this upload, note the dependent’s lastBuiltAt and lastBuildError before you build, then poll it until one of them changes — with a timeout, because a rebuild whose code ran to completion without saving a result publishes nothing and moves neither. If it is already rebuilding when you look, let that rebuild finish first. A moved lastBuiltAt means the rows are queryable — it is written in the same step that publishes them — while buildVerdict can still read building for an instant and fields[] can still describe the previous build’s columns, so wait for the verdict to leave building as well if it is the column list you need. Push rows has the full rule. ronja api … --wait-until from the CLI runs the poll — see Waiting for something to finish

The table’s schema is auto-detected from the Parquet files.

Polling caveat. status alone is not a success signal. A table that already holds data from an earlier build is restored to ready after a FAILED rebuild, so it stays queryable — usually on its last good data, though a rebuild that failed after publishing its rows serves THIS run’s data under a failure verdict, with lastBuiltAt moved. The failure surfaces either way as a populated lastBuildError ({message, type, occurredAt}) on the same response. A table with no previous data ends at status: "build_failed" — and so does one whose rebuild can never succeed (an input table was deleted), even though its last good data is still queryable. See Table types.

buildFailing tells a failure that is still going on from one that is over. lastBuildError is the latest attempt’s error and stays on the response after a later build succeeds. buildFailing ({since, reason}) is present only while a table’s updates keep failing: since is when the current run of failures began, reason is the latest failure’s message, and the field disappears as soon as a build publishes. Its absence does not by itself mean the build you are waiting for worked, and it can also appear on a table that has never built — use it beside buildVerdict, not instead of it. See When a table stops updating.

Branch on buildVerdict, not on status. The same response carries buildVerdict, which already combines the two: building and pending mean keep polling, ok means it worked, ok_partial means the table is queryable but the build skipped unreadable source files, failed_stale is the trap above (the run failed and the table is still queryable with status reading ready — on its previous data, unless the failure came after the rows had already been published, in which case lastBuiltAt has moved and the data is this run’s), failed means the run did not produce a result — not that the table is empty — and invalidated means something upstream changed and this table is due a rebuild.

A failed_stale with no lastBuildError beside it is narrower than it looks: Ronja could not read the failure record for that table, so the last run’s outcome is unknown rather than known-bad. It is reported that way deliberately — a verdict that claimed success on a record nobody could read is the one mistake an automated publish would act on.

failed is usually a first build with nothing to fall back on, but it is also where a table sits when every rebuild is bound to fail the same way — an input table was deleted, say. Such a table keeps its last good data and stays queryable while reading failed, so treat the verdict as a statement about the last run, never about whether there is data to read. lastBuildError stays the field that tells you why.

A table a workflow wrote also says whether it is behind its inputs. The same GET /api/v2/feature/model/{tableID} carries stale and provenance on a table a workflow has written: provenance names the workflow (workflowID), the run (runID — which stops resolving once the run has aged out, so link the workflow, not the run), when the run read its inputs (snapshotAt), and each input in inputs[] as {id, name, changed, missing, changedAt}; a table built from such a table carries stale on the matching inputRefs[] entry instead. stale is three-state — true, false, or absent when Ronja cannot say (the table has since been written by something other than that workflow, provenance.superseded; by more than one workflow, provenance.mixed; or the comparison could not be made) — so test for the key, and never read a missing one as fresh. It is advisory: nothing refuses a stale table — a query, a row push and an app all proceed — and a gate is one line in your caller. See When a workflow’s inputs move on.

Each upload is validated as a readable Parquet file before it is accepted: the footer must parse, there must be at least one column, and the data pages must decode. A file that fails any of these is rejected with a 400 and discarded, so a bad file can never be stored. An empty file (valid Parquet, zero rows) is accepted — it is treated as “no data this window” and skipped at build time, contributing nothing to the table.

Push rows into a Dynamic table (Table types), for data your own app or scheduled script produces — as JSON, or as a Parquet file when the volume warrants it. Data in, queryable table out, with no build step: when the call returns, the rows are there.

Requires a token with write access to the table’s Feature and (if scoped) Data: Write.

Step Endpoint Notes
1. Create the table (once) POST /api/v2/feature/model with {"name":"...","featureID":"...","kind":"dynamic","fields":[{"name":"ts"},{"name":"amount"}]} Requires an Admin, the owner of a personal Feature, or a maintainer of an organization Feature. Returns the table ID (table-…). fields is optional; on a Dynamic table its names both document the columns and declare the ones a query sees before the first write — see the rules below the table. Each entry is an object, not a bare string — {"name":"ts"} — and name is the only part you normally send. schemaPolicy is optional and Dynamic-only: "widen" (the default) or "strict" — see below. Send it on a PUT /api/v2/feature/model/{tableID} to change it later
2. Write rows POST /api/v2/feature/model/{tableID}/rows with {"rows":[{...}],"writeMode":"append"} Synchronous. Max 50,000 rows and 8 MiB of JSON per request. JSON types are honoured — a string is text ("020" keeps its leading zero), true/false is a boolean, a number is a number — so send numbers as numbers. Returns {tableID, rowsWritten, writeMode, partitionCount, warnings}
2b. Or upload Parquet POST /api/v2/feature/model/{tableID}/rows/parquet?writeMode=append, the file as the raw request body Same write, bulk envelope. Max 1 GiB, no row limit. Returns {tableID, bytesReceived, writeMode, partitionCount, warnings}
3. Query POST /api/v2/duckdb/query with {"sql":"SELECT ts, amount FROM {{ ref('table-…') }}"} No build required. Table IDs only — this endpoint resolves the marker straight to the table’s data, so a table name such as Sales.orders is not looked up here, only where SQL or code is saved. A table with no writes yet answers with no rows rather than an error. Name the columns rather than writing SELECT *: before the first write they come out alphabetically and afterwards in the order the data produced them, and a UNION pairs columns by position, so a SELECT * keeps working and starts pairing the wrong columns. Without fields, SELECT * returns a single placeholder column. ronja query from the CLI fails loudly on a query error, where this endpoint answers success with the error inside — see Query your data
Undo — empty the table POST /api/v2/feature/model/{tableID}/clear with {"reason":"..."} Same access as writing rows. Contents are snapshotted first
Undo — restore a snapshot GET /api/v2/feature/model/{tableID}/dynamic-versions, then POST /api/v2/feature/model/{tableID}/dynamic-versions/{versionID}/restore Same access as writing rows. Snapshots expire after 7 days

writeMode is append (the default) or replace, and is strict — "REPLACE" or any other value is a 400 rather than a guess. On the Parquet endpoint it is a query parameter rather than a body field, since the body is the file.

The Parquet upload must be the raw request body (curl --data-binary @data.parquet), never a form upload (curl -F): a multipart envelope is not a Parquet file and is rejected with a 400. Every upload is validated before anything is written — the footer must parse, there must be at least one column, and the data pages must decode — so a bad file is rejected and the table is untouched. Apart from the envelope and the size cap, the Parquet endpoint behaves exactly like the row push: same access, same writeMode semantics, same partition ceiling, same drift warnings, same Automation triggering, same snapshots.

append is at-least-once. A retry after a timeout can write the same batch twice. For a recurring refresh, send the whole current dataset with "writeMode":"replace" — that is the idempotent shape.

Appends add data files. Each append adds one, and Ronja merges small data files together in the background — in batches that share a shape (the same columns, column types, column order and nullability). A batch whose shape no other batch shares is not merged, and a table restored from any version is not merged again until its next replace or clear. On a table you append to steadily, partitionCount normally rises into the tens and then drops back after a merge, so that alone needs nothing. The count is capped: an append past the ceiling is refused with a 400 naming the two fixes. Only a count that keeps rising toward the ceiling calls for switching to replace or clearing the table.

Schema changes are widened, not refused. A new column is added, and earlier rows read null there. A column whose type changes is widened to a type that holds both — a whole number meeting a decimal reads as a decimal, anything meeting text reads as text, except binary data, which stays binary when it meets text. When the two types can’t be read together at all (a date meeting a number), the values in that write are stored as text and the column reads as text from then on; the rows already in the table are untouched. Two kinds of change are refused with a 400 naming the column, and nothing lands: a column changing between a nested shape — a list or an object — and anything else, and a column that can’t be read alongside what the table holds and can’t be rewritten as text either, which is a decimal, a time-of-day, a legacy-format timestamp, or one of the rarer binary-encoded types (a half-precision float, an interval, a geometry or geography value, or a BSON document — the binary form of JSON that MongoDB and some other databases export). Decimal is the one to watch, since that is what pandas and Spark write for money columns; send the column as text or as a plain number instead. A half-precision float is refused even when it meets a column of numbers — send it as an ordinary (32- or 64-bit) float. On a write that lands, warnings names each column that moved and says what happened to it; a refusal has no response body to carry warnings, so read the 400’s error message instead. The warnings strings are advisory prose for a person to read — their wording is not a stable contract, so don’t pattern-match them.

schemaPolicy: "strict" refuses instead. Set on the table at create or with a later PUT (Dynamic tables only; the default is "widen"), it requires every write to carry exactly the table’s columns with the same types — an added, missing or retyped column is a 400 and nothing lands. The nested-shape refusal applies under both policies. A column that has been widened reports widenedFrom (the type it carried before) and widenedAt on GET /api/v2/feature/model/field/table/{tableID}, the endpoint that lists the columns — not on GET /api/v2/feature/model/{tableID}, which carries no column types at all. Both are null on a column that has never been widened. See When a column changes type.

Columns take their types from the values you send. On the row push a JSON string is text — including a numeric-looking one such as "020", which keeps its leading zero, and whitespace is kept exactly as sent, without a warning, since text onto text is not a type change — true/false is a boolean, and a number is a number: stored as a whole number when it is written without a fraction or exponent and fits 64 bits (20), otherwise as a decimal (20.0, 1e3). At the ends of that range: a whole number too large for 64 bits is stored as a decimal and loses its low digits, and a number too large even for a decimal (1e400) is stored as text, which makes the column text. Send numbers as numbers: "amount": "12.50" makes a text column, and a text value arriving on a column that so far held numbers is a type change — widened to text with a warning under Widen, refused under Strict. A Dynamic table that existed before September 2026 keeps the older reading: its jsonTyping on GET /api/v2/feature/model/{tableID} is "legacy", a numeric-looking string is stored as a number, true/false as text, and every write to it says so in warnings, one line per column. It stays that way until you switch it — PUT /api/v2/feature/model/{tableID} with {"jsonTyping":"typed"} once your code sends numbers as numbers, or a clear, which switches it too; {"jsonTyping":"legacy"} switches it back. A table created since is "typed". When you switch a table whose code still sends numbers as quoted strings, true/false, or whole-number decimals such as 20.0, the first write afterwards is a type change: under Widen a quoted-number column widens to text once, and any arithmetic on it in a derived table stops working; a boolean onto a text column lands, but every such write comes back with a warning; a boolean onto a number column is stored as 1/0 and every such write warns until you send 1/0 instead. Under Strict the write is refused with a 400 until the payload matches the table or the schema policy is changed. Dates arrive as text on the row push, so cast — some_column::TIMESTAMP — ahead of a date function; the same cast is what makes a query work before the first write, when the columns have no type yet. Clearing the table keeps its column names, so a query that names them keeps working against the now-empty table; the types come back with the next write.

Every successful write counts as an update to the table: the Derived tables built from it rebuild themselves, and any Automation watching it fires, exactly as an in-app write would — except that a write sent with ?cascade=defer holds the rebuild until you release it (see Sending several tables at once). Every write’s response carries cascade, "immediate" or "deferred", saying which happened. A clear does the same, except that those rebuilds fail — a clear removes the table’s data and its column types, not just its rows, so there is nothing to build from — so the Derived tables keep their data from before the clear. The next write rebuilds them, and a write that lands while that failed rebuild is still running is picked up by a follow-up rebuild, so you can clear and reload back to back.

One limit worth planning around. Writing rows — and clearing or restoring, which need exactly the same access — requires write access to the table’s Feature: for a personal Feature that means its owner, or an Admin; for a shared one, an Admin or a maintainer of that Feature. Any other colleague who can write the table inside Ronja still gets a 403 over the API. Move the table to a shared Feature and make them a maintainer of it if a second person needs to push to it.

When one script sends several tables that a Derived table reads together, hold back each table’s rebuild and release them all in one call, so the Derived table rebuilds once over the whole set instead of once per table over a half-written set. The walkthrough, with a Python example, is Send several tables at once.

Step Endpoint Notes
1. Hold back the rebuild Add ?cascade=defer to POST …/rows, POST …/rows/parquet or POST …/clear on a Dynamic table, or to POST …/build on a file-fed Integration table immediate (the default) or defer; anything else is a 400. The data lands and the table’s own Automations and health checks run as usual; only the Derived tables built from it wait. The response’s cascade field says what happened: "deferred", or "immediate" when the hold could not be recorded and the write rebuilt its Derived tables at once. A response without the field came from a server that ignores the parameter. On /build, cascade is "deferred" when you asked for it — the build runs afterwards — and defer on a table that is already building is a 409, on any other kind of table a 400
2. Wait for every build Poll each /build as in the File push API Integration tables only. Start every build first, then wait. Dynamic writes need no wait
3. Release POST /api/v2/feature/model/cascade with {"tableIds":["table-…","table-…"]} 1 to 100 tables (a repeated ID counts once). Returns {"released":[…],"notPending":[…]}. Every Derived table built from the released tables rebuilds once, over all of them. Releases only what is still held, so repeating the call is free; notPending lists tables with nothing held — never held (including a write whose response said "immediate", and a build that failed before its data landed), or already released by an earlier call, by the automatic release, or by an ordinary write to the table. Needs write access to every named table and (if scoped) Data: Write

Automatic release. A held table is released on its own about 15–20 minutes after its first held write. A batch that takes longer than that is released in parts, so send everything first and release straight after.

While a table is held, the Derived tables built from it normally keep their previous data, and the held write doesn’t move their lastBuiltAt. A Derived table that also reads a table someone else changes after the first held write waits for the release instead of rebuilding over half the batch — at most the same 15–20 minutes — and shows as Waiting on an input on the Data map.

Bodies are always {"error": "<message>"}, on every endpoint. A missing or invalid token is always 401 Unauthorized.

The two data-push APIs above have separate error vocabularies — including two different meanings for 403 — so they are listed separately.

Status Message Cause
403 forbidden Role below Admin, or a scoped token without the Data: Write grant
400 invalid filename: must match [a-zA-Z0-9._-]+.parquet Bad filename
400 model is not a manual_integration table Wrong table kind
404 not found Nonexistent table ID, or one you cannot reach — the two are deliberately indistinguishable
400 uploaded file is not a valid parquet file (corrupt or missing footer): re-export the file and upload again Not a Parquet file, or a corrupt/truncated footer; the upload is discarded
400 uploaded parquet file has no columns The file declares an empty schema; the upload is discarded
400 uploaded parquet file is unreadable (…): re-export the file and upload again The footer parses but the data pages fail to decode; the upload is discarded
404 file not found DELETE on a missing file
413 file too large: max 1073741824 bytes Over 1 GiB — split the file client-side
400 invalid cascade "…": must be "immediate" (default) or "defer" Unrecognized cascade value on /build (case-sensitive)
400 cascade=defer on /build applies only to manual_integration tables, … ?cascade=defer on a Build of any other kind of table. A Dynamic table holds back its rebuild on its write endpoints instead
409 table is already building — wait for that build to finish, then build again with cascade=defer ?cascade=defer on a table whose build is still running: that build would rebuild the Derived tables as soon as it lands
409 a build of this table is already running — poll GET /feature/model/… until buildVerdict leaves "building", then build again if you still need to /build without cascade=defer on a table whose build is still running. The running build is not restarted, so the files you just uploaded may not be in it: wait for it, then build again
400 table … is a dynamic table: it is written, not built — … /build on a Dynamic table, which has nothing to build — write rows to it instead

Covers /rows, /rows/parquet, /clear, and the version-restore endpoint.

Status Message Cause
403 forbidden No write access to the table’s Feature — you are neither the owner of a personal Feature nor an Admin, or neither an Admin nor a maintainer of a shared one — or a scoped token without the Data: Write grant. Any role below User is refused outright
404 not found Nonexistent, or deleted, table ID
400 table … is a … table, not a dynamic one The table is not a Dynamic table
400 table … is a session-scoped scratch table and is not writable over HTTP … The table belongs to a chat session rather than a Feature; save it to a Feature first
400 no rows provided — \rows` must be a non-empty array of objects` Empty rows
400 row … is null, not an object; every element of rows must be a JSON object A null element in rows; the index is zero-based. Nothing lands
400 too many rows: … exceeds the limit of 50000 per request Split the batch
400 invalid writeMode "…": must be "append" (default) or "replace" Unrecognized writeMode (case-sensitive)
400 invalid cascade "…": must be "immediate" (default) or "defer" Unrecognized cascade value (case-sensitive)
400 table already has … committed partitions (limit …) Append ceiling — the background merge has not kept the table under it (batches of differing shape, or a table restored from a version). Re-push with replace, or clear the table
400 table …: schema conflict: "…": was …, incoming has …. A struct, list or map column cannot be read alongside a column of another shape, so this write is refused under every schema policy … A column changing between a nested shape — a list or an object — and anything else. Refused under both schema policies; nothing lands
400 table …: column(s) "…" could not be read alongside the column(s) the table already holds, and cannot be rewritten as text either: … A column whose type can’t be read beside the stored one and can’t be rendered as text: a decimal, a time-of-day, a legacy-format timestamp, or a half-precision float, interval, geometry, geography or BSON column. Refused under both schema policies; nothing lands. Send the column as text or as a plain number (a half-precision float as an ordinary 32- or 64-bit float, even beside a column of numbers)
400 table …: schema mismatch: added=[…] missing=[…] typed_differently=[…]. This table's schemaPolicy is "strict", so every write must carry exactly its columns with the same types … Under schemaPolicy: "strict" only: the write added a column, left one out, or changed a column’s type. Nothing lands
409 table is busy with another write — retry shortly Another writer holds the table’s lock; nothing was written
413 request body too large: limit is 8388608 bytes /rows body over 8 MiB — split the batch, or send Parquet
400 request body is not a valid Parquet file (corrupt or missing footer). Send the file as the RAW request body … Parquet upload that is not a Parquet file — most often a curl -F form upload; the upload is discarded
400 uploaded Parquet file has no columns Parquet upload declaring an empty schema; the upload is discarded
400 uploaded Parquet file is unreadable: re-export the file and upload again Parquet upload whose footer parses but whose data pages fail to decode; the upload is discarded
413 parquet file too large: max 1073741824 bytes Parquet upload over 1 GiB — split the file client-side

Covers POST /api/v2/feature/model/cascade. Every refusal except a 503 happens before anything is released.

Status Message Cause
403 forbidden A role below User, or a scoped token without the Data: Write grant
404 table … not found A table ID that doesn’t exist, one in another organization, or one you can’t write — deliberately the same answer for all three
400 tableIds must name at least one table Empty list
400 tableIds names … tables; at most 100 may be released in one call — split the set More than 100 distinct tables
400 tableIds must not contain an empty id An empty string in the list
400 table … is a derived table — only dynamic and manual_integration tables can have their writes deferred and released; … A Derived table has nothing of yours to release
400 table … is not a dynamic or manual_integration table — … Any other kind of table
400 table … is a session-scoped scratch table — … The table belongs to a chat session rather than a Feature
409 … is still building — wait for its build to finish, then trigger A named table’s build is still running. Nothing was released; wait for the build, then call again
503 the release collided with a concurrent change and released nothing — retry Nothing was released; retry
503 The release may already have applied. Retrying is safe; if the retry lists the tables as notPending, the release had already applied. The outcome is unknown; retry