JustPaid Workflows
Blocks

Data Table Block

Read and write rows in user-managed data tables from a workflow step

The Data Table block performs CRUD operations against a user-managed data table. Use it to persist workflow state across runs, build small lookup tables, deduplicate records, or share data between workflows.

Data tables are first-class objects in the workspace — you create the table once in the Studio (Data section) or via the Tables API, then any workflow can read and write its rows.

Data tables are scoped to a company. Every operation is tenant-isolated — a workflow running in company A can never read or write a table that belongs to company B.

When to Use

  • Workflow state. Track which records have already been processed so subsequent runs skip them.
  • Lookups. Map incoming external IDs to internal IDs without round-tripping to a CRM.
  • Light reporting. Append a row per run for downstream aggregation in your BI tool.
  • Approval queues. Insert a row representing pending work that a human resolves out-of-band.

For larger volumes, frequent reads, or relational data, use a real database via the Code block or a dedicated integration. Each table is capped at maxRows rows (default 10,000, max 100,000).

Configuration

Operation

The Data Table block dispatches on the operation field:

OperationBehaviour
insertAppend a new row. Fails if the table is at maxRows.
updateApply a JSONB merge to every row whose data->>matchKey equals matchValue.
upsertUpdate if a match exists, otherwise insert.
deleteDelete every row matching matchKey/matchValue.
findReturn rows whose data contains the provided filter JSON, ordered by createdAt DESC.
getByIdLook up a single row by its row UUID. Throws if not found.

Inputs

FieldTypeRequired forDescription
operationstringallOne of the operations above.
tableIdstring (UUID)allTarget table. Populated by the Studio from GET /api/v1/tables.
dataobjectinsert, update, upsertRow payload. For update/upsert, merged into the existing row's data with the Postgres `
matchKeystringupdate, upsert, deleteColumn name on the row used to find existing records. Compared as text via data->>matchKey.
matchValuestringupdate, upsert, deleteValue to compare against. Coerced to text — references like {{steps.previous.result.id}} resolve at runtime.
filterobjectfindJSONB containment predicate (data @> filter).
limitnumberfindMax rows returned (default 50, max 500).
rowIdstring (UUID)getByIdRow UUID to retrieve.

Outputs

OutputTypeDescription
resultjsonOperation result — the inserted/found/fetched row, an { updated: number } count, an { rows: [...] } list, or an { deleted: number } count depending on the operation.
successbooleantrue when the operation finished without throwing.

Examples

Insert a row after a webhook fires

{
  "type": "data_table",
  "config": {
    "operation": "insert",
    "tableId": "{{tables.processed_invoices.id}}",
    "data": {
      "stripeInvoiceId": "{{trigger.data.object.id}}",
      "customerEmail": "{{trigger.data.object.customer_email}}",
      "amountCents": "{{trigger.data.object.amount_paid}}",
      "processedAt": "{{run.startedAt}}"
    }
  }
}

Upsert by an external key

{
  "type": "data_table",
  "config": {
    "operation": "upsert",
    "tableId": "{{tables.contacts.id}}",
    "matchKey": "email",
    "matchValue": "{{steps.parse.result.email}}",
    "data": {
      "email": "{{steps.parse.result.email}}",
      "lastSeenAt": "{{run.startedAt}}"
    }
  }
}

Find rows by a property

{
  "type": "data_table",
  "config": {
    "operation": "find",
    "tableId": "{{tables.queue.id}}",
    "filter": { "status": "pending" },
    "limit": 25
  }
}

The returned result.rows is the standard row shape:

[
  {
    "id": "row_550e8400-e29b-41d4-a716-446655440000",
    "tableId": "tbl_660e...",
    "companyId": "00000000-0000-0000-0000-000000000001",
    "data": { "status": "pending", "...": "..." },
    "position": null,
    "createdAt": "2024-01-15T10:30:00.000Z",
    "updatedAt": "2024-01-15T10:30:00.000Z"
  }
]

Delete by external ID

{
  "type": "data_table",
  "config": {
    "operation": "delete",
    "tableId": "{{tables.processed_invoices.id}}",
    "matchKey": "stripeInvoiceId",
    "matchValue": "{{trigger.data.object.id}}"
  }
}

Error Handling

The block throws (and the step fails with STEP_ERROR) when:

  • The table does not exist for the current company.
  • An insert would exceed maxRows.
  • getById is called with a row ID that does not exist.
  • data_table config fails Zod validation (e.g. operation: "merge" instead of upsert).

update, upsert, and delete against zero matching rows succeed and report updated: 0 / deleted: 0 — they are not treated as errors.

matchKey/matchValue is a text comparison against data->>matchKey. Numeric IDs stored as JSON numbers still compare correctly because they are stringified on both sides, but be careful with boolean or null values — pass them as the explicit strings "true", "false", or "".

Limits

LimitValue
Rows per table (default)10,000
Rows per table (max)100,000
Max rows returned by find500
Max columnsunbounded (column metadata is advisory)

find uses a GIN index on the data JSONB column, so containment queries (data @> { ... }) stay fast even on large tables.

Frequently Asked Questions

On this page