Query
Rekor provides two ways to query data: SQL queries for agents and advanced use cases, and a Filter DSL for the frontend list endpoint and trigger conditions.
SQL Query
Execute read-only SQL queries against base data. Supports filtering, aggregation, JOINs across record_types, CTEs, and array operations.
Endpoint
POST /v1/{base_id}/sql
Request
{
"query": "SELECT ... FROM records WHERE org_id = {org_id:String} AND base_id = {base_id:String} AND ...",
"params": { "custom_param": "value" }
}
Response
{
"data": [ { "column": "value", ... }, ... ],
"meta": { "rows": 100, "has_more": false }
}
Rules
- Always include BOTH
org_id = {org_id:String}ANDbase_id = {base_id:String}— a base id is unique per-org, not globally, so both are required to isolate your data. Both placeholders are auto-filled server-side from your authenticated org and the base in the URL - Always include
deleted = falseto exclude soft-deleted records - The
recordstable also hasarchivedandcancelledcolumns — addarchived = falseto query only active records - Read-only — only
SELECTstatements are allowed - Queries always see the latest version of each row — the server handles deduplication
- Max 1000 rows per query (automatically enforced)
- 10 second timeout, 512MB memory limit
Available tables
| Table | Description |
|---|---|
records | All records across record_types. Filter by record_type = 'name'. |
relationships | All relationships. Filter by rel_type = 'name'. |
record_types | RecordType metadata and schemas. |
bases | Base metadata. |
operations_log | Audit log of all write operations. |
organization | Org-level metadata (plan, status). Requires an {org_id:String} predicate. |
Accessing JSON fields
Record data is stored in a data column (JSON type). Access fields using subcolumn syntax:
-- String field
data.status.:String
-- Numeric field (use CAST for type-safe conversion)
CAST(data.amount, 'Float64')
-- Date filtering
CAST(data.due_at.:String, 'Date') < today()
Working with arrays
Arrays embedded in JSON can be queried using subcolumn syntax and array functions:
-- Sum values from an array of objects
arraySum(CAST(data.line_items[].amount, 'Array(Float64)'))
-- Explode array elements into rows
SELECT item.description.:String, CAST(item.amount, 'Float64')
FROM records
ARRAY JOIN data.line_items[] as item
WHERE org_id = {org_id:String} AND base_id = {base_id:String}
AND record_type = 'invoices'
AND deleted = false
CTEs and JOINs
Use Common Table Expressions to join data across tables. This is useful for reconciliation queries that combine records with relationship metadata:
WITH
inv AS (
SELECT id, data.invoice_number.:String as invoice_number,
arraySum(CAST(data.line_items[].amount, 'Array(Float64)')) as total
FROM records
WHERE org_id = {org_id:String} AND base_id = {base_id:String}
AND record_type = 'invoices' AND deleted = false
),
pay AS (
SELECT target_id,
sum(CAST(data.allocated, 'Float64')) as total_paid
FROM relationships
WHERE org_id = {org_id:String} AND base_id = {base_id:String}
AND rel_type = 'payment_for' AND deleted = false
GROUP BY target_id
)
SELECT inv.invoice_number, inv.total,
coalesce(pay.total_paid, 0) as paid,
inv.total - coalesce(pay.total_paid, 0) as balance
FROM inv
LEFT JOIN pay ON pay.target_id = inv.id
ORDER BY balance DESC
Cross-Base Query
Query data across all bases in an account. Useful for dashboards, reporting, and analytics that span multiple bases.
Endpoint
POST /v1/orgs/{org_id}/sql
Request
{
"query": "SELECT base_id, record_type, count() as record_count FROM records WHERE org_id = {org_id:String} AND deleted = false GROUP BY base_id, record_type ORDER BY record_count DESC"
}
Uses {org_id:String} instead of {base_id:String}. The token must have read:records permission. Results are scoped to the token's org.
Examples
-- Record counts across all bases
SELECT base_id, count() as total
FROM records
WHERE org_id = {org_id:String} AND deleted = false
GROUP BY base_id
-- Search for a record across all bases
SELECT base_id, record_type, id, data.name.:String as name
FROM records
WHERE org_id = {org_id:String}
AND deleted = false
AND data.name.:String ILIKE '%acme%'
Filter DSL
JSON filter expressions used by the frontend list endpoint (GET /v1/{base_id}/records/{record_type}?filter=...) and trigger conditions.
List queries return active records by default — archived records are excluded. To query archived records, add a filter condition on archived (e.g. {"field":"archived","op":"eq","value":true}); your condition then decides which records you see. This applies to record and relationship lists (record traversal is active-only, with no override); raw SQL queries apply no default — add archived = false yourself for active-only results.
Simple filter
{
"field": "data.status",
"op": "eq",
"value": "active"
}
Compound filter
{
"and": [
{ "field": "data.status", "op": "eq", "value": "issued" },
{ "field": "data.amount", "op": "gt", "value": 5000 }
]
}
Operators
| Operator | Description | Example |
|---|---|---|
eq | Equal | {"field":"data.status","op":"eq","value":"active"} |
neq | Not equal | {"field":"data.status","op":"neq","value":"deleted"} |
gt | Greater than | {"field":"data.amount","op":"gt","value":100} |
gte | Greater than or equal | {"field":"data.amount","op":"gte","value":100} |
lt | Less than | {"field":"data.amount","op":"lt","value":100} |
lte | Less than or equal | {"field":"data.amount","op":"lte","value":100} |
in | In array | {"field":"data.status","op":"in","value":["active","pending"]} |
not_in | Not in array | {"field":"data.status","op":"not_in","value":["deleted"]} |
like | Pattern match (case-sensitive) | {"field":"data.name","op":"like","value":"%Jane%"} |
ilike | Pattern match (case-insensitive) | {"field":"data.name","op":"ilike","value":"%jane%"} |
search | Fuzzy/approximate text match — results are ranked by closeness, each carrying a _search_score (0–1). Query-only (not valid in trigger conditions). | {"field":"data.car_model","op":"search","value":"honda civic"} |
is_null | Is null | {"field":"data.email","op":"is_null"} |
is_not_null | Is not null | {"field":"data.email","op":"is_not_null"} |
has | Array contains | {"field":"data.tags","op":"has","value":"urgent"} |
The search operator matches a field by approximate value (when you have a near-correct string but not the exact stored one). Combine it with exact operators in the same filter to narrow first, then rank; results come back ordered by _search_score (highest first). Every field is searchable by default — set an optional x-search hint on the field in the record_type schema (mode: name | text | fuzzy, threshold, or searchable: false to forbid it) to tune matching.
Datetimes
Declare datetime fields in the record_type schema with "format": "date-time" (or "format": "date"). Set an optional record_type x-timezone (e.g. "America/Sao_Paulo") — or a base-wide settings.timezone — to interpret values written without an explicit offset; the default is UTC. eq, neq, in, not_in and the range operators (gt/gte/lt/lte) match datetimes by point in time, so the exact representation you pass (T vs space separator, with or without an offset) does not change the result; a value with no offset is read in the configured timezone. Use eq for an exact datetime — like/ilike are substring matching only. Raw SQL queries are not rewritten this way, so match the stored value as returned.
Sorting
{
"sort": [
{ "field": "data.created_at", "direction": "desc" },
{ "field": "data.name", "direction": "asc" }
]
}
Pagination
Use limit and offset query parameters:
GET /v1/{base_id}/records/{record_type}?limit=20&offset=40
Field selection
Return a subset of fields with the fields parameter:
GET /v1/{base_id}/records/{record_type}?fields=id,data.name,data.status