Tables API
Create, manage, and read rows in user-managed data tables
Tables API
Data tables are tenant-scoped, schema-flexible tables you can use to persist workflow state, build small lookups, or maintain queues that humans resolve out-of-band. The same tables that show up under Data Store in the Studio (route /data) and that the Data Table block operates on are exposed via this REST API.
Every endpoint requires the standard x-api-key header described in Authentication. Reads require the workflows:read scope; writes require workflows:write.
Every table is owned by exactly one company. The API never returns rows belonging to a different company than the one the API key is scoped to.
Resource Shape
A table:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Processed Invoices",
"description": "Deduplication table for Stripe invoice events",
"columns": [
{ "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true },
{ "id": "amountCents", "name": "amountCents", "type": "number" }
],
"schema": {
"columns": [
{ "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true },
{ "id": "amountCents", "name": "amountCents", "type": "number" }
]
},
"metadata": {},
"maxRows": 10000,
"rowCount": 47,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-20T08:12:00.000Z"
}A row:
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"data": {
"stripeInvoiceId": "in_1234567890",
"amountCents": 9900
},
"position": null,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}Column Types
| Type | Description |
|---|---|
text | Free-form string |
number | Numeric value |
boolean | True or false |
date | ISO date string in YYYY-MM-DD form |
select | One value from an enumerated set (provide options: string[]) |
url | URL (string) |
email | Email address (string) |
json | Arbitrary nested JSON value |
Columns are advisory — the underlying storage is JSONB and any keys you write in data are preserved, even if they're not declared in columns. The Studio uses columns to render forms and CSV importers; workflows that write rows via the API can include extra fields without redefining the schema.
List Tables
GET /api/v1/tablesReturns every table the API key's company owns, ordered by createdAt descending.
Example
curl -X GET "https://workflow.loopfour.ai/api/v1/tables" \
-H "x-api-key: YOUR_API_KEY"Response
{
"success": true,
"data": [
{ "id": "...", "name": "Processed Invoices", "...": "..." },
{ "id": "...", "name": "Pending Approvals", "...": "..." }
]
}Create Table
POST /api/v1/tablesRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name (1-255 chars). |
description | string | No | Free-form description. |
columns | object[] | No | Column definitions (see Column Types). |
schema | object | No | Free-form schema metadata. When columns is provided it is merged into schema.columns. |
metadata | object | No | Free-form metadata (used by the Studio for view preferences). |
maxRows | number | No | Row cap (1 - 100,000, default 10,000). |
Example
curl -X POST "https://workflow.loopfour.ai/api/v1/tables" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Processed Invoices",
"description": "Deduplicate Stripe events",
"columns": [
{ "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true },
{ "id": "amountCents", "name": "amountCents", "type": "number" }
],
"maxRows": 25000
}'Response (201 Created)
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Processed Invoices",
"columns": [
{ "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true },
{ "id": "amountCents", "name": "amountCents", "type": "number" }
],
"maxRows": 25000,
"rowCount": 0,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T10:00:00.000Z"
}
}An explicit, unique column id containing 1-255 characters is preserved. When id is omitted, the API assigns a generated col_... storage key. Always use the IDs returned by the API for row keys, filters, and sorting; display-name changes do not change those IDs.
Import Table
POST /api/v1/tables/importCreates a table and imports its initial rows in one transaction. Use this for CSV-style imports or other bulk table creation flows where partial imports would be unsafe.
The route consumes one write rate-limit request for the whole import, not one request per row.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name (1-255 chars). |
description | string | No | Free-form description. |
columns | object[] | Yes | Column definitions (see Column Types). |
schema | object | No | Free-form schema metadata. columns is merged into schema.columns. |
metadata | object | No | Free-form metadata. |
maxRows | number | No | Row cap (1 - 100,000, default 10,000). The import is rejected if rows.length exceeds this value. |
rows | object[] | Yes | Row payloads. Keys beyond declared columns are preserved. |
Example
curl -X POST "https://workflow.loopfour.ai/api/v1/tables/import" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Imported Customers",
"columns": [
{ "id": "email", "name": "email", "type": "email", "required": true },
{ "id": "tier", "name": "tier", "type": "text" }
],
"rows": [
{ "email": "alice@example.com", "tier": "enterprise" },
{ "email": "bob@example.com", "tier": "self-serve" }
]
}'Response (201 Created)
Returns the created table with rowCount set to the number of imported rows.
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Imported Customers",
"columns": [
{ "id": "email", "name": "email", "type": "email", "required": true },
{ "id": "tier", "name": "tier", "type": "text" }
],
"maxRows": 10000,
"rowCount": 2,
"createdAt": "2024-01-15T10:00:00.000Z",
"updatedAt": "2024-01-15T10:00:00.000Z"
}
}If row insertion fails, the table creation is rolled back. The API returns 400 BAD_REQUEST when rows.length exceeds maxRows.
Import columns must resolve to unique storage IDs containing 1-255 characters. An omitted id resolves to that column's name so existing row-object keys remain connected; if two explicit or name-derived IDs collide, the import is rejected instead of silently reminting an ID that would make its row values unreadable. For duplicate display names, supply distinct explicit IDs.
Get Table
GET /api/v1/tables/:idReturns a single table including its current rowCount and columns.
Returns 404 NOT_FOUND if the table does not exist or belongs to another company.
Update Table
PATCH /api/v1/tables/:idPatch one or more table fields. Any field omitted from the body is left unchanged.
Request Body
| Field | Type | Description |
|---|---|---|
name | string | Rename the table. |
description | string | Replace description. |
columns | object[] | Replace schema.columns entirely. |
schema | object | Replace the full schema JSONB. |
metadata | object | Replace metadata JSONB. |
maxRows | number | Adjust the row cap (1 - 100,000). |
columns is a full replacement of schema.columns. To add a column, send the existing columns plus the new one — partial updates are not supported.
Delete Table
DELETE /api/v1/tables/:idDeletes the table and cascades to every row it contains.
{ "success": true, "data": { "deleted": true } }Table Folders
Table folders provide one level of company-scoped organization in the Studio Data Store. They are separate from workspace-file folders. A table can belong to one folder or to the Data Store root. Folders cannot be nested, renamed, or deleted in the current Studio or API, and there is no application-defined limit on how many folders a company can create.
List Table Folders
GET /api/v1/table-foldersReturns folders in name order. tableIds contains the company-owned tables currently assigned to each folder.
{
"success": true,
"data": [
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"name": "July",
"tableIds": ["550e8400-e29b-41d4-a716-446655440000"],
"createdAt": "2024-07-01T10:00:00.000Z",
"updatedAt": "2024-07-01T10:00:00.000Z"
}
]
}Create Table Folder
POST /api/v1/table-folderscurl -X POST "https://workflow.loopfour.ai/api/v1/table-folders" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "July" }'name is trimmed and must contain 1-255 characters. Folder names are unique within a company using case-sensitive comparison, so Sales and sales are distinct names. The response is 201 Created; an exact duplicate returns 409 CONFLICT.
Move Table To A Folder Or Root
PATCH /api/v1/table-folders/tables/:tableIdPass a company-owned folder UUID, or null to move the table back to the Data Store root.
curl -X PATCH "https://workflow.loopfour.ai/api/v1/table-folders/tables/$TABLE_ID" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "folderId": "770e8400-e29b-41d4-a716-446655440002" }'{
"success": true,
"data": {
"tableId": "550e8400-e29b-41d4-a716-446655440000",
"folderId": "770e8400-e29b-41d4-a716-446655440002"
}
}The API returns 404 NOT_FOUND if either ID does not belong to the API key's company.
Query Rows
POST /api/v1/tables/:id/rows/queryThis is the recommended endpoint for typed filters and Studio-equivalent reads. The query is a JSON body, so selected table values do not become part of the request URL.
This POST .../query route is a read-only JSON-body companion to the retained GET /api/v1/tables/:id/rows route, not a second row resource and not a row-creation operation. Both use the read scope and return the same row and pagination shape. Use the body route for new structured queries; the GET route remains for existing query-string callers.
Request Body
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | 1 - 100 rows per page. |
offset | number | 0 | Zero-based, non-negative integer offset within the filtered result set. |
filter | object | None | Filters keyed by stable column ID. Structured clauses are described below. |
sort | string | None | Stable column ID to sort by. The API validates it against the stored schema and applies type-aware ordering. |
order | asc or desc | asc | Direction used when sort is present. |
Filters on separate columns are combined with AND. A structured column clause can contain a condition, a list of exact included values, or exact excludedValues. values and excludedValues are mutually exclusive. Included values use OR within the column; an exclusion rule retains every value not present in its list. When both a condition and a value rule are present, both must match.
- Omit both value lists to select every value.
values: []deliberately matches no rows.excludedValues: ["Archived"]means every exact raw JSON value except"Archived", including values outside the current distinct-value page.nullin a value list represents the blank bucket: a missing key, JSONnull, or an empty string.
Condition operators are type-aware:
| Column type | Supported operators |
|---|---|
text, email, url, select | contains, not_contains, equals, not_equals, starts_with, ends_with, is_empty, is_not_empty |
number | equals, not_equals, greater_than, greater_than_or_equal, less_than, less_than_or_equal, is_empty, is_not_empty |
date | equals, not_equals, greater_than, greater_than_or_equal, less_than, less_than_or_equal, between, is_empty, is_not_empty |
boolean | equals, not_equals, is_empty, is_not_empty |
json | contains, not_contains, equals, not_equals, is_empty, is_not_empty |
Text-style condition matching is case-insensitive, including equals and not_equals. Exact values and excludedValues lists compare the stored raw JSON values instead, so they remain case-sensitive for strings.
Date conditions accept YYYY-MM-DD operands. The between operator takes { "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }, includes both endpoints, and rejects a start after the end. Datetime strings are rejected so offset timestamps are never compared or sorted lexically as if they represented chronological instants. In Studio, date columns use calendar inputs and date-specific labels (Is on, Is before, and Is after), along with inclusive Last 30 days, Last 3 months, Last 12 months, and Custom range controls. The rolling presets end on the user's current local date; the 30-day preset includes today plus the previous 29 days, while month presets preserve the day of month and clamp to the last valid day when necessary.
Studio creates and edits filter clauses only through each column header menu; the retired page-level filter builder is not present. When clauses are active, the grid toolbar shows one removable column · operator · value chip per clause and offers Clear all filters when more than one clause is active. Sorting is authored in the header and summarized by a removable Sorted by <column> ↑/↓ chip.
Without sort, rows are returned in position, createdAt, then row-ID order. A typed sort uses the requested column first and those same fields as stable tie-breakers.
Filter Example
curl -X POST "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/rows/query" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filter": {
"status": { "values": ["active", "pending"] },
"amountCents": { "condition": { "operator": "greater_than", "value": 5000 } }
},
"sort": "amountCents",
"order": "desc",
"limit": 50,
"offset": 0
}'The body accepts at most 20 filtered columns, 500 included or excluded values per column, 1,000 exact values across the request, 50,000 serialized filter characters, and 2,000 serialized characters per condition or exact value. The complete JSON request body is limited to 512 KiB before parsing; a larger body returns 413 PAYLOAD_TOO_LARGE.
Legacy GET Compatibility
GET /api/v1/tables/:id/rows remains available for existing callers. It accepts limit, offset, sort, and order as query parameters, and its filter parameter is a URL-encoded JSON object string. Legacy scalar entries such as {"status":"pend"} retain case-insensitive substring matching. On this GET row route only, bounded scalar entries may also target non-empty JSONB row keys that are not declared in the table schema. Structured filter clauses and sort must reference declared column IDs, and new structured integrations should use the JSON-body query endpoint so filter data stays out of URLs.
This compatibility contract applies to external GET API callers. It does not retain the retired Studio page-level top filter; Studio uses per-column filters through the structured body-query routes.
Response
{
"success": true,
"data": [
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"data": { "stripeInvoiceId": "in_1234567890", "amountCents": 9900 },
"position": null,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
],
"meta": {
"total": 1,
"limit": 50,
"offset": 0,
"hasMore": false
}
}meta.total is counted after filters are applied, so it can be used directly for filtered pagination. An offset equal to or greater than meta.total returns an empty data page with the filtered total preserved and hasMore: false.
Query Distinct Column Values
POST /api/v1/tables/:id/columns/:columnId/values/queryReturns counted exact values across the full company-scoped table, not just one row page. Filters for other columns remain active; any filter for :columnId itself is ignored so the caller can rebuild that column's checkbox list.
| Body field | Type | Default | Description |
|---|---|---|---|
filter | object | None | Same structured filter contract as Query Rows. |
search | string | None | Case-insensitive substring search across the returned display label (maximum 255 characters). |
limit | number | 100 | 1 - 500 distinct values. |
As with the row body-query route, the complete JSON request body is limited to 512 KiB before parsing and returns 413 PAYLOAD_TOO_LARGE when exceeded.
curl -X POST "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/columns/status/values/query" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filter": { "region": { "values": ["West"] } },
"search": "pend",
"limit": 100
}'{
"success": true,
"data": {
"values": [
{ "value": "Pending", "label": "Pending", "count": 12 }
],
"truncated": false
}
}Each entry includes its exact raw JSON value, a canonical display/search label, and its row count. Blank values use value: null and label: "(Blanks)"; searching for (Blanks) therefore finds that bucket. JSON objects and arrays use the same server-generated text for display and search. When more than limit values match—or a stored value exceeds the filter contract's safe round-trip size—truncated is true; narrow search and request again.
The distinct-values surface is new and offers two read-only forms rather than two stored resources. GET /api/v1/tables/:id/columns/:columnId/values is the query-string alternative, accepting URL-encoded filter, search, and limit parameters; scalar filters on declared columns use substring matching. POST .../values/query is the structured JSON-body companion used by Studio. Unlike the row GET compatibility case, the target column and every filtered column must be declared column IDs. Both forms return the same distinct-value shape, and new structured integrations should prefer the JSON-body endpoint.
Create Row
POST /api/v1/tables/:id/rowsRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | Row payload. Keys beyond declared columns are preserved. |
Returns 201 Created with the new row, or 400 BAD_REQUEST with "Table has reached its maximum row limit of N" when the table is full.
Example
curl -X POST "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/rows" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "stripeInvoiceId": "in_1234567890", "amountCents": 9900 } }'Update Row
PATCH /api/v1/tables/:id/rows/:rowIdBy default, replaces the entire data field with the value in the request body. Pass "merge": true to apply the body as a shallow patch instead: only the keys you send are written, every other key on the row is preserved, and you do not need to read the row first. A null value is stored as JSON null; it does not remove the key.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | Row payload. Replaces the existing data JSONB, or is shallow-merged onto it when merge is true. |
merge | boolean | No | Defaults to false. When true, keys absent from data retain their current values; nested objects are replaced at the top-level key. |
curl -X PATCH "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/rows/$ROW_ID" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "qboId": "42" }, "merge": true }'Returns 404 NOT_FOUND if the row does not belong to the given table or company.
Delete Row
DELETE /api/v1/tables/:id/rows/:rowIdDeletes one row and decrements rowCount on the table.
{ "success": true, "data": { "deleted": true } }Error Responses
| HTTP | Code | Trigger |
|---|---|---|
400 | BAD_REQUEST | Validation failure or row-limit reached |
401 | UNAUTHORIZED | Missing or invalid x-api-key |
403 | FORBIDDEN | API key lacks workflows:read / workflows:write |
404 | NOT_FOUND | Table or row does not exist for this company |
409 | CONFLICT | A table folder with that company-scoped name already exists |
413 | PAYLOAD_TOO_LARGE | A structured row or distinct-value query body exceeds 512 KiB |
429 | RATE_LIMITED | Over the read (1000/min) or write (100/min) bucket |
Audit Logging
POST /api/v1/tables, POST /api/v1/tables/import, PATCH, and DELETE on table routes emit audit events with actions table.created, table.updated, and table.deleted. Creating a folder emits table_folder.created; moving a table emits table.moved. Row writes do not emit per-row audit events — query /api/v1/audit-logs for table-level and folder events.
Use From Workflows
Workflows shouldn't usually call this API directly — use the Data Table block instead. The block dispatches to the same storage through the workflow runtime, which handles tenant scoping, validation, and row-count bookkeeping inside a single transaction.
Aggregate Rows
POST /api/v1/tables/:id/rows/aggregate requires workflows:read and the same company-scoped authentication as row queries. Use it for a single-table chart or KPI; prepare joins and reporting metrics upstream. For customer revenue reporting, prepare one table with customer_id, customer_name, month, mrr, and arr.
curl -X POST "https://workflow.loopfour.ai/api/v1/tables/550e8400-e29b-41d4-a716-446655440000/rows/aggregate" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tableId":"550e8400-e29b-41d4-a716-446655440000","groupBy":"month","timeGrain":"month","aggregate":{"fn":"sum","columnId":"mrr"},"limit":100}'| Field | Contract |
|---|---|
tableId | UUID matching :id, owned by the authenticated company |
filter | Optional structured row filter, with the same operators and bounds as /rows/query; filters on groupBy apply too |
groupBy, timeGrain | Optional column id; grain requires a date column and is day, week (Monday start), month, quarter, or year |
aggregate | Required here: {fn, columnId?}; fn is count, count_distinct, sum, avg, min, or max; only count may omit the column |
order, limit | Optional label_asc, label_desc, value_asc, or value_desc; integer limit 1–500, default 100 |
sum and avg require a declared number column. min and max compare declared number and date columns in their native order, otherwise text. Invalid typed values are excluded and counted; date grains accept only valid YYYY-MM-DD values, so ISO timestamps must be prepared upstream. Numeric aggregates are display values, not reconciled accounting figures.
The response is {success: true, data: {data: [{label, value}], rowCount, groupCount, totalGroupCount, excludedRows, truncated, asOf}}. rowCount counts source rows matching the filters; groupCount counts returned groups and totalGroupCount counts groups before the limit. asOf is the successful fetch time. An empty grouped result returns data: [] successfully. Unknown columns and incompatible operations return 400; missing or foreign-company tables return 404. Requests use the read rate limit, a 512 KiB body limit, and a three-second database statement timeout.
Connect a dashboard widget
Dashboard widgets are json-render specs. A spec reads table data through a loadData action that writes its result to a /data/<key> state path; components bind {"$state":"/data/<key>/value"} (KPIs) or {"$state":"/data/<key>/rows"} (charts, tables, pivots). A loadData action takes tableId plus the query fields above: with aggregate it calls /rows/aggregate, without it /rows/query. Set "limit": 500 on a rows-mode load that feeds a table, pivot, cohort heatmap, or CSV export.
Widget POST/PATCH (see Dashboards) validates the spec, checks every tableId against the dashboard company, and rejects invalid column references. In Studio, Copilot authors these specs with the jsonRenderWidget tool.
An open dashboard runs its loads on render, again when a filter changes, and on refresh. A failed refresh keeps the previous result and marks it stale. Public share links show live data through POST /api/v1/dashboards/share/:token/data when the deployment enables it.
Loopfour