Reten Docs

Update Audience Criteria

Partially update an audience criteria by id. Only the fields sent are modified; including queryBlocks fully replaces the definition (blocks and compositions).

PATCH /api/audience-criterias/:id

Updates an existing Audience Criteria. It is a partial update: only the fields present in the body are modified; omitted ones stay intact. The operation is transactional — if the definition is replaced, the changes are applied together or none is applied.

Auth: Required — audience-criteria:update permission

Editing the project's root audience also requires the project:update permission, for any change (even just name or description). Without it, the API responds 403 — No tiene permiso para editar la audiencia raíz del proyecto.

Path Parameters

ParameterTypeDescription
idUUIDAudience criteria ID. Must be a valid UUID (otherwise, 400).

Update Semantics (read first)

The PATCH behavior is not a field-by-field merge of the tree. It boils down to this:

  • Only what is sent gets updated. An omitted field is not touched.
    • name — if sent, it cannot be empty or null; updates the name.
    • description — sending a string changes it; sending null clears it (becomes null); omitting it leaves it unchanged.
  • updatedBy is set to the editing user; updatedAt is refreshed automatically.

queryBlocks is a full replacement, not a merge. If the body includes queryBlocks, the service deletes all of the criteria's existing query blocks (and all compositions) and recreates them from scratch with what was sent. No diff is performed and no previous blocks are kept. If you omit queryBlocks, the definition (blocks + compositions) stays intact and only name / description are updated.

Sending queryBlocks without composedWith deletes the compositions. The replacement removes blocks and compositions together, and compositions are only recreated if composedWith carries at least one element. To keep the compositions when re-sending queryBlocks, you must also re-send composedWith.

Corollary: composedWith on its own is a no-op. Compositions are only touched when queryBlocks is also sent; sending composedWith without queryBlocks does not modify the existing compositions.

Business validation is conditional. The definition tree is revalidated (with the tenant's timezone) only if queryBlocks is sent. If it is not sent, name / description are updated without revalidating the definition.

Request Body

Root level (UpdateAudienceCriteriaDto). All fields are optional:

FieldTypeRequiredDescription
namestringNoIf sent, it cannot be empty or null (400).
descriptionstring | nullNoString to change it, null to clear it. Omitting it leaves it unchanged.
queryBlocksQueryBlockDto[]NoIf included, replaces the entire definition. Same shape as in Create.
composedWithCreateCompositionDto[]NoOnly takes effect if queryBlocks is also sent (see warnings).

As in Create, if queryBlocks is sent it must carry at least one element: an empty array ([]) produces a 400. To keep the current definition, omit the field.

When queryBlocks and/or composedWith are sent, they use exactly the same DTOs as Create (QueryBlockDto / CreateCompositionDto). The sub-tree is fully documented below.

queryBlocks[] — query block

Each element of queryBlocks (QueryBlockDto) is a complete SQL statement:

FieldTypeRequiredDescription
ordernumber (integer)YesBlock position; integer ≥ 0.
setOperatorSetOperatorEnumNoCombines this block with the previous one. Absent in the first block; required in the rest (see rules).
fromFromDtoYesData source: main table + joins.
selectSelectDtoYesProjection (columns and/or aggregations).
timeWindowTimeWindowDtoNoBlock time window (discriminated union by timeOperator).
whereWhereDtoYesPredicate tree. Required even if it is an empty group.
groupByColumnRefDto[]NoGrouping columns.
havingHavingDtoNoFilter over aggregates.

A query block's setOperator combines blocks within the same criteria; it should not be confused with the setOperator of a composition, which combines whole criteria. They are two different levels of set operation.

ColumnRefDto — column reference

Used across the entire tree (select, filters, group by, joins, aggregates, time window):

FieldTypeRequiredDescription
tablestringYesCatalog table. Not empty.
columnstringYesColumn. 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)

FieldTypeRequiredDescription
tablestringYesMain catalog table. Not empty.
joinsJoinDto[]NoJoins with other tables. Default [].
unnestsColumnRefDto[]NoRepeated 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:

FieldTypeRequiredDescription
typeJoinTypeEnumYesINNER | LEFT | RIGHT | FULL.
tablestringYesTable to join (from the catalog). Not empty.
onJoinPredicateDto[]YesJoin conditions.

Each JoinPredicateDto is a pair of columns: { left: ColumnRefDto, right: ColumnRefDto }.

select — projection (SelectDto)

FieldTypeRequiredDescription
distinctbooleanYesApplies SELECT DISTINCT.
items(SelectColumnItem | SelectAggregateItem)[]YesNot empty. Discriminated union by the kind field.

The items are discriminated by kind:

  • Column — kind: "column":

    FieldTypeRequiredDescription
    kind"column"YesDiscriminator.
    columnColumnRefDtoYesProjected column.
    aliasstringNoMust match ^[A-Za-z_][A-Za-z0-9_]*$.
  • Aggregate — kind: "aggregate":

    FieldTypeRequiredDescription
    kind"aggregate"YesDiscriminator.
    aggregateAggregateDtoYesAggregation expression.
    aliasstringYesHere the alias is required. Same identifier regex.

AggregateDto:

FieldTypeRequiredDescription
aggregationOperatorAggregationOperatorEnumYesCOUNT | SUM | AVG | MIN | MAX.
operandobjectYesWhat is aggregated. Discriminated union on kind: column, star or arithmetic (see below).
distinctbooleanNoApplies DISTINCT inside the aggregate.

operand — what is aggregated — is discriminated by kind:

kindAdditional fieldsMeaning
columncolumn: ColumnRefDtoA catalog column.
star(none)*: counts rows. Only valid with COUNT and without distinct (COUNT(DISTINCT *) is not allowed).
arithmeticarithmeticOperator: ArithmeticOperatorEnum, left, rightBinary 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:

kindWhat it filters
groupNot a leaf: it groups children with AND / OR. Groups can be nested.
predicateA scalar column of a table in the from.
recordPredicateA RECORD REPEATED column: at least 1 element satisfies the sub-filter.
Group — kind: "group"
FieldTypeRequiredDescription
kind"group"YesDiscriminator.
logicalOperatorLogicalOperatorEnum (AND | OR)YesConnective between the children.
children(Group | Leaf)[]YesGroup 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.

FieldTypeRequiredDescription
kind"predicate"YesDiscriminator.
columnColumnRefDtoYesPredicate column.
comparisonOperatorComparisonOperatorEnumYesComparison operator.
valuePredicateValueDependsIts 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.

FieldTypeRequiredDescription
kind"recordPredicate"YesDiscriminator.
columnColumnRefDtoYesRECORD REPEATED column of a table in the from.
subWhereRelative sub-treeYesConditions 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"?: … }. The field must 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
Operatorsvalue shapeExample
IS_NULL, IS_NOT_NULLNo value (omitted)(omitted)
IN, NOT_INNon-empty list ScalarValue[]["CL", "MX"]
BETWEENPair [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. Also, 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 on an aggregate, not on 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. Restricts the block to a time period over a catalog TIMESTAMP column (reference). It is a sibling field of where, not a node of the filter tree.

timeOperatorAdditional fields
ALL_TIME(none) — no time restriction.
IN_THE_LASTreference: ColumnRefDto, unit: TimeUnitEnum, value: integer > 0
IN_THE_FIRSTreference: ColumnRefDto, unit: TimeUnitEnum, value: integer > 0
SINCEreference: ColumnRefDto, from: string (YYYY-MM-DD)
BETWEENreference: 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 last value unit (e.g. last 30 DAYS). Recomputed on each run.
  • IN_THE_FIRST — window relative to the origin of the user's lifecycle (today, their signup date): the first value unit of each user's life. Anchored per user, not to a fixed date.
  • SINCE — from the civil date from (inclusive) onward.
  • BETWEEN — between from and to, 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 criteria with another audience via a set operation:

FieldTypeRequiredDescription
operandCriteriaIdstring (UUID)YesId of the other audience (operand). Must exist.
setOperatorSetOperatorEnumYesUNION_DISTINCT | UNION_ALL | INTERSECT_DISTINCT.

Remember: composedWith only applies when queryBlocks is also sent (see Update Semantics).

Validation Rules

The request goes through two layers of validation, both returning 400 Bad Request:

  1. DTO validation (structure, types, enums, required fields of the sub-tree). In this case message is an array of strings and unknown properties are rejected. Always runs.
  2. Business validation (the criteria must generate valid, coherent SQL against the catalog). In this case message is a single string with the message of the failed rule and it stops at the first violated invariant. Only runs if the body includes queryBlocks.

The business rules of the definition are identical to those of Create (catalog of tables/columns, star (*) only with COUNT, numeric columns in arithmetic expressions, GROUP BY coverage, HAVING context, identity column, setOperator by block position, operator compatibility by type, value shape, timeWindow rules, etc.).

Rules specific to composedWith (also 400, checked when recreating the compositions when queryBlocks is sent):

RuleMessage
A criteria cannot reference itself.An audience criteria cannot reference itself
The operand must exist.Operand audience criteria <id> not found or deleted
Circular references between criteria are not allowed.Circular reference detected

Examples

Partial update (metadata only)

Rename the criteria and clear its description, without touching the definition (queryBlocks / composedWith are omitted, so blocks and compositions stay intact):

curl -X PATCH https://api.reten.ai/api/audience-criterias/a1b2c3d4-5678-4abc-9def-1234567890ab \
  -H "Authorization: Bearer <token>" \
  -H "x-tenant-id: <tenant-id>" \
  -H "x-project-id: <project-id>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Compradores VIP (renombrado)",
    "description": null
  }'
import axios from 'axios';

const id = 'a1b2c3d4-5678-4abc-9def-1234567890ab';
const response = await axios.patch(
  `https://api.reten.ai/api/audience-criterias/${id}`,
  { name: 'Compradores VIP (renombrado)', description: null },
  { headers: { Authorization: 'Bearer <token>', 'x-tenant-id': '<tenant-id>', 'x-project-id': '<project-id>' } },
);
const updated = response.data;

Definition replacement

Including queryBlocks replaces the entire definition. Here composedWith is also re-sent to keep the composition (if it were omitted, the composition would be deleted):

curl -X PATCH https://api.reten.ai/api/audience-criterias/a1b2c3d4-5678-4abc-9def-1234567890ab \
  -H "Authorization: Bearer <token>" \
  -H "x-tenant-id: <tenant-id>" \
  -H "x-project-id: <project-id>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Compradores VIP (últimos 60 días)",
    "queryBlocks": [
      {
        "order": 0,
        "from": { "table": "event_transactions", "joins": [] },
        "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": 60
        },
        "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": "SUM",
                "operand": { "kind": "column", "column": { "table": "event_transactions", "column": "pricing.final_amount" } }
              },
              "comparisonOperator": "GREATER_THAN",
              "value": 100000
            }
          ]
        }
      }
    ],
    "composedWith": [
      {
        "operandCriteriaId": "b2c3d4e5-6789-4abc-9def-0123456789ab",
        "setOperator": "INTERSECT_DISTINCT"
      }
    ]
  }'
import axios from 'axios';

const id = 'a1b2c3d4-5678-4abc-9def-1234567890ab';
const body = {
  name: 'Compradores VIP (últimos 60 días)',
  queryBlocks: [
    {
      order: 0,
      from: { table: 'event_transactions', joins: [] },
      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: 60,
      },
      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: 'SUM',
              operand: { kind: 'column', column: { table: 'event_transactions', column: 'pricing.final_amount' } },
            },
            comparisonOperator: 'GREATER_THAN',
            value: 100000,
          },
        ],
      },
    },
  ],
  composedWith: [
    {
      operandCriteriaId: 'b2c3d4e5-6789-4abc-9def-0123456789ab',
      setOperator: 'INTERSECT_DISTINCT',
    },
  ],
};

const response = await axios.patch(
  `https://api.reten.ai/api/audience-criterias/${id}`,
  body,
  { headers: { Authorization: 'Bearer <token>', 'x-tenant-id': '<tenant-id>', 'x-project-id': '<project-id>' } },
);
const updated = response.data;

Response 200 OK

Returns the already updated criteria as IAudienceCriteriaDetail — the same shape as GET /audience-criterias/:id and as Create. It is the base resource plus the full tree: queryBlocks[] (with their id and order resolved, ordered by order ascending) and compositions[] (with id and operandCriteriaName).

Base fields (IAudienceCriteria) — all camelCase; instants are serialized to ISO 8601:

FieldTypeDescription
idstring (UUID)Criteria id.
projectIdstring (UUID)Project the criteria belongs to.
namestringName (already updated if sent).
descriptionstring | nullnull if empty.
isActivebooleanWhether the criteria is active.
isRootbooleantrue only for the project's root audience (see Projects).
ownerIdstring (UUID)Creating user (unchanged on edit).
updatedBystring (UUID)User who made this edit.
createdAtstring (ISO 8601)Creation date.
updatedAtstring (ISO 8601)Refreshed on edit.

Nested relations:

  • queryBlocks[] — each block adds its id (UUID) and returns the tree as it ended up (from, select, timeWindow, where, groupBy, having). Absent optional fields are returned as null (setOperator, timeWindow, groupBy, having) and from.joins as [] if there were no joins; from.unnests always comes as [].
  • compositions[] — each composition: id (UUID), operandCriteriaId, operandCriteriaName (resolved operand name; "" if it does not resolve) and setOperator.
{
  "id": "a1b2c3d4-5678-4abc-9def-1234567890ab",
  "projectId": "3f6c2b1a-8d4e-4f7a-9b2c-5e1d0a7f6c34",
  "name": "Compradores VIP (últimos 60 días)",
  "description": null,
  "isActive": true,
  "isRoot": false,
  "ownerId": "0f0e0d0c-0b0a-4090-8070-605040302010",
  "updatedBy": "1a2b3c4d-5e6f-4071-8092-a0b0c0d0e0f0",
  "createdAt": "2026-07-15T14:32:07.481Z",
  "updatedAt": "2026-07-15T16:05:44.902Z",
  "queryBlocks": [
    {
      "id": "e5f6a7b8-9012-4abc-9def-567890abcdef",
      "order": 0,
      "setOperator": null,
      "from": { "table": "event_transactions", "joins": [], "unnests": [] },
      "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": 60
      },
      "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": "SUM",
              "operand": { "kind": "column", "column": { "table": "event_transactions", "column": "pricing.final_amount" } }
            },
            "comparisonOperator": "GREATER_THAN",
            "value": 100000
          }
        ]
      }
    }
  ],
  "compositions": [
    {
      "id": "f6a7b8c9-0123-4abc-9def-67890abcdef0",
      "operandCriteriaId": "b2c3d4e5-6789-4abc-9def-0123456789ab",
      "operandCriteriaName": "Usuarios activos",
      "setOperator": "INTERSECT_DISTINCT"
    }
  ]
}

Error Responses

StatusDescription
400Invalid DTO (wrong type, enum out of range, unknown property, empty or null name, empty queryBlocks) or, when queryBlocks is sent, a failed business rule (see validation rules). Also if the id is not a valid UUID.
401Missing or invalid Authorization: Bearer.
403The user lacks the audience-criteria:update permission, or the x-tenant-id is not authorized. Also when the criteria is the project's root audience and the user lacks project:update (No tiene permiso para editar la audiencia raíz del proyecto).
404No audience criteria exists with that id in the active project.