Loopfour
API Reference

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

TypeDescription
textFree-form string
numberNumeric value
booleanTrue or false
dateISO date string in YYYY-MM-DD form
selectOne value from an enumerated set (provide options: string[])
urlURL (string)
emailEmail address (string)
jsonArbitrary 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/tables

Returns 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/tables

Request Body

FieldTypeRequiredDescription
namestringYesDisplay name (1-255 chars).
descriptionstringNoFree-form description.
columnsobject[]NoColumn definitions (see Column Types).
schemaobjectNoFree-form schema metadata. When columns is provided it is merged into schema.columns.
metadataobjectNoFree-form metadata (used by the Studio for view preferences).
maxRowsnumberNoRow 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/import

Creates 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

FieldTypeRequiredDescription
namestringYesDisplay name (1-255 chars).
descriptionstringNoFree-form description.
columnsobject[]YesColumn definitions (see Column Types).
schemaobjectNoFree-form schema metadata. columns is merged into schema.columns.
metadataobjectNoFree-form metadata.
maxRowsnumberNoRow cap (1 - 100,000, default 10,000). The import is rejected if rows.length exceeds this value.
rowsobject[]YesRow 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/:id

Returns 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/:id

Patch one or more table fields. Any field omitted from the body is left unchanged.

Request Body

FieldTypeDescription
namestringRename the table.
descriptionstringReplace description.
columnsobject[]Replace schema.columns entirely.
schemaobjectReplace the full schema JSONB.
metadataobjectReplace metadata JSONB.
maxRowsnumberAdjust 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/:id

Deletes 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-folders

Returns 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-folders
curl -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/:tableId

Pass 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/query

This 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

ParameterTypeDefaultDescription
limitnumber501 - 100 rows per page.
offsetnumber0Zero-based, non-negative integer offset within the filtered result set.
filterobjectNoneFilters keyed by stable column ID. Structured clauses are described below.
sortstringNoneStable column ID to sort by. The API validates it against the stored schema and applies type-aware ordering.
orderasc or descascDirection 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.
  • null in a value list represents the blank bucket: a missing key, JSON null, or an empty string.

Condition operators are type-aware:

Column typeSupported operators
text, email, url, selectcontains, not_contains, equals, not_equals, starts_with, ends_with, is_empty, is_not_empty
numberequals, not_equals, greater_than, greater_than_or_equal, less_than, less_than_or_equal, is_empty, is_not_empty
dateequals, not_equals, greater_than, greater_than_or_equal, less_than, less_than_or_equal, between, is_empty, is_not_empty
booleanequals, not_equals, is_empty, is_not_empty
jsoncontains, 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/query

Returns 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 fieldTypeDefaultDescription
filterobjectNoneSame structured filter contract as Query Rows.
searchstringNoneCase-insensitive substring search across the returned display label (maximum 255 characters).
limitnumber1001 - 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/rows

Request Body

FieldTypeRequiredDescription
dataobjectYesRow 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/:rowId

By 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

FieldTypeRequiredDescription
dataobjectYesRow payload. Replaces the existing data JSONB, or is shallow-merged onto it when merge is true.
mergebooleanNoDefaults 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/:rowId

Deletes one row and decrements rowCount on the table.

{ "success": true, "data": { "deleted": true } }

Error Responses

HTTPCodeTrigger
400BAD_REQUESTValidation failure or row-limit reached
401UNAUTHORIZEDMissing or invalid x-api-key
403FORBIDDENAPI key lacks workflows:read / workflows:write
404NOT_FOUNDTable or row does not exist for this company
409CONFLICTA table folder with that company-scoped name already exists
413PAYLOAD_TOO_LARGEA structured row or distinct-value query body exceeds 512 KiB
429RATE_LIMITEDOver 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}'
FieldContract
tableIdUUID matching :id, owned by the authenticated company
filterOptional structured row filter, with the same operators and bounds as /rows/query; filters on groupBy apply too
groupBy, timeGrainOptional column id; grain requires a date column and is day, week (Monday start), month, quarter, or year
aggregateRequired here: {fn, columnId?}; fn is count, count_distinct, sum, avg, min, or max; only count may omit the column
order, limitOptional 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.

On this page