Preview
Runs an audience criteria against BigQuery and returns a sample of real rows plus the total, from the body (not yet persisted) or by id of a saved criteria.
Preview
Runs an audience criteria against BigQuery (bounded by limit) and returns
a sample of the real rows it matches plus the total of the universe. A single
engine serves two entry points:
POST /api/audience-criterias/preview-from-body— runs a definition sent in the body (not yet persisted).POST /api/audience-criterias/:id/preview— runs an already saved criteria, referenced by itsid.
Both return the same response shape:
IPreviewAudienceCriteriaResult.
Unlike Generate SQL, preview does not return SQL; and unlike Validate (which only does a dry-run), preview does bring data: it runs the query and shows you rows.
Within a project, every audience other than the root audience runs intersected with the project's root audience — see Intersection with the root audience.
Auth: Required — audience-criteria:view-sql permission (both endpoints).
Preview consumes BigQuery compute on every call (it runs the real audience,
not a dry-run). To bound the cost, each query runs with a maximumBytesBilled of
10 GB (10737418240 bytes) and a 30 s timeout (jobTimeoutMs): a query
that exceeds either limit is rejected with 400 instead of being billed. Use
a small limit while you iterate.
The SQL that runs points to BigQuery: it references tables as
`<gcp-project>.<dataset>.<table>`, where the <gcp-project> (the Google Cloud
project, not a Reten project) comes from the API's environment configuration and the <dataset> is resolved from the tenant. Neither is part
of the input contract.
POST /api/audience-criterias/preview-from-body
Runs a definition sent in the body, without persisting it.
Auth: Required — audience-criteria:view-sql permission
The endpoint responds 201 Created —not 200—, even though it creates no
resource.
Request Body
Root level (PreviewFromBodyDto): the criteria definition (CriteriaDefinitionDto)
plus the limit field.
| Field | Type | Required | Description |
|---|---|---|---|
queryBlocks | QueryBlockDto[] | Yes | At least 1 element. Each block ≈ a complete SQL statement. |
composedWith | CreateCompositionDto[] | No | Composes this definition with other audiences via set operations. |
limit | number (integer) | No | Size of the rows sample. Integer 1..500. Default 100 (see limit). |
criteriaId | string (UUID) | null | No | The saved audience this definition edits: the draft takes that audience's identity, so it gives the same result that audience will give once saved (see Intersection with the root audience). Absent or null: a new audience. It must be a valid UUID (otherwise, 400) of an audience of the active project (otherwise, 404). |
Unlike Create, the definition
does not carry name or description: it is just the query tree plus limit. The
validation is strict, so sending name/description (or any other unknown
property) produces a 400.
Criteria Definition
It is the same tree that Create receives, without the
name/description fields. It is documented here in a self-contained way.
queryBlocks[] — query block
Each element of queryBlocks (QueryBlockDto) is a complete SQL statement:
| Field | Type | Required | Description |
|---|---|---|---|
order | number (integer) | Yes | Block position; integer ≥ 0. |
setOperator | SetOperatorEnum | No | Combines this block with the previous one. Absent in the first block; required in the rest (see rules). |
from | FromDto | Yes | Data source: main table + joins. |
select | SelectDto | Yes | Projection (columns and/or aggregations). |
timeWindow | TimeWindowDto | No | Time window of the block (discriminated union by timeOperator). |
where | WhereDto | Yes | Predicate tree. Required even if it is an empty group. |
groupBy | ColumnRefDto[] | No | Grouping columns. |
having | HavingDto | No | Filter over aggregates. |
The setOperator of a query block combines blocks within the same definition;
it should not be confused with the setOperator of a composition,
which combines entire criteria. They are two distinct levels of set operation.
ColumnRefDto — column reference
Used across the entire tree (select, filters, group by, joins, aggregates, time window):
| Field | Type | Required | Description |
|---|---|---|---|
table | string | Yes | Catalog table. Not empty. |
column | string | Yes | Column. Not empty. Can be a STRUCT path (e.g. "pricing.final_amount"). |
Every column is always qualified with its table to avoid ambiguity.
from — data source (FromDto)
| Field | Type | Required | Description |
|---|---|---|---|
table | string | Yes | Main catalog table. Not empty. |
joins | JoinDto[] | No | Joins with other tables. Default []. |
unnests | ColumnRefDto[] | No | Repeated columns declared as a row source (one row per element). An audience does not accept them: omit the field or send [] (the default); a non-empty list gives 400. |
Each JoinDto:
| Field | Type | Required | Description |
|---|---|---|---|
type | JoinTypeEnum | Yes | INNER | LEFT | RIGHT | FULL. |
table | string | Yes | Table to join (from the catalog). Not empty. |
on | JoinPredicateDto[] | Yes | Join conditions. |
Each JoinPredicateDto is a pair of columns: { left: ColumnRefDto, right: ColumnRefDto }.
select — projection (SelectDto)
| Field | Type | Required | Description |
|---|---|---|---|
distinct | boolean | Yes | Applies SELECT DISTINCT. |
items | (SelectColumnItem | SelectAggregateItem)[] | Yes | Not empty. Discriminated union by the kind field. |
The items are discriminated by kind:
-
Column —
kind: "column":Field Type Required Description kind"column"Yes Discriminator. columnColumnRefDtoYes Projected column. aliasstring No Must match ^[A-Za-z_][A-Za-z0-9_]*$. -
Aggregate —
kind: "aggregate":Field Type Required Description kind"aggregate"Yes Discriminator. aggregateAggregateDtoYes Aggregation expression. aliasstring Yes Here the alias is required. Same identifier regex.
AggregateDto:
| Field | Type | Required | Description |
|---|---|---|---|
aggregationOperator | AggregationOperatorEnum | Yes | COUNT | SUM | AVG | MIN | MAX. |
operand | object | Yes | What is aggregated. Discriminated union on kind: column, star or arithmetic (see below). |
distinct | boolean | No | Applies DISTINCT inside the aggregate. |
operand — what is aggregated — is discriminated by kind:
kind | Additional fields | Meaning |
|---|---|---|
column | column: ColumnRefDto | A catalog column. |
star | (none) | *: counts rows. Only valid with COUNT and without distinct (COUNT(DISTINCT *) is not allowed). |
arithmetic | arithmeticOperator: ArithmeticOperatorEnum, left, right | Binary expression. Each side is another operand: a column or a nested arithmetic (never star). |
ArithmeticOperatorEnum: ADD | SUBTRACT | MULTIPLY | SAFE_DIVIDE (a division that yields
NULL when the denominator is zero instead of failing the query). The columns of an arithmetic
expression must be numeric (INT64, NUMERIC, BIGNUMERIC, FLOAT64), and expressions nest up to
3 levels of arithmetic, counting the outermost one.
where — predicate tree (WhereDto)
The where is a recursive tree. The root node is always a group, and its leaves come in
two kinds:
kind | What it filters |
|---|---|
group | Not a leaf: it groups children with AND / OR. Groups can be nested. |
predicate | A scalar column of a table in the from. |
recordPredicate | A RECORD REPEATED column: at least 1 element satisfies the sub-filter. |
Group — kind: "group"
| Field | Type | Required | Description |
|---|---|---|---|
kind | "group" | Yes | Discriminator. |
logicalOperator | LogicalOperatorEnum (AND | OR) | Yes | Connective between the children. |
children | (Group | Leaf)[] | Yes | Group children; they can be nested groups. |
logicalOperator is always required in a group, but it only has effect with
2 or more children. With fewer than 2 children its value is irrelevant (it generates the same SQL), but
it must still be sent. A where with no filters is expressed as an empty group:
{ "kind": "group", "logicalOperator": "AND", "children": [] }.
Column leaf — kind: "predicate"
Compares a scalar column qualified by its table.
| Field | Type | Required | Description |
|---|---|---|---|
kind | "predicate" | Yes | Discriminator. |
column | ColumnRefDto | Yes | Predicate column. |
comparisonOperator | ComparisonOperatorEnum | Yes | Comparison operator. |
value | PredicateValue | Depends | Its shape depends on the operator. |
{
"kind": "predicate",
"column": { "table": "user_properties", "column": "country" },
"comparisonOperator": "IN",
"value": ["CL", "MX"]
}Record leaf — kind: "recordPredicate"
Filters by a RECORD REPEATED column (an array of structs, such as attributes).
It means at least one element of the array satisfies the sub-filter.
| Field | Type | Required | Description |
|---|---|---|---|
kind | "recordPredicate" | Yes | Discriminator. |
column | ColumnRefDto | Yes | RECORD REPEATED column of a table in the from. |
subWhere | Relative sub-tree | Yes | Conditions over the element's sub-fields. It cannot be left empty. |
{
"kind": "recordPredicate",
"column": { "table": "event_transactions", "column": "attributes" },
"subWhere": {
"kind": "group",
"logicalOperator": "AND",
"children": [
{ "kind": "predicate", "field": "key", "comparisonOperator": "EQUALS", "value": "canal" },
{ "kind": "predicate", "field": "value", "comparisonOperator": "EQUALS", "value": "web" }
]
}
}The subWhere conditions are evaluated over a single element of the array, not over
the whole array. The example above selects rows that have an attribute whose key is
canal and whose value is web — not rows that have an attribute with that key
and another, different one, with that value.
Which columns accept a record filter is stated by the
catalog: the ones that come with
kind: "record".
Relative sub-tree
The subWhere is an AND/OR group whose leaves are resolved against the sub-fields the
record declares.
There is a single difference with the top-level tree, and it is the most common source of errors:
Inside a relative sub-tree the operands carry no table: you write
"field": "key", not "column": { "table": "attributes", "column": "key" }. The leaf uses
the field key (a string), not column.
Leaves allowed inside a relative sub-tree:
- Scalar leaf —
{ "kind": "predicate", "field": string, "comparisonOperator": …, "value"?: … }. Thefieldmust be a scalar sub-field of the record. - Nested record —
{ "kind": "recordPredicate", "field": string, "subWhere": … }. A record inside another record; it nests with no declared limit, as long as the catalog declares each level. - Group — the same group shape, to combine the previous leaves with
AND/OR.
value shape by comparisonOperator
| Operators | value shape | Example |
|---|---|---|
IS_NULL, IS_NOT_NULL | No value (omitted) | (omitted) |
IN, NOT_IN | Non-empty list ScalarValue[] | ["CL", "MX"] |
BETWEEN | Pair [min, max] | [10, 20] |
The rest (EQUALS, NOT_EQUALS, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH) | Single scalar | "activo" · 42 |
ScalarValue = string | number | boolean. In addition, each operator must be
compatible with the column type (see validation rules).
groupBy — grouping columns
Optional list of ColumnRefDto[]. When the block aggregates (it declares groupBy or has an
aggregate in the select), the groupBy must match exactly the non-aggregated
columns of the select (see validation rules).
having — filter over aggregates (HavingDto)
Same recursive structure as where (nested AND/OR groups and the same value rule
per operator), but the leaf operates over an aggregate, not over a column:
- Predicate (leaf) —
{ kind: "predicate", aggregate: AggregateDto, comparisonOperator: ComparisonOperatorEnum, value?: PredicateValue }. - Group — identical to the one in
where.
timeWindow — time window (TimeWindowDto)
Discriminated union by timeOperator. Bounds the block to a time period over a
TIMESTAMP column of the catalog (reference). It is a sibling field of where, not a
node of the filter tree.
timeOperator | Additional fields |
|---|---|
ALL_TIME | (none) — not bounded by time. |
IN_THE_LAST | reference: ColumnRefDto, unit: TimeUnitEnum, value: integer > 0 |
IN_THE_FIRST | reference: ColumnRefDto, unit: TimeUnitEnum, value: integer > 0 |
SINCE | reference: ColumnRefDto, from: string (YYYY-MM-DD) |
BETWEEN | reference: ColumnRefDto, from: string (YYYY-MM-DD), to: string (YYYY-MM-DD) |
TimeUnitEnum: DAYS | WEEKS | MONTHS | YEARS.
Business semantics of each operator:
ALL_TIME— no window; considers the entire history.IN_THE_LAST— rolling window relative to "now": the lastvalueunit(e.g. last30 DAYS). Recomputed on every run.IN_THE_FIRST— window relative to the origin of the user's lifecycle (their signup date): the firstvalueunitof each user's life. It is anchored per user, not to a fixed date.SINCE— from the civil datefrom(inclusive) onward.BETWEEN— betweenfromandto, both civil dates inclusive of their full day.
The civil dates YYYY-MM-DD (SINCE, BETWEEN) resolve to the start of the day in
the tenant's timezone. The timezone detail is internal; it is enough to send the
civil date.
composedWith[] — composition with other audiences
Each element (CreateCompositionDto) combines this definition with another audience
via a set operation:
| Field | Type | Required | Description |
|---|---|---|---|
operandCriteriaId | string (UUID) | Yes | Id of the other audience (operand). Must exist. |
setOperator | SetOperatorEnum | Yes | UNION_DISTINCT | UNION_ALL | INTERSECT_DISTINCT. |
Example
Audience of users with 3 or more COMPLETED transactions in the last 90 days. It projects
only user_id, like the root audience's default definition (see
Intersection with the root audience). A sample of 50
rows is requested (limit).
curl -X POST https://api.reten.ai/api/audience-criterias/preview-from-body \
-H "Authorization: Bearer <token>" \
-H "x-tenant-id: <tenant-id>" \
-H "x-project-id: <project-id>" \
-H "Content-Type: application/json" \
-d '{
"queryBlocks": [
{
"order": 0,
"from": { "table": "event_transactions" },
"select": {
"distinct": false,
"items": [
{
"kind": "column",
"column": { "table": "event_transactions", "column": "user_id" }
}
]
},
"timeWindow": {
"timeOperator": "IN_THE_LAST",
"reference": { "table": "event_transactions", "column": "event_date" },
"unit": "DAYS",
"value": 90
},
"where": {
"kind": "group",
"logicalOperator": "AND",
"children": [
{
"kind": "predicate",
"column": { "table": "event_transactions", "column": "status" },
"comparisonOperator": "EQUALS",
"value": "COMPLETED"
}
]
},
"groupBy": [{ "table": "event_transactions", "column": "user_id" }],
"having": {
"kind": "group",
"logicalOperator": "AND",
"children": [
{
"kind": "predicate",
"aggregate": {
"aggregationOperator": "COUNT",
"operand": { "kind": "star" }
},
"comparisonOperator": "GREATER_THAN_OR_EQUAL",
"value": 3
}
]
}
}
],
"limit": 50
}'import axios from 'axios';
const body = {
queryBlocks: [
{
order: 0,
from: { table: 'event_transactions' },
select: {
distinct: false,
items: [
{
kind: 'column',
column: { table: 'event_transactions', column: 'user_id' },
},
],
},
timeWindow: {
timeOperator: 'IN_THE_LAST',
reference: { table: 'event_transactions', column: 'event_date' },
unit: 'DAYS',
value: 90,
},
where: {
kind: 'group',
logicalOperator: 'AND',
children: [
{
kind: 'predicate',
column: { table: 'event_transactions', column: 'status' },
comparisonOperator: 'EQUALS',
value: 'COMPLETED',
},
],
},
groupBy: [{ table: 'event_transactions', column: 'user_id' }],
having: {
kind: 'group',
logicalOperator: 'AND',
children: [
{
kind: 'predicate',
aggregate: {
aggregationOperator: 'COUNT',
operand: { kind: 'star' },
},
comparisonOperator: 'GREATER_THAN_OR_EQUAL',
value: 3,
},
],
},
},
],
limit: 50, // optional; defaults to 100
};
const response = await axios.post(
'https://api.reten.ai/api/audience-criterias/preview-from-body',
body,
{ headers: { Authorization: 'Bearer <token>', 'x-tenant-id': '<tenant-id>', 'x-project-id': '<project-id>' } },
);
const preview = response.data; // IPreviewAudienceCriteriaResultThe response is documented in IPreviewAudienceCriteriaResult,
with a non-empty rows example.
POST /api/audience-criterias/:id/preview
Runs an already saved criteria, referenced by its id. The definition does not travel in
the body: it is taken from the criteria persisted in the active project. The body only carries limit.
Auth: Required — audience-criteria:view-sql permission
Like the previous one, it responds 201 Created.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | The audience criteria id. Must be a valid UUID (otherwise, 400). |
Request Body
PreviewQueryDto — a single, optional field:
| Field | Type | Required | Description |
|---|---|---|---|
limit | number (integer) | No | Size of the rows sample. Integer 1..500. Default 100 (see limit). |
An empty body ({}) is valid: it uses the default limit (100).
Example
curl -X POST https://api.reten.ai/api/audience-criterias/f47ac10b-58cc-4372-a567-0e02b2c3d479/preview \
-H "Authorization: Bearer <token>" \
-H "x-tenant-id: <tenant-id>" \
-H "x-project-id: <project-id>" \
-H "Content-Type: application/json" \
-d '{ "limit": 50 }'import axios from 'axios';
const id = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
const response = await axios.post(
`https://api.reten.ai/api/audience-criterias/${id}/preview`,
{ limit: 50 }, // optional; defaults to 100
{ headers: { Authorization: 'Bearer <token>', 'x-tenant-id': '<tenant-id>', 'x-project-id': '<project-id>' } },
);
const preview = response.data; // IPreviewAudienceCriteriaResultlimit — sample size
limit bounds only how many rows the response brings in rows (the sample you see), not
the universe that the audience matches.
- Range: integer 1..500 (less than 1 or greater than 500 ⇒
400). - Default:
100(PREVIEW_ROW_LIMIT_DEFAULT) when omitted.
limit governs the size of the sample in rows; totalCount is the complete
universe that the audience matches and is not bounded by limit. Preview runs two
queries over the same definition: one SELECT ... LIMIT <limit> for the sample and one
SELECT COUNT(*) for the total. That is why you can see rows.length = 50 with
totalCount = 4821: rows is a glance, totalCount is the real size of the audience.
Response — IPreviewAudienceCriteriaResult
| Field | Type | Description |
|---|---|---|
rows | Record<string, unknown>[] | Sample of real rows, bounded by limit. Each row carries the columns the audience projects (its select, with its aliases); that is why rows has no fixed shape. That projection must be compatible with the root audience's (see Intersection with the root audience). |
totalCount | number | Total rows the audience matches, not bounded by limit (the complete universe). |
bytesProcessed | number | Bytes processed by BigQuery in the run. Sums the bytes of both queries (sample + count). |
Always distinguish rows (the sample, at most limit rows) from totalCount (the
universe, with no limit). If totalCount > rows.length, there are more rows than the
sample can show.
Example
201 response to the example above (limit: 50). The audience matches 4821 users;
rows shows the first 50 (trimmed to 3 here for brevity), each with the
only projected column (user_id):
{
"rows": [
{ "user_id": "u_9f3a1c" },
{ "user_id": "u_2b7e04" },
{ "user_id": "u_c081ff" }
],
"totalCount": 4821,
"bytesProcessed": 5242880
}The keys of each object in rows (user_id) come from the audience's select. totalCount
(4821) is the complete universe, greater than the 50 rows of the sample.
Intersection with the root audience
Every project has a root audience, and every other
audience of the project is evaluated within it. Preview runs the same SQL that
Generate SQL
returns: for any audience that is not the root audience, intersected with the project's root
audience. So rows and totalCount are the audience's matches inside the root audience, and
bytesProcessed includes the root audience's part of the query.
There is no intersection when the audience run is the root audience itself — :id/preview with
the root audience's id, or a preview-from-body draft whose criteriaId is the root audience.
The intersection is positional (SELECT * on both sides): the audience must project the same
columns as the root audience, in the same order and with compatible types — with the root
audience's default definition, only user_id. Otherwise, BigQuery rejects the query when it
runs and preview responds 400 (Query execution failed: <detail>).
Validation Rules
Before executing, the definition goes through the same two layers of validation as
Create, and both
return 400 Bad Request:
- DTO validation (structure, types, enums, required fields,
limitwithin range).messageis an array of strings and unknown properties are rejected (includingname/description). - Business validation (the definition must generate valid SQL coherent against the
catalog).
messageis a single string with the violated rule.
Business validation runs on both endpoints: preview-from-body validates the
definition in the body, and :id/preview validates the saved criteria before executing it. The
complete catalog of rules and their exact messages is in
Create → Validation Rules.
If the definition passes the two layers but the BigQuery execution fails —by
exceeding the maximumBytesBilled limit (10 GB), running out of the 30 s timeout, or any
other engine error— the response is also 400, with a message of the form
Query execution failed: <detail>.
Error Responses
| Status | Description |
|---|---|
400 | Invalid DTO (missing field, wrong type, enum out of range, limit outside 1..500, unknown property such as name/description), a violated business rule, or a BigQuery execution failure (Query execution failed: ..., includes exceeding 10 GB or the 30 s timeout). In :id/preview, also if the id is not a valid UUID; in preview-from-body, if the criteriaId is not. |
401 | The Authorization: Bearer is missing or invalid. |
403 | The user does not have the audience-criteria:view-sql permission, or the x-tenant-id is not authorized. |
404 | In :id/preview, no audience criteria with that id exists in the active project. In preview-from-body, an operandCriteriaId in composedWith or the criteriaId does not exist in the active project. |