Validate
Validate an audience criteria definition and run a dry-run against BigQuery, from the body (not yet persisted) or by id of a saved criteria; returns whether it is valid and the estimated cost, with no data or SQL.
Validate
Validate an audience criteria definition and, if it passes all validations, run a dry-run against BigQuery. A single engine serves two entry points:
POST /api/audience-criterias/validate-from-body— validates a definition sent in the body (not yet persisted).POST /api/audience-criterias/:id/validate— validates an already saved criteria, referenced by itsid.
Both return the same response shape:
IValidateAudienceCriteriaResult, with the
validity and the estimated cost of the query. Validation does not return rows or the
generated SQL: only whether the definition is executable and how many bytes it would process.
Within a project, every audience other than the root audience is validated intersected with the project's root audience — see Intersection with the root audience.
Auth: Required — audience-criteria:view-sql permission (both endpoints).
Distinguish two failure paths that this resource treats differently:
- Structural and business validation (DTO, catalog, aggregation rules, time
window, etc.) happens before the dry-run and, if it fails, responds with an
HTTP error (
400/404) — it does not reach the response body. - The BigQuery dry-run is the only thing reflected in the body as
valid: false(with status201).
POST /api/audience-criterias/validate-from-body
Validates 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 (CriteriaDefinitionDto): the criteria definition, without a wrapper.
| 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. |
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 carries no
name or description: it is only the query tree. Nor does the format field from
Generate SQL exist here —validating
emits no SQL—. DTO validation is strict, so sending name/description/format
(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 | Block time window (union discriminated 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. |
A query block's setOperator combines blocks within the same definition; it must
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 throughout the tree (select, filters, group by, joins, aggregates, time window):
| Field | Type | Required | Description |
|---|---|---|---|
table | string | Yes | Catalog table. Non-empty. |
column | string | Yes | Column. Non-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. Non-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). Non-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 | Non-empty. Union discriminated 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 only takes 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.
Shape of value by comparisonOperator
| Operators | Shape of value | Example |
|---|---|---|
IS_NULL, IS_NOT_NULL | No value (omitted) | (omitted) |
IN, NOT_IN | List non-empty 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 (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 that of
where.
timeWindow — time window (TimeWindowDto)
Union discriminated by timeOperator. Restricts the block to a time period over a
TIMESTAMP catalog column (reference). It is a sibling field of where, not a
node of the filter tree.
timeOperator | Additional fields |
|---|---|
ALL_TIME | (none) — no time restriction. |
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. 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.
Civil dates YYYY-MM-DD (SINCE, BETWEEN) resolve to the start of the day in the
tenant's timezone. The timezone detail is internal; sending the civil date is
enough.
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
Definition of an audience of users with 3 or more COMPLETED transactions in the
last 90 days.
curl -X POST https://api.reten.ai/api/audience-criterias/validate-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
}
]
}
}
]
}'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,
},
],
},
},
],
};
const response = await axios.post(
'https://api.reten.ai/api/audience-criterias/validate-from-body',
body,
{ headers: { Authorization: 'Bearer <token>', 'x-tenant-id': '<tenant-id>', 'x-project-id': '<project-id>' } },
);
const result = response.data; // IValidateAudienceCriteriaResultThe response is documented in
IValidateAudienceCriteriaResult, with a
valid example and an invalid one.
POST /api/audience-criterias/:id/validate
Validates 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.
Auth: Required — audience-criteria:view-sql permission
Like validate-from-body, the endpoint responds 201 Created —not 200—, even
though it creates no resource.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Audience criteria id. Must be a valid UUID (otherwise, 400). |
This endpoint receives no body and no query params.
Example
curl -X POST https://api.reten.ai/api/audience-criterias/f47ac10b-58cc-4372-a567-0e02b2c3d479/validate \
-H "Authorization: Bearer <token>" \
-H "x-tenant-id: <tenant-id>" \
-H "x-project-id: <project-id>"import axios from 'axios';
const id = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
const response = await axios.post(
`https://api.reten.ai/api/audience-criterias/${id}/validate`,
undefined, // no body
{ headers: { Authorization: 'Bearer <token>', 'x-tenant-id': '<tenant-id>', 'x-project-id': '<project-id>' } },
);
const result = response.data; // IValidateAudienceCriteriaResultResponse — IValidateAudienceCriteriaResult
Status 201 Created on both endpoints. The body reports the result of the BigQuery
dry-run:
| Field | Type | Required | Description |
|---|---|---|---|
valid | boolean | Yes | true if the dry-run succeeded; false if BigQuery rejected the query. |
errors | string[] | No | Present only when valid: false. Contains the BigQuery error message(s). |
bytesProcessed | number | Yes | Bytes BigQuery estimates it will process. On success, the dry-run estimate; on failure, 0. |
The dry-run does not execute the query or return rows: it only validates it against
BigQuery and estimates its cost (bytesProcessed). To get a sample of real rows use
Preview; to see the generated SQL,
Generate SQL.
errors is an array by contract, but in practice the dry-run returns a single
message (the one BigQuery reports). Always discriminate by valid before reading
errors.
Example — valid (valid: true)
A 201 response to a definition that compiles and passes the dry-run:
{
"valid": true,
"bytesProcessed": 10485760
}When valid is true, errors is omitted and bytesProcessed carries the BigQuery
estimate (in this example, ~10 MB).
Example — invalid (valid: false)
The definition passed structural and business validation and compiled to SQL, but the
BigQuery dry-run rejected it (e.g. a catalog column that does not yet exist in the real
table of the dataset, or a query whose estimated cost exceeds the billable bytes
limit). Even so, the status is 201:
{
"valid": false,
"errors": [
"Unrecognized name: legacy_column at [3:8]"
],
"bytesProcessed": 0
}With valid: false, errors carries the BigQuery message and bytesProcessed is 0.
Intersection with the root audience
Every project has a root audience, and every other
audience of the project is evaluated within it. The dry-run runs on the same SQL that
Generate SQL
returns: for any audience that is not the root audience, intersected with the project's root
audience. So valid and bytesProcessed refer to that intersected query, and the estimate
includes the root audience's part.
There is no intersection when the audience validated is the root audience itself —
:id/validate with the root audience's id, or a validate-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 intersected
query in the dry-run and the response is valid: false, with BigQuery's message in errors.
Structural validation vs. dry-run
This is the key point of the resource: not all failures reach errors[]. A request
goes through stages and each one fails differently.
| Stage | How it fails | Where it shows |
|---|---|---|
| 1. DTO validation (structure, types, enums) | HTTP 400 error | message (array of strings); stops the request. |
2. Criteria resolution (only :id, criteriaId and composedWith) | HTTP 404 error | message (string); stops the request. |
| 3. Business validation (catalog, aggregation, time) | HTTP 400 error | message (string); stops the request. |
| 4. BigQuery dry-run | valid: false (status 201) | errors field of the response body. |
In other words:
- Everything that happens before the dry-run (stages 1–3) that fails responds with an
HTTP error and does not produce an
IValidateAudienceCriteriaResultbody. For example: a table outside the catalog, a criteria that does not project its identity column, a malformedtimeWindow, a nonexistentidorcriteriaId, or anoperandCriteriaIdthat does not exist. valid: falseis exclusive to the dry-run: the definition was already structurally valid and compiled to SQL, but BigQuery rejected it when estimating it. The status is still201.
Do not assume that an "invalid criteria" always arrives as valid: false. Most shape and
business errors are HTTP 400/404 errors and never reach the dry-run.
valid: false specifically means that BigQuery rejected a query that had already
passed all local validation.
Validation Rules
Before the dry-run, 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).
messageis an array of strings and unknown properties are rejected (includingname/description/format). - Business validation (the definition must generate valid SQL that is coherent against
the catalog).
messageis a single string with the broken rule, and it stops at the first violated invariant.
Business validation runs on both endpoints: validate-from-body validates the body
definition, and :id/validate validates the saved criteria before compiling it.
The full catalog of rules and their exact messages is in
Create → Validation Rules.
Error Responses
These are the HTTP errors (distinct from valid: false, which travels with status 201):
| Status | Description |
|---|---|
400 | Invalid DTO (missing field, wrong type, enum out of range, unknown property such as name/description/format) or a broken business rule. In :id/validate, also if the id is not a valid UUID; in validate-from-body, if the criteriaId is not. |
401 | Missing or invalid Authorization: Bearer. |
403 | The user lacks the audience-criteria:view-sql permission, or the x-tenant-id is not authorized. |
404 | In :id/validate, no audience criteria with that id exists in the active project. In validate-from-body, an operandCriteriaId of composedWith or the criteriaId does not exist in the active project. |