diff --git a/api/boards/create-chart.mdx b/api/boards/create-chart.mdx
index fa2a326..9ef1297 100644
--- a/api/boards/create-chart.mdx
+++ b/api/boards/create-chart.mdx
@@ -143,21 +143,27 @@ Each step in the `steps` array has the following shape:
| `op` | SQL equivalent | Notes |
| ------------ | ---------------------- | ----------------------------------------------------- |
-| `equals` | `= 'value'` | Exact match |
-| `notEquals` | `!= 'value'` | Inverse match |
+| `eq` | `= 'value'` | Exact match |
+| `neq` | `!= 'value'` | Inverse match |
| `in` | `IN (...)` | Pipe-delimited value: `"metamask\|rainbow\|coinbase"` |
-| `notIn` | `NOT IN (...)` | Pipe-delimited value |
+| `nin` | `NOT IN (...)` | Pipe-delimited value |
| `gt` | `> value` | Numeric comparison |
| `gte` | `>= value` | Numeric comparison |
| `lt` | `< value` | Numeric comparison |
| `lte` | `<= value` | Numeric comparison |
| `startsWith` | `startsWith(col, 'v')` | String prefix |
| `endsWith` | `endsWith(col, 'v')` | String suffix |
-| `includes` | `like '%v%'` | Substring match |
+| `contains` | `like '%v%'` | Substring match |
+| `notEmpty` | `col != ''` | Value-less existence check; `value` is ignored |
+| `isEmpty` | `col = ''` | Value-less existence check; `value` is ignored |
- For `in` and `notIn`, join multiple values with a pipe `|`. Escape a literal
- pipe with `\|` and a literal backslash with `\\`.
+ For `in` and `nin`, join multiple values with a pipe `|`. Escape a literal
+ pipe with `\|` and a literal backslash with `\\`. The legacy numeric long
+ forms (`greater`, `greaterOrEqual`, `less`, `lessOrEqual`) are deprecated
+ aliases for `gt` / `gte` / `lt` / `lte` and remain accepted. `notEmpty` and
+ `isEmpty` are rejected on the numeric event columns `volume` / `revenue` /
+ `points`.
### `settings` for Funnel Charts
@@ -221,8 +227,8 @@ Filter step 2 to MetaMask wallets on Ethereum mainnet:
{
"type": "event",
"event": "connect",
- "rdns": { "op": "equals", "value": "io.metamask" },
- "chain_id": { "op": "equals", "value": "1" }
+ "rdns": { "op": "eq", "value": "io.metamask" },
+ "chain_id": { "op": "eq", "value": "1" }
},
{ "type": "event", "event": "transaction" }
],
@@ -266,7 +272,7 @@ Filter step 2 to MetaMask wallets on Ethereum mainnet:
{
"type": "event",
"event": "page",
- "device": { "op": "equals", "value": "mobile" }
+ "device": { "op": "eq", "value": "mobile" }
},
{
"type": "event",
@@ -333,7 +339,7 @@ Each `RetentionUserFilter`:
| Field | Type | Description |
| ------- | ---------------- | ------------------------------------------------------------------------ |
| `field` | string | User property (e.g. `device`, `browser`, `os`, `utm_source`, `location`) |
-| `op` | string | Comparison operator (same set as `StepFilterCondition.op`) |
+| `op` | string | Comparison operator (same set as `StepFilterCondition.op`, minus the substring operators `startsWith` / `endsWith` / `contains`) |
| `value` | string \| number | Value to compare against |
```json
@@ -345,8 +351,8 @@ Each `RetentionUserFilter`:
"settings": {
"retentionFilter": { "type": "event", "event": "transaction" },
"retentionUserFilters": [
- { "field": "device", "op": "equals", "value": "desktop" },
- { "field": "utm_source", "op": "notEquals", "value": "direct" }
+ { "field": "device", "op": "eq", "value": "desktop" },
+ { "field": "utm_source", "op": "neq", "value": "direct" }
]
}
}
@@ -550,21 +556,27 @@ Each step in the `steps` array has the following shape:
| `op` | SQL equivalent | Notes |
| ------------ | ---------------------- | ----------------------------------------------------- |
-| `equals` | `= 'value'` | Exact match |
-| `notEquals` | `!= 'value'` | Inverse match |
+| `eq` | `= 'value'` | Exact match |
+| `neq` | `!= 'value'` | Inverse match |
| `in` | `IN (...)` | Pipe-delimited value: `"metamask\|rainbow\|coinbase"` |
-| `notIn` | `NOT IN (...)` | Pipe-delimited value |
+| `nin` | `NOT IN (...)` | Pipe-delimited value |
| `gt` | `> value` | Numeric comparison |
| `gte` | `>= value` | Numeric comparison |
| `lt` | `< value` | Numeric comparison |
| `lte` | `<= value` | Numeric comparison |
| `startsWith` | `startsWith(col, 'v')` | String prefix |
| `endsWith` | `endsWith(col, 'v')` | String suffix |
-| `includes` | `like '%v%'` | Substring match |
+| `contains` | `like '%v%'` | Substring match |
+| `notEmpty` | `col != ''` | Value-less existence check; `value` is ignored |
+| `isEmpty` | `col = ''` | Value-less existence check; `value` is ignored |
- For `in` and `notIn`, join multiple values with a pipe `|`. Escape a literal
- pipe with `\|` and a literal backslash with `\\`.
+ For `in` and `nin`, join multiple values with a pipe `|`. Escape a literal
+ pipe with `\|` and a literal backslash with `\\`. The legacy numeric long
+ forms (`greater`, `greaterOrEqual`, `less`, `lessOrEqual`) are deprecated
+ aliases for `gt` / `gte` / `lt` / `lte` and remain accepted. `notEmpty` and
+ `isEmpty` are rejected on the numeric event columns `volume` / `revenue` /
+ `points`.
### `settings` for Funnel Charts
@@ -628,8 +640,8 @@ Filter step 2 to MetaMask wallet connections on Ethereum mainnet:
{
"type": "event",
"event": "connect",
- "rdns": { "op": "equals", "value": "io.metamask" },
- "chain_id": { "op": "equals", "value": "1" }
+ "rdns": { "op": "eq", "value": "io.metamask" },
+ "chain_id": { "op": "eq", "value": "1" }
},
{ "type": "event", "event": "transaction" }
],
@@ -673,7 +685,7 @@ Filter step 2 to MetaMask wallet connections on Ethereum mainnet:
{
"type": "event",
"event": "page",
- "device": { "op": "equals", "value": "mobile" }
+ "device": { "op": "eq", "value": "mobile" }
},
{
"type": "event",
@@ -740,7 +752,7 @@ Each `RetentionUserFilter`:
| Field | Type | Description |
| ------- | ---------------- | ------------------------------------------------------------------------ |
| `field` | string | User property (e.g. `device`, `browser`, `os`, `utm_source`, `location`) |
-| `op` | string | Comparison operator (same set as `StepFilterCondition.op`) |
+| `op` | string | Comparison operator (same set as `StepFilterCondition.op`, minus the substring operators `startsWith` / `endsWith` / `contains`) |
| `value` | string \| number | Value to compare against |
```json
@@ -752,8 +764,8 @@ Each `RetentionUserFilter`:
"settings": {
"retentionFilter": { "type": "event", "event": "transaction" },
"retentionUserFilters": [
- { "field": "device", "op": "equals", "value": "desktop" },
- { "field": "utm_source", "op": "notEquals", "value": "direct" }
+ { "field": "device", "op": "eq", "value": "desktop" },
+ { "field": "utm_source", "op": "neq", "value": "direct" }
]
}
}
diff --git a/api/idempotency.mdx b/api/idempotency.mdx
index c77d7e3..541a095 100644
--- a/api/idempotency.mdx
+++ b/api/idempotency.mdx
@@ -72,7 +72,7 @@ curl -X POST https://api.formo.so/v0/alerts \
"name": "Daily revenue drop",
"trigger_type": "event",
"trigger_filters": [
- { "name": "event", "operator": "equals", "value": "transaction" }
+ { "name": "event", "operator": "eq", "value": "transaction" }
],
"recipient": [{ "type": "webhook", "value": ["https://myapp.com/formo-webhook"] }]
}'
diff --git a/api/openapi.json b/api/openapi.json
index 8ce66bd..dce71f6 100644
--- a/api/openapi.json
+++ b/api/openapi.json
@@ -168,7 +168,8 @@
"type": "string"
},
"operator": {
- "type": "string"
+ "type": "string",
+ "description": "Comparison operator for the trigger filter. Both the canonical filter operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `startsWith`, `endsWith`, `contains`, `notEmpty`, `isEmpty`) and their legacy aliases (`equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, `includes`, `like`, `=`, `!=`) are accepted; the value is normalized to canonical form before the alert condition is evaluated."
},
"value": {
"type": "string"
@@ -262,24 +263,26 @@
},
"StepFilterCondition": {
"type": "object",
- "description": "A filter condition for an event property on a funnel step. The `op` field specifies the comparison operator and `value` is the value to compare against. For `in` and `notIn` operators the value is a pipe-delimited string (e.g. `\"metamask|rainbow|coinbase\"`).",
+ "description": "A filter condition for an event property on a funnel step. The `op` field specifies the comparison operator and `value` is the value to compare against. For `in` and `nin` operators the value is a pipe-delimited string (e.g. `\"metamask|rainbow|coinbase\"`).",
"properties": {
"op": {
"type": "string",
"enum": [
- "equals",
- "notEquals",
+ "eq",
+ "neq",
"in",
- "notIn",
+ "nin",
"gt",
"gte",
"lt",
"lte",
"startsWith",
"endsWith",
- "includes"
+ "contains",
+ "notEmpty",
+ "isEmpty"
],
- "description": "Comparison operator. Use `in` / `notIn` for multi-value matching (pipe-delimited value string)."
+ "description": "Comparison operator. The terse comparison tokens (`gt`, `gte`, `lt`, `lte`) are canonical; the legacy long forms (`greater`, `greaterOrEqual`, `less`, `lessOrEqual`) remain accepted and equivalent (deprecated). Use `in` / `nin` for multi-value matching (pipe-delimited value string). `notEmpty` (\"is not empty\") and `isEmpty` (\"is empty\") are value-less existence checks on the property; the `value` is ignored. Existence checks are rejected on the numeric event columns `volume`/`revenue`/`points` — a number has no emptiness semantics."
},
"value": {
"oneOf": [
@@ -290,7 +293,7 @@
"type": "number"
}
],
- "description": "The comparison value. For `in` / `notIn`, use a pipe-delimited string: `\"metamask|rainbow|coinbase\"`."
+ "description": "The comparison value. For `in` / `nin`, use a pipe-delimited string: `\"metamask|rainbow|coinbase\"`."
}
},
"required": [
@@ -300,7 +303,7 @@
},
"FunnelStep": {
"type": "object",
- "description": "A single step in a funnel or user-path flow.\n\n`type` and `event` are required. Any additional property key becomes a filter condition using the `StepFilterCondition` schema (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`).\n\nStandard filterable columns: `origin`, `device`, `browser`, `os`, `location`, `referrer`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`. Any other key is treated as a JSON event property and extracted via `JSONExtractString(properties, '')`.",
+ "description": "A single step in a funnel or user-path flow.\n\n`type` and `event` are required. Any additional property key becomes a filter condition using the `StepFilterCondition` schema (e.g. `\"rdns\": { \"op\": \"eq\", \"value\": \"io.metamask\" }`).\n\nStandard filterable columns: `origin`, `device`, `browser`, `os`, `location`, `referrer`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`. Any other key is treated as a JSON event property and extracted via `JSONExtractString(properties, '')`.",
"properties": {
"type": {
"type": "string",
@@ -351,7 +354,7 @@
},
"AnalyticsFilterCondition": {
"type": "object",
- "description": "A single analytics filter condition. Use `in` / `notIn` with a pipe-delimited string value (e.g. `\"a|b|c\"`) for multi-value matches.",
+ "description": "A single analytics filter condition. Use `in` / `nin` with a pipe-delimited string value (e.g. `\"a|b|c\"`) for multi-value matches.",
"properties": {
"field": {
"type": "string",
@@ -360,19 +363,21 @@
"op": {
"type": "string",
"enum": [
- "equals",
- "notEquals",
- "greater",
- "less",
- "greaterOrEqual",
- "lessOrEqual",
+ "eq",
+ "neq",
+ "gt",
+ "lt",
+ "gte",
+ "lte",
"in",
- "notIn",
+ "nin",
"startsWith",
"endsWith",
- "includes"
+ "contains",
+ "notEmpty",
+ "isEmpty"
],
- "description": "Comparison operator. `startsWith` / `endsWith` / `includes` are substring matches on text columns (e.g. `referrer`, `ref`, `utm_*`, `origin`, `builder_codes`)."
+ "description": "Comparison operator. `startsWith` / `endsWith` / `contains` are substring matches on text columns (e.g. `referrer`, `ref`, `utm_*`, `origin`, `builder_codes`). `notEmpty` matches rows where the column has any non-empty value, and `isEmpty` is its complement (the column is empty/unset); both ignore `value` (pass any placeholder). Existence checks are rejected on the numeric event columns `volume`/`revenue`/`points`."
},
"value": {
"oneOf": [
@@ -386,7 +391,7 @@
"type": "boolean"
}
],
- "description": "Value to compare against. For `in` / `notIn`, pass a pipe-delimited string (e.g. `\"chrome|firefox\"`)."
+ "description": "Value to compare against. For `in` / `nin`, pass a pipe-delimited string (e.g. `\"chrome|firefox\"`)."
}
},
"required": [
@@ -406,19 +411,18 @@
"op": {
"type": "string",
"enum": [
- "equals",
- "notEquals",
+ "eq",
+ "neq",
"in",
- "notIn",
+ "nin",
"gt",
"gte",
"lt",
"lte",
- "startsWith",
- "endsWith",
- "includes"
+ "notEmpty",
+ "isEmpty"
],
- "description": "Comparison operator."
+ "description": "Comparison operator. The terse numeric tokens (`gt`, `gte`, `lt`, `lte`) are canonical; the legacy long forms (`greater`, `greaterOrEqual`, `less`, `lessOrEqual`) remain accepted and equivalent (deprecated). `notEmpty` (\"is not empty\") and `isEmpty` (\"is empty\") are value-less existence checks on a string user property; the `value` is ignored. Substring operators (`startsWith` / `endsWith` / `contains`) are not supported on retention user filters."
},
"value": {
"oneOf": [
@@ -449,13 +453,13 @@
"op": {
"type": "string",
"enum": [
- "greater",
- "greaterOrEqual",
- "less",
- "lessOrEqual",
- "equals"
+ "gt",
+ "gte",
+ "lt",
+ "lte",
+ "eq"
],
- "default": "greater",
+ "default": "gt",
"description": "Comparison operator. Numeric operators coerce both sides via toFloat64OrZero, so a numeric op on a non-numeric value yields no match (not an error)."
},
"value": {
@@ -634,7 +638,7 @@
"$ref": "#/components/schemas/FunnelStep"
},
"minItems": 2,
- "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep`; add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`)."
+ "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep`; add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"eq\", \"value\": \"io.metamask\" }`)."
},
"settings": {
"$ref": "#/components/schemas/ChartSettings"
@@ -709,7 +713,7 @@
"$ref": "#/components/schemas/FunnelStep"
},
"minItems": 2,
- "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep`; add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`)."
+ "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep`; add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"eq\", \"value\": \"io.metamask\" }`)."
},
"settings": {
"$ref": "#/components/schemas/ChartSettings"
@@ -1631,8 +1635,7 @@
"type": "object",
"required": [
"field",
- "op",
- "value"
+ "op"
],
"properties": {
"field": {
@@ -1649,11 +1652,23 @@
"lt",
"lte",
"in",
- "nin"
- ]
+ "nin",
+ "contains",
+ "notEmpty",
+ "isEmpty",
+ "equals",
+ "notEquals",
+ "greater",
+ "greaterOrEqual",
+ "less",
+ "lessOrEqual",
+ "notIn",
+ "includes"
+ ],
+ "description": "Comparison operator. The terse tokens (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `notEmpty`, `isEmpty`) are canonical; the long-form spellings (`equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, `includes`) are deprecated aliases that remain accepted and are folded to canonical at the boundary. `contains` (case-insensitive substring) is only supported on social fields (e.g. `users.twitter`, `users.email`). `notEmpty` (\"is not empty\" / \"is set\") is a string existence check for user string attributes (device/os/utm_*/referrer/…) and social fields; `isEmpty` (\"is empty\" / \"is not set\") is its complement, supported for user string attributes only (not social fields). Both ignore `value`. `users.lifecycle` supports only `eq` (a single stage) and `in` (a list of stages)."
},
"value": {
- "description": "Filter value (string, number, boolean, or array)"
+ "description": "Filter value (string, number, boolean, or array). Required for every operator except the existence checks `notEmpty` and `isEmpty`, which ignore it."
},
"scope": {
"type": "string",
@@ -1766,7 +1781,7 @@
"AnalyticsFilters": {
"name": "filters",
"in": "query",
- "description": "Array of filter conditions, JSON-encoded in the query string. Each entry is `{ field, op, value }`. Use `in` / `notIn` with a pipe-delimited `value` (e.g. `\"chrome|firefox\"`) for multi-value matching.",
+ "description": "Array of filter conditions, JSON-encoded in the query string. Each entry is `{ field, op, value }`. Use `in` / `nin` with a pipe-delimited `value` (e.g. `\"chrome|firefox\"`) for multi-value matching.",
"content": {
"application/json": {
"schema": {
@@ -1778,7 +1793,7 @@
"example": [
{
"field": "location",
- "op": "equals",
+ "op": "eq",
"value": "US"
},
{
@@ -1893,7 +1908,7 @@
"example": [
{
"field": "net_worth_usd",
- "op": "greater",
+ "op": "gt",
"value": "1000"
}
]
@@ -1936,7 +1951,7 @@
"protocol",
1,
"uniswap-v3",
- "greater",
+ "gt",
"0"
]
]
@@ -1961,7 +1976,7 @@
1,
"",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
- "greater",
+ "gt",
"100"
]
]
@@ -1983,7 +1998,7 @@
"example": [
[
1,
- "greater",
+ "gt",
"0"
]
]
@@ -2006,7 +2021,7 @@
{
"event": "swap",
"event_type": "track",
- "op": "greaterOrEqual",
+ "op": "gte",
"times": 1,
"relative_to": "first_seen",
"window": { "value": 24, "unit": "hour" }
@@ -2018,7 +2033,7 @@
"AnalyticsSourceFilter": {
"name": "source_filter",
"in": "query",
- "description": "JSON object filtering users by raw event source; which SDK/integration the event was sent from (the raw `channel` column). Format: `{ op, value }` where `op` is one of `equals`, `notEquals`, `in`, `notIn` and `value` is one of `web`, `api`, `import`, `mobile`, `server`, `onchain` (pipe-delimited for `in`/`notIn`). Distinct from `channel_filter`, which matches the classified acquisition channel.",
+ "description": "JSON object filtering users by raw event source; which SDK/integration the event was sent from (the raw `channel` column). Format: `{ op, value }` where `op` is one of `eq`, `neq`, `in`, `nin` and `value` is one of `web`, `api`, `import`, `mobile`, `server`, `onchain` (pipe-delimited for `in`/`nin`). Distinct from `channel_filter`, which matches the classified acquisition channel.",
"content": {
"application/json": {
"schema": {
@@ -2034,7 +2049,7 @@
"AnalyticsChannelFilter": {
"name": "channel_filter",
"in": "query",
- "description": "JSON object filtering users by classified acquisition channel; the precomputed `channel_type` column (GA4-style attribution). Format: `{ op, value }` where `op` is one of `equals`, `notEquals`, `in`, `notIn` and `value` is one of `Direct`, `Organic Search`, `Organic Social`, `Organic Video`, `Paid Search`, `Paid Social`, `Paid Video`, `Email`, `Referrals`, `Display`, `AI`, `Referrers` (pipe-delimited for `in`/`notIn`). Distinct from `source_filter`, which matches the raw SDK source.",
+ "description": "JSON object filtering users by classified acquisition channel; the precomputed `channel_type` column (GA4-style attribution). Format: `{ op, value }` where `op` is one of `eq`, `neq`, `in`, `nin` and `value` is one of `Direct`, `Organic Search`, `Organic Social`, `Organic Video`, `Paid Search`, `Paid Social`, `Paid Video`, `Email`, `Referrals`, `Display`, `AI`, `Referrers` (pipe-delimited for `in`/`nin`). Distinct from `source_filter`, which matches the raw SDK source.",
"content": {
"application/json": {
"schema": {
@@ -2063,7 +2078,7 @@
[
"whale",
1,
- "equals",
+ "eq",
"true"
]
]
@@ -2281,7 +2296,7 @@
"trigger_filters": [
{
"name": "event",
- "operator": "equals",
+ "operator": "eq",
"value": "transaction"
},
{
@@ -2351,7 +2366,8 @@
"type": "string"
},
"operator": {
- "type": "string"
+ "type": "string",
+ "description": "Comparison operator for the trigger filter. Both the canonical filter operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `startsWith`, `endsWith`, `contains`, `notEmpty`, `isEmpty`) and their legacy aliases (`equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, `includes`, `like`, `=`, `!=`) are accepted; the value is normalized to canonical form before the alert condition is evaluated."
},
"value": {
"type": "string"
@@ -2409,17 +2425,17 @@
{
"name": "type",
"value": "swap",
- "operator": "equals"
+ "operator": "eq"
},
{
"name": "device",
"value": "mobile",
- "operator": "equals"
+ "operator": "eq"
},
{
"name": "volume",
"value": "1000",
- "operator": "greater",
+ "operator": "gt",
"numericThreshold": "1000"
}
],
@@ -2453,12 +2469,12 @@
{
"name": "utm_source",
"value": "google",
- "operator": "equals"
+ "operator": "eq"
},
{
"name": "utm_medium",
"value": "cpc",
- "operator": "equals"
+ "operator": "eq"
}
],
"recipient": [
@@ -2480,12 +2496,12 @@
{
"name": "location",
"value": "US",
- "operator": "equals"
+ "operator": "eq"
},
{
"name": "net_worth_usd",
"value": "10000",
- "operator": "greaterOrEqual",
+ "operator": "gte",
"numericThreshold": "10000"
}
],
@@ -2508,13 +2524,13 @@
{
"name": "apps",
"value": "uniswap",
- "operator": "greater",
+ "operator": "gt",
"numericThreshold": "5"
},
{
"name": "chains",
"value": "1",
- "operator": "greaterOrEqual",
+ "operator": "gte",
"numericThreshold": "3"
}
],
@@ -2580,7 +2596,7 @@
"trigger_filters": [
{
"name": "event",
- "operator": "equals",
+ "operator": "eq",
"value": "transaction"
},
{
@@ -3245,11 +3261,11 @@
"type": "event",
"event": "connect",
"rdns": {
- "op": "equals",
+ "op": "eq",
"value": "io.metamask"
},
"chain_id": {
- "op": "equals",
+ "op": "eq",
"value": "1"
}
},
@@ -3310,7 +3326,7 @@
"type": "event",
"event": "page",
"device": {
- "op": "equals",
+ "op": "eq",
"value": "mobile"
}
},
@@ -3464,7 +3480,7 @@
"retentionUserFilters": [
{
"field": "device",
- "op": "equals",
+ "op": "eq",
"value": "desktop"
}
]
@@ -4100,7 +4116,7 @@
"projectId": "proj_abc123",
"title": "High net worth desktop users",
"filterSet": [
- "{\"field\":\"device\",\"op\":\"equals\",\"value\":\"desktop\"}",
+ "{\"field\":\"device\",\"op\":\"eq\",\"value\":\"desktop\"}",
"{\"field\":\"net_worth_usd\",\"op\":\"greaterOrEqual\",\"value\":100000}"
]
}
@@ -4152,9 +4168,9 @@
"value": {
"title": "High-value mobile users",
"filterSets": [
- "device::equals::mobile",
- "net_worth_usd::greaterOrEqual::10000",
- "utm_source::equals::paid_ads"
+ "device::eq::mobile",
+ "net_worth_usd::gte::10000",
+ "utm_source::eq::paid_ads"
]
}
},
@@ -4172,7 +4188,7 @@
"value": {
"title": "Non-US users",
"filterSets": [
- "location::notEquals::US"
+ "location::neq::US"
]
}
},
@@ -4181,9 +4197,9 @@
"value": {
"title": "Organic power users",
"filterSets": [
- "net_worth_usd::greater::1000",
- "utm_source::notIn::google_ads|facebook_ads",
- "lifecycle::equals::power"
+ "net_worth_usd::gt::1000",
+ "utm_source::nin::google_ads|facebook_ads",
+ "lifecycle::eq::power"
]
}
},
@@ -4192,19 +4208,19 @@
"value": {
"title": "Multi-chain whales",
"filterSets": [
- "chains::greaterOrEqual::3",
- "net_worth_usd::greater::100000",
- "apps::greater::5"
+ "chains::gte::3",
+ "net_worth_usd::gt::100000",
+ "apps::gt::5"
]
}
},
"behavioralFilterSimple": {
"summary": "Users who performed 'signature' event at least once in last 30 days",
- "description": "Behavioural filters use the 'events' filter key with a base64-encoded JSON array as the value. The JSON contains event name, frequency operator (greater/greaterOrEqual/equals/lessOrEqual/less), times count, and date range.",
+ "description": "Behavioural filters use the 'events' filter key with a base64-encoded JSON array as the value. The JSON contains event name, frequency operator (gt/gte/eq/lte/lt), times count, and date range.",
"value": {
"title": "Recent signers",
"filterSets": [
- "events::equals::W3siZXZlbnQiOiAic2lnbmF0dXJlIiwgIm9wZXJhdG9yIjogImdyZWF0ZXJPckVxdWFsIiwgInRpbWVzIjogMSwgInByZXNldCI6ICJsYXN0XzMwZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0wOS0xOSIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifV0="
+ "events::eq::W3siZXZlbnQiOiAic2lnbmF0dXJlIiwgIm9wZXJhdG9yIjogImdyZWF0ZXJPckVxdWFsIiwgInRpbWVzIjogMSwgInByZXNldCI6ICJsYXN0XzMwZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0wOS0xOSIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifV0="
]
}
},
@@ -4214,8 +4230,8 @@
"value": {
"title": "Indian MetaMask users",
"filterSets": [
- "events::equals::W3siZXZlbnQiOiAiY29ubmVjdCIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5IiwgInJkbnMiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICJpby5tZXRhbWFzayJ9LCAiY2hhaW5faWQiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICIxIn19XQ==",
- "location::equals::IN"
+ "events::eq::W3siZXZlbnQiOiAiY29ubmVjdCIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5IiwgInJkbnMiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICJpby5tZXRhbWFzayJ9LCAiY2hhaW5faWQiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICIxIn19XQ==",
+ "location::eq::IN"
]
}
},
@@ -4225,8 +4241,8 @@
"value": {
"title": "Active transactors",
"filterSets": [
- "events::equals::W3siZXZlbnQiOiAicGFnZSIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDUsICJwcmVzZXQiOiAibGFzdF83ZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0xMC0xMiIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifSwgeyJldmVudCI6ICJ0cmFuc2FjdGlvbiIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5In1d",
- "device::equals::desktop"
+ "events::eq::W3siZXZlbnQiOiAicGFnZSIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDUsICJwcmVzZXQiOiAibGFzdF83ZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0xMC0xMiIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifSwgeyJldmVudCI6ICJ0cmFuc2FjdGlvbiIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5In1d",
+ "device::eq::desktop"
]
}
}
@@ -7274,7 +7290,7 @@
"example": [
{
"field": "device",
- "op": "equals",
+ "op": "eq",
"value": "desktop"
}
]
@@ -7407,7 +7423,7 @@
"get": {
"operationId": "getAnalyticsFunnel",
"summary": "Get multi-step conversion funnel",
- "description": "Multi-step conversion funnel. For an ordered list of step specs, returns one row per step with the unique-user count, conversion ratios against step 1 and the previous step, drop-off ratio, and median time-to-convert (from-previous and from-start, in seconds).\n\n**Closed vs open:** `funnel_type=closed` (default) requires the user to fire the steps in order within `window_seconds`. `funnel_type=open` counts whoever fired step k regardless of order, and adds a `dropped_off_users` column for each step.\n\n**Breakdown:** when `group_by` is set to a column from the breakdown allowlist (`device`, `browser`, `os`, `location`, `referrer`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `builder_codes`), the response gains a `breakdown` column and is grouped per (step, top-N category + 'Others', up to `limit` which defaults to 5). First-touch attribution is used by default; pass `attribution=last_touch` to bucket by each user's latest value.\n\n**Steps:** the `steps` query param is a JSON-encoded array of 2 to 10 step specs of shape `{type, event, name, filters?: [{operand, operator, value}]}`. Use `name` (e.g. `\"page::0\"`) as a unique step id so the same event re-used at different steps can be disambiguated in the response. `operator` accepts `equals | notEquals | in | notIn | gt | lt | gte | lte | startsWith | endsWith | includes`. `operand` may target a standard event column or a JSON property on `properties`.",
+ "description": "Multi-step conversion funnel. For an ordered list of step specs, returns one row per step with the unique-user count, conversion ratios against step 1 and the previous step, drop-off ratio, and median time-to-convert (from-previous and from-start, in seconds).\n\n**Closed vs open:** `funnel_type=closed` (default) requires the user to fire the steps in order within `window_seconds`. `funnel_type=open` counts whoever fired step k regardless of order, and adds a `dropped_off_users` column for each step.\n\n**Breakdown:** when `group_by` is set to a column from the breakdown allowlist (`device`, `browser`, `os`, `location`, `referrer`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `builder_codes`), the response gains a `breakdown` column and is grouped per (step, top-N category + 'Others', up to `limit` which defaults to 5). First-touch attribution is used by default; pass `attribution=last_touch` to bucket by each user's latest value.\n\n**Steps:** the `steps` query param is a JSON-encoded array of 2 to 10 step specs of shape `{type, event, name, filters?: [{operand, operator, value}]}`. Use `name` (e.g. `\"page::0\"`) as a unique step id so the same event re-used at different steps can be disambiguated in the response. `operator` accepts `equals | notEquals | in | notIn | gt | lt | gte | lte | greater | greaterOrEqual | less | lessOrEqual | startsWith | endsWith | includes | notEmpty | isEmpty` (the short numeric aliases `gt`/`gte`/`lt`/`lte` and their canonical long forms are equivalent; `notEmpty`/`isEmpty` are value-less existence checks, rejected on the numeric event columns `volume`/`revenue`/`points`). `operand` may target a standard event column or a JSON property on `properties`.",
"tags": [
"Query"
],
@@ -7442,7 +7458,7 @@
"schema": {
"type": "string"
},
- "description": "JSON-encoded array of 2 to 10 step specs. Each step is `{type, event, name, filters?: [{operand, operator, value}]}`. `type` is the event type (e.g. `event` for page views, `track` for custom events, `transaction`, `signature`, `decoded_log`). `event` is the event name. `name` is the unique step id (use `\"::\"` to disambiguate repeated events).\n\n**Filter operators:** `equals`, `notEquals`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `startsWith`, `endsWith`, `includes`. For `in`/`notIn`, pass the values as a `|`-separated string in `value` (e.g. `\"ethereum|polygon|base\"`).\n\n**Standard columns** (rendered via direct column access): `origin`, `device`, `browser`, `os`, `location`, `referrer`, `direct`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `builder_codes`, `version`, `locale`, `timezone`, `page_path`. Anything else is treated as a JSON property and read from `properties` via `JSONExtractString` (or `JSONExtractFloat` for numeric comparators).",
+ "description": "JSON-encoded array of 2 to 10 step specs. Each step is `{type, event, name, filters?: [{operand, operator, value}]}`. `type` is the event type (e.g. `event` for page views, `track` for custom events, `transaction`, `signature`, `decoded_log`). `event` is the event name. `name` is the unique step id (use `\"::\"` to disambiguate repeated events).\n\n**Filter operators:** `equals`, `notEquals`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `startsWith`, `endsWith`, `includes`, `notEmpty`, `isEmpty` (the short numeric aliases and their canonical long forms are equivalent; `notEmpty`/`isEmpty` are value-less existence checks, rejected on numeric event columns `volume`/`revenue`/`points`). For `in`/`notIn`, pass the values as a `|`-separated string in `value` (e.g. `\"ethereum|polygon|base\"`).\n\n**Standard columns** (rendered via direct column access): `origin`, `device`, `browser`, `os`, `location`, `referrer`, `direct`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `builder_codes`, `version`, `locale`, `timezone`, `page_path`. Anything else is treated as a JSON property and read from `properties` via `JSONExtractString` (or `JSONExtractFloat` for numeric comparators).",
"examples": {
"simple": {
"summary": "Simple 3-step funnel: page → connect → swap",
@@ -7678,7 +7694,7 @@
"schema": {
"type": "string"
},
- "description": "JSON-encoded start-step spec of shape `{type, event, resolved_event, filters?: [...], status_type?, status_value?}`. Use `\"resolved_event\":\"__ALL_PAGE_VIEWS__\"` to match any page view; pass a page path for a specific landing page; or pass an event name for `track`/`decoded_log` start events. `filters` use `{operand, operator, value, values?}` (use `values` for `in`/`notIn`).\n\n**Filter operators:** `equals`, `notEquals`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `startsWith`, `endsWith`, `includes`. Standard columns (`device`, `browser`, `os`, `location`, `referrer`, `utm_*`, `page_path`, etc.) are read directly; everything else is read from `properties` via `JSONExtractString` (or `JSONExtractFloat` for numeric comparators).",
+ "description": "JSON-encoded start-step spec of shape `{type, event, resolved_event, filters?: [...], status_type?, status_value?}`. Use `\"resolved_event\":\"__ALL_PAGE_VIEWS__\"` to match any page view; pass a page path for a specific landing page; or pass an event name for `track`/`decoded_log` start events. `filters` use `{operand, operator, value, values?}` (use `values` for `in`/`notIn`).\n\n**Filter operators:** `equals`, `notEquals`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `startsWith`, `endsWith`, `includes`, `notEmpty`, `isEmpty` (the short numeric aliases and their canonical long forms are equivalent; `notEmpty`/`isEmpty` are value-less existence checks, rejected on numeric event columns `volume`/`revenue`/`points`). Standard columns (`device`, `browser`, `os`, `location`, `referrer`, `utm_*`, `page_path`, etc.) are read directly; everything else is read from `properties` via `JSONExtractString` (or `JSONExtractFloat` for numeric comparators).",
"examples": {
"anyPageView": {
"summary": "Match any page view as the start step",
diff --git a/api/profiles/search.mdx b/api/profiles/search.mdx
index b9eb2b5..d93eb5d 100644
--- a/api/profiles/search.mdx
+++ b/api/profiles/search.mdx
@@ -146,6 +146,12 @@ Each condition in the `conditions` array has:
| `in` | In array | `{"field": "users.lifecycle", "op": "in", "value": ["New", "Power user"]}` |
| `nin` | Not in array | `{"field": "users.location", "op": "nin", "value": ["US", "UK"]}` |
| `contains` | Case-insensitive substring match for social fields only | `{"field": "users.twitter", "op": "contains", "value": "vitalik"}` |
+| `notEmpty` | Field is set (non-empty). String fields only — value is ignored | `{"field": "users.email", "op": "notEmpty"}` |
+| `isEmpty` | Field is not set (empty). String user attributes only — value is ignored | `{"field": "users.utm_source", "op": "isEmpty"}` |
+
+
+**Deprecated aliases.** The long-form spellings `equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, and `includes` are deprecated but still accepted on this endpoint — they are folded to the canonical operators above at the input boundary, so existing integrations keep working. Use the canonical operators for new integrations; the aliases may be retired in a future release. `notEmpty` / `isEmpty` are value-less existence checks (any `value` is ignored) and are supported on string user/profile attributes only — not on numeric metrics (`net_worth_usd`, `volume`, `revenue`, `points`), the `lifecycle` enum, or chain/app/token/label fields.
+
---
@@ -263,8 +269,8 @@ Filter by user lifecycle stage. Valid values: `At Risk`, `Churned`, `New`, `Powe
Social fields support two modes:
-- Presence checks: use an empty string value to match profiles where the field is present.
-- Value matching: use `eq`, `neq`, or `contains` with a non-empty string to match the actual social value.
+- Presence checks: use `notEmpty` to match profiles where the field is set (or `isEmpty` for the opposite). The value is ignored.
+- Value matching: use `eq`, `neq`, or `contains` (case-insensitive substring) with a non-empty string to match the actual social value.
| Field | Description |
|-------|-------------|
@@ -291,7 +297,7 @@ Social fields support two modes:
```json
{
"conditions": [
- { "field": "users.email", "op": "eq", "value": "" }
+ { "field": "users.email", "op": "notEmpty" }
],
"logic": "and"
}
@@ -302,8 +308,8 @@ Social fields support two modes:
```json
{
"conditions": [
- { "field": "users.ens", "op": "eq", "value": "" },
- { "field": "users.farcaster", "op": "eq", "value": "" }
+ { "field": "users.ens", "op": "notEmpty" },
+ { "field": "users.farcaster", "op": "notEmpty" }
],
"logic": "and"
}
@@ -591,7 +597,7 @@ curl -sS -X GET \
-d '{
"conditions": [
{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 },
- { "field": "users.ens", "op": "eq", "value": "" },
+ { "field": "users.ens", "op": "notEmpty" },
{ "field": "chains.1.balance", "op": "gt", "value": 0 }
],
"logic": "and"
diff --git a/api/query/event-timeseries.mdx b/api/query/event-timeseries.mdx
index e5a8226..369bc20 100644
--- a/api/query/event-timeseries.mdx
+++ b/api/query/event-timeseries.mdx
@@ -8,4 +8,4 @@ Returns total event counts per day. Combine with `filters` to scope to a specifi
Set `group_by` to a breakdown dimension (`channel_type`, `device`, `browser`, `os`, `location`, `referrer`, `ref`, `builder_codes`, or any UTM column) to split the series by that dimension. When set, `event_key` carries the dimension's value (empty → `Direct`) instead of the event type/name; the response keeps the top 100 values and buckets the rest as `Others`.
-`filters` supports the string operators `startsWith`, `endsWith`, and `includes` (substring matches on text columns such as `referrer`, `ref`, `utm_*`) alongside the standard comparison operators. An unsupported operator now returns `400 Invalid query parameters` rather than silently matching everything.
+`filters` supports the string operators `startsWith`, `endsWith`, and `contains` (substring matches on text columns such as `referrer`, `ref`, `utm_*`) alongside the standard comparison operators. An unsupported operator now returns `400 Invalid query parameters` rather than silently matching everything.
diff --git a/api/query/funnel.mdx b/api/query/funnel.mdx
index 82ab4d3..822fbe9 100644
--- a/api/query/funnel.mdx
+++ b/api/query/funnel.mdx
@@ -34,6 +34,8 @@ The `steps` query parameter is a JSON-encoded array of 2 to 10 step specs. Each
- `startsWith`
- `endsWith`
- `includes`
+ - `notEmpty` / `isEmpty` - value-less existence checks (the `value` is ignored; rejected on the numeric event columns `volume`/`revenue`/`points`)
+ - The numeric long forms `greater`, `greaterOrEqual`, `less`, `lessOrEqual` are accepted as equivalents of `gt` / `gte` / `lt` / `lte`.
- **`operand`** may target a standard event column or a JSON property on `properties`.
## Example
diff --git a/cli/analytics.mdx b/cli/analytics.mdx
index 7234666..9079e7a 100644
--- a/cli/analytics.mdx
+++ b/cli/analytics.mdx
@@ -52,7 +52,7 @@ Requires `query:read` scope on your API key.
### `--filters`
-A JSON array of `{ field, op, value }` conditions, e.g. `[{"field":"location","op":"equals","value":"US"}]`. For multi-value matching, use `in` / `notIn` with a pipe-delimited `value` (e.g. `"chrome|firefox"`).
+A JSON array of `{ field, op, value }` conditions, e.g. `[{"field":"location","op":"eq","value":"US"}]`. For multi-value matching, use `in` / `nin` with a pipe-delimited `value` (e.g. `"chrome|firefox"`).
### `--params` (pipe-specific)
@@ -87,7 +87,7 @@ formo analytics funnel \
formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params '{"limit":10}'
# Retention restricted to US visitors
-formo analytics retention --filters '[{"field":"location","op":"equals","value":"US"}]'
+formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]'
# Revenue timeseries for a specific wallet
formo analytics revenue_timeseries \
diff --git a/cli/profiles.mdx b/cli/profiles.mdx
index 8834b1b..bccc34e 100644
--- a/cli/profiles.mdx
+++ b/cli/profiles.mdx
@@ -125,7 +125,9 @@ The response is a paginated envelope: `{ data, total, page, size, has_more }`.
### Filter Operators
-`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`
+`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `notEmpty`, `isEmpty`
+
+The long-form spellings (`equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, `includes`) are deprecated aliases — still accepted by the API, but new usage should stick to the canonical operators above.
---