# Create Alert Source: https://docs.formo.so/api/alerts/create POST /v0/alerts Create a new alert for your project. Define trigger conditions and configure webhook notification delivery (including Slack). # Delete Alert Source: https://docs.formo.so/api/alerts/delete DELETE /v0/alerts/{alertId} Permanently delete an alert by ID. Removes the alert configuration and stops all future notifications. # Get Alert Source: https://docs.formo.so/api/alerts/get GET /v0/alerts/{alertId} Retrieve a single alert by ID. Returns the alert name, trigger conditions, notification channel, and status. # List Alerts Source: https://docs.formo.so/api/alerts/list GET /v0/alerts Retrieve all alerts configured for your project. Returns alert names, trigger conditions, notification channels, and enabled or disabled status. # Toggle Alert Source: https://docs.formo.so/api/alerts/toggle PATCH /v0/alerts/{alertId} Enable or disable an alert by ID. Toggling an alert pauses or resumes notifications without deleting the alert configuration. # Update Alert Source: https://docs.formo.so/api/alerts/update PUT /v0/alerts/{alertId} Modify an existing alert by ID. Update the alert name, conditions, and webhook notification configuration through the Formo API. # Authentication Source: https://docs.formo.so/api/authentication Learn how to authenticate your Formo API requests. Use your API Key to call to the Formo Events API, Query API, BI integration, and more. All API endpoints require authentication using either: * a **Workspace API Key** to query data and fetch profiles * a **SDK Write Key** to send events You'll need to include the key or token in the request headers for all API calls. ## Workspace API Key Find your API key in your workspace settings. Create one with the appropriate scope if you don't have one yet. Workspace API keys require a **Scale or Enterprise** plan. [Upgrade your workspace](https://app.formo.so) to create one. Where to find your Workspace API key To access the Profiles and Query API, include your workspace API key in your request headers as a Bearer token: ```bash theme={null} Authorization: Bearer ``` API Authentication Flow Diagram ## API Scopes When creating a Workspace API Key, select the scopes your key needs. Each API endpoint requires a specific scope. | Scope | Description | | ----------------- | --------------------------------------------------------------- | | `profiles:read` | Search and get wallet profiles | | `profiles:write` | Import wallet addresses (requires profiles:read) | | `query:read` | Execute SQL analytics queries | | `alerts:read` | List and get alerts | | `alerts:write` | Create, update, delete alerts (requires alerts:read) | | `boards:read` | List and get boards and charts | | `boards:write` | Create, update, delete boards and charts (requires boards:read) | | `contracts:read` | List contracts | | `contracts:write` | Create, update, delete contracts (requires contracts:read) | | `segments:read` | List segments | | `segments:write` | Create and delete segments (requires segments:read) | Write scopes automatically require the corresponding read scope. Create keys with the required scopes in Team Settings > API. ## SDK Write Key Find your SDK Write Key in your project settings. Tokens in project settings page To send events through the Events API, include the SDK Write Key in your request headers: ```bash theme={null} Authorization: Bearer ``` # Create Board Source: https://docs.formo.so/api/boards/create POST /v0/boards Create a new dashboard board for your project. Specify a title and optional description to organize your analytics charts and visualizations. # Create Chart Source: https://docs.formo.so/api/boards/create-chart POST /v0/boards/{boardId}/charts Add a new chart to a dashboard board with a SQL query and chart type. Supports line, bar, pie, funnel, and other visualization types. ## Overview Creates a new chart and attaches it to a board. Returns the full chart object on success. The `chart_type` field controls which other request body fields are required or validated. Funnel charts derive SQL from `steps` and accept `"SELECT 1"` as the placeholder. User Paths charts derive SQL from `settings.anchors`, so omit `query`. *** ## Request Fields | Field | Type | Required | Description | | ------------- | ------------- | ----------- | -------------------------------------------------------------------------------------------------------- | | `projectId` | string | Yes | Project the chart belongs to | | `query` | string | Conditional | SQL query. Omit for `user_paths` and `retention`. For `funnel`, pass `"SELECT 1"` | | `chart_type` | string | Yes | `table` · `number` · `bar` · `line` · `area` · `pie` · `stacked` · `funnel` · `user_paths` · `retention` | | `title` | string | Yes | Display name (minimum 1 character) | | `description` | string | No | Optional description | | `x_axis` | string | Conditional | Column for the X axis. Required for `bar`, `line`, `area`, `stacked` | | `y_axis` | string\[] | Conditional | Column(s) for Y axis metrics. See per-type rules below | | `group_by` | string | Conditional | Column to group/stack series by. Required for `stacked` | | `steps` | FunnelStep\[] | Conditional | Ordered funnel steps. Required for `funnel` (minimum 2) | | `settings` | ChartSettings | No | Type-specific configuration object | *** ## Chart Types ### `table` Renders query results in a paginated table. No axis fields needed. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT * FROM events ORDER BY timestamp DESC LIMIT 10", "chart_type": "table", "title": "Recent Events" } ``` *** ### `number` Renders a single scalar value (KPI card). The query **must** return exactly **1 row × 1 column**. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT COUNT(DISTINCT address) FROM events WHERE type = 'connect'", "chart_type": "number", "title": "Total Connected Wallets" } ``` *** ### `bar`, `line`, and `area` All three require `x_axis` and at least **1** column in `y_axis`. `area` requires exactly **1** column in `y_axis` when `group_by` is set. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date DESC LIMIT 30", "chart_type": "line", "title": "Daily Active Users", "x_axis": "date", "y_axis": ["users"] } ``` *** ### `pie` Requires exactly **1** column in `y_axis`. `x_axis` identifies the label column. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT device, COUNT(*) AS session_count FROM sessions GROUP BY device ORDER BY session_count DESC LIMIT 10", "chart_type": "pie", "title": "Sessions by Device", "x_axis": "device", "y_axis": ["session_count"] } ``` *** ### `stacked` Requires `x_axis`, exactly **1** column in `y_axis`, and `group_by`. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT device, browser, COUNT(*) AS session_count FROM sessions GROUP BY device, browser ORDER BY session_count DESC", "chart_type": "stacked", "title": "Sessions by Device and Browser", "x_axis": "device", "y_axis": ["session_count"], "group_by": "browser" } ``` *** ## Funnel Charts Funnel charts measure step-by-step user conversion. The SQL is auto-generated from `steps`, so `query` must be the placeholder `"SELECT 1"`. At least **2 steps** are required. ### The `FunnelStep` Object Each step in the `steps` array has the following shape: ```json theme={null} { "type": "event | track | decoded_log", "event": "", "filters": [{ "field": "", "op": "", "value": "" }] } ``` | Field | Type | Required | Description | | --------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | string | Yes | `event` - built-in events (page, connect, transaction); `track` - custom tracked events; `decoded_log` - decoded smart-contract events | | `event` | string | Yes | Event name (e.g. `page`, `connect`, `transaction`) | | `filters` | `StepFilterCondition[]` | No | Canonical `{field, op, value}` property filters. Standard columns are filtered directly; other fields are extracted from event `properties`. | ### Filter Operators (`StepFilterCondition`) ```json theme={null} { "field": "", "op": "", "value": "" } ``` | `op` | SQL equivalent | Notes | | ------------ | ---------------------- | ----------------------------------------------------- | | `eq` | `= 'value'` | Exact match | | `neq` | `!= 'value'` | Inverse match | | `in` | `IN (...)` | Pipe-delimited value: `"metamask\|rainbow\|coinbase"` | | `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 | | `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 `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 retired and rejected with a `400` naming the token. Send `gt` / `gte` / `lt` / `lte`. `notEmpty` and `isEmpty` are rejected on the numeric event columns `volume` / `revenue` / `points`. ### `settings` for Funnel Charts | Field | Type | Default | Description | | ------------------ | ---------------------- | ---------- | -------------------------------------------------------------------------------------------------------------- | | `funnelType` | `"closed"` \| `"open"` | `"closed"` | `closed` - strict ordering, no events between steps; `open` - ordered but other events may occur between steps | | `conversionWindow` | `ConversionWindow` | 2 days | Max time from Step 1 for a user to complete all steps | | `breakdown` | string | - | Split each funnel bar by this dimension | #### `ConversionWindow` ```json theme={null} { "value": 7, "unit": "day" } ``` `unit` must be one of: `hour` · `day` · `week` (7 days). #### `breakdown` values `device` · `browser` · `os` · `location` · `referrer` · `ref` · `utm_source` · `utm_medium` · `utm_campaign` · `utm_term` · `utm_content` · `builder_codes` The top categories are shown individually. The rest collapse into **"Others"**. *** ### Funnel Examples #### Basic 3-step closed funnel ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "Onboarding Funnel", "steps": [ { "type": "event", "event": "page" }, { "type": "event", "event": "connect" }, { "type": "event", "event": "transaction" } ], "settings": { "funnelType": "closed", "conversionWindow": { "value": 7, "unit": "day" } } } ``` #### With per-step property filters Filter step 2 to MetaMask wallets on Ethereum mainnet: ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "MetaMask Conversion Funnel", "steps": [ { "type": "event", "event": "page" }, { "type": "event", "event": "connect", "filters": [ { "field": "rdns", "op": "eq", "value": "io.metamask" }, { "field": "chain_id", "op": "eq", "value": "1" } ] }, { "type": "event", "event": "transaction" } ], "settings": { "funnelType": "closed", "conversionWindow": { "value": 7, "unit": "day" } } } ``` #### With breakdown by device ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "Onboarding Funnel by Device", "steps": [ { "type": "event", "event": "page" }, { "type": "event", "event": "connect" }, { "type": "event", "event": "signature" } ], "settings": { "funnelType": "closed", "conversionWindow": { "value": 30, "unit": "day" }, "breakdown": "device" } } ``` #### Open funnel with multi-value `in` filter ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "Mobile Onboarding Funnel", "steps": [ { "type": "event", "event": "page", "filters": [{ "field": "device", "op": "eq", "value": "mobile" }] }, { "type": "event", "event": "connect", "filters": [ { "field": "provider_name", "op": "in", "value": ["metamask", "rainbow", "coinbase"] } ] }, { "type": "event", "event": "transaction" } ], "settings": { "funnelType": "open", "conversionWindow": { "value": 30, "unit": "day" }, "breakdown": "device" } } ``` *** ## User Paths Charts Visualize how users navigate your app through an ordered list of path anchors. `settings.anchors` requires at least one entry. One anchor produces an open-ended exploration; two or more constrain the path in order. ### `settings` for User Paths Charts | Field | Type | Default | Description | | ------------------ | ------------------ | ------- | ---------------------------------------------------------------- | | `anchors` | `FunnelStep[]` | - | **Required.** Ordered path anchors; one entry is open-ended | | `maxSteps` | integer (2 to 5) | `3` | Max steps to show in the flow. Values above 5 are clamped to 5 | | `nodesPerStep` | integer (2 to 8) | `5` | Max unique event nodes per step. Values above 8 are clamped to 8 | | `conversionWindow` | `ConversionWindow` | - | Time window to group the flow | | `filters` | string | - | JSON-encoded string of additional path filters | ```json theme={null} { "projectId": "proj_abc", "chart_type": "user_paths", "title": "Post-Connect User Flow", "settings": { "anchors": [ { "type": "event", "event": "connect" }, { "type": "event", "event": "transaction" } ], "maxSteps": 5, "conversionWindow": { "value": 2, "unit": "week" } } } ``` *** ## Retention Charts Measures how often users return over time. Omit the `query` field; retention data is fetched automatically. ### `settings` for Retention Charts | Field | Type | Description | | ---------------------- | ----------------------- | ---------------------------------------------------------------------- | | `entryFilter` | `FunnelStep` \| `null` | **Required key.** Event that enters the cohort; `null` means any event | | `retentionFilter` | `FunnelStep` \| `null` | Event that qualifies a return as "retained"; `null` means any event | | `retentionUserFilters` | `RetentionUserFilter[]` | User-segment filters that narrow the retention cohort | 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`, minus the substring operators `startsWith` / `endsWith` / `contains`) | | `value` | string \| number | Value to compare against | ```json theme={null} { "projectId": "proj_abc", "chart_type": "retention", "title": "Weekly Retention - Desktop Users", "settings": { "entryFilter": { "type": "event", "event": "transaction" }, "retentionFilter": { "type": "event", "event": "transaction" }, "retentionUserFilters": [ { "field": "device", "op": "eq", "value": "desktop" }, { "field": "utm_source", "op": "neq", "value": "direct" } ] } } ``` For all-user retention with no event filters, explicitly set both event filters to `null`: ```json theme={null} { "projectId": "proj_abc", "chart_type": "retention", "title": "Overall Retention", "settings": { "entryFilter": null, "retentionFilter": null } } ``` *** ## Validation Reference | Chart Type | Validation Rule | | ------------ | ---------------------------------------------------------------------------------------- | | `table` | `query` must execute successfully | | `number` | `query` must return exactly 1 row × 1 column | | `bar` | `x_axis` required; `y_axis` requires ≥ 1 element | | `line` | `x_axis` required; `y_axis` requires ≥ 1 element | | `pie` | `y_axis` requires exactly 1 element | | `stacked` | `x_axis` required; `y_axis` requires exactly 1 element; `group_by` required | | `funnel` | `steps` requires ≥ 2 `FunnelStep` objects; `query` must be `"SELECT 1"` or any valid SQL | | `user_paths` | `settings.anchors` requires at least one entry; the query is generated from the anchors | | `retention` | `settings.entryFilter` key is required; omit `query` | *** ## Response **`201 Created`** - returns the full chart object. ```json theme={null} { "id": "chart_1a2b3c4d", "chart_type": "table", "title": "Recent Events", "description": null, "query": "SELECT * FROM events ORDER BY timestamp DESC LIMIT 10", "project_id": "proj_abc", "board_id": "board_xyz" } ``` Use the returned `id` with the [Get Chart](/api/boards/get-chart), [Update Chart](/api/boards/update-chart), and [Delete Chart](/api/boards/delete-chart) endpoints. **`400 Bad Request`** - validation failed (missing required fields, invalid SQL, wrong step count, etc.). Branch on `error.code`; see [Errors](/api/errors). ```json theme={null} { "error": { "code": "BAD_REQUEST", "message": "Funnel chart must have at least 2 steps", "doc_url": "https://docs.formo.so/api/errors#bad_request" } } ``` ## Overview Creates a new chart and attaches it to a board. Returns the full chart object on success. The `chart_type` field controls which other request body fields are required or validated. Funnel charts derive SQL from `steps` and accept `"SELECT 1"` as the placeholder. User Paths charts derive SQL from `settings.anchors`, so omit `query`. *** ## Request Fields | Field | Type | Required | Description | | ------------- | ------------- | ----------- | -------------------------------------------------------------------------------------------------------- | | `projectId` | string | Yes | Project the chart belongs to | | `query` | string | Conditional | SQL query. Omit for `user_paths` and `retention`. For `funnel`, pass `"SELECT 1"` | | `chart_type` | string | Yes | `table` · `number` · `bar` · `line` · `area` · `pie` · `stacked` · `funnel` · `user_paths` · `retention` | | `title` | string | Yes | Display name (minimum 1 character) | | `description` | string | No | Optional description | | `x_axis` | string | Conditional | Column for the X axis. Required for `bar`, `line`, `area`, `stacked` | | `y_axis` | string\[] | Conditional | Column(s) for Y axis metrics. See per-type rules below | | `group_by` | string | Conditional | Column to group/stack series by. Required for `stacked` | | `steps` | FunnelStep\[] | Conditional | Ordered funnel steps. Required for `funnel` (minimum 2) | | `settings` | ChartSettings | No | Type-specific configuration object | *** ## Chart Types ### `table` Renders query results in a paginated table. No axis fields needed. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT * FROM events ORDER BY timestamp DESC LIMIT 10", "chart_type": "table", "title": "Recent Events" } ``` *** ### `number` Renders a single scalar value (KPI card). The query **must** return exactly **1 row × 1 column**. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT COUNT(DISTINCT address) FROM events WHERE type = 'connect'", "chart_type": "number", "title": "Total Connected Wallets" } ``` *** ### `bar`, `line`, and `area` All three require `x_axis` and at least **1** column in `y_axis`. `area` requires exactly **1** column in `y_axis` when `group_by` is set. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date DESC LIMIT 30", "chart_type": "line", "title": "Daily Active Users", "x_axis": "date", "y_axis": ["users"] } ``` *** ### `pie` Requires exactly **1** column in `y_axis`. `x_axis` identifies the label column. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT device, COUNT(*) AS session_count FROM sessions GROUP BY device ORDER BY session_count DESC LIMIT 10", "chart_type": "pie", "title": "Sessions by Device", "x_axis": "device", "y_axis": ["session_count"] } ``` *** ### `stacked` Requires `x_axis`, exactly **1** column in `y_axis`, and `group_by`. ```json theme={null} { "projectId": "proj_abc", "query": "SELECT device, browser, COUNT(*) AS session_count FROM sessions GROUP BY device, browser ORDER BY session_count DESC", "chart_type": "stacked", "title": "Sessions by Device and Browser", "x_axis": "device", "y_axis": ["session_count"], "group_by": "browser" } ``` *** ## Funnel Charts Funnel charts measure step-by-step user conversion. The SQL is auto-generated from `steps`, so `query` must be the placeholder `"SELECT 1"`. At least **2 steps** are required. ### The `FunnelStep` Object Each step in the `steps` array has the following shape: ```json theme={null} { "type": "event | track | decoded_log", "event": "", "filters": [{ "field": "", "op": "", "value": "" }] } ``` | Field | Type | Required | Description | | --------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | string | Yes | `event` - built-in events (page, connect, transaction); `track` - custom tracked events; `decoded_log` - decoded smart-contract events | | `event` | string | Yes | Event name (e.g. `page`, `connect`, `transaction`) | | `filters` | `StepFilterCondition[]` | No | Canonical `{field, op, value}` property filters. Standard columns are filtered directly; other fields are extracted from event `properties`. | ### Filter Operators (`StepFilterCondition`) ```json theme={null} { "field": "", "op": "", "value": "" } ``` | `op` | SQL equivalent | Notes | | ------------ | ---------------------- | ----------------------------------------------------- | | `eq` | `= 'value'` | Exact match | | `neq` | `!= 'value'` | Inverse match | | `in` | `IN (...)` | Pipe-delimited value: `"metamask\|rainbow\|coinbase"` | | `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 | | `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 `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 retired and rejected with a `400` naming the token. Send `gt` / `gte` / `lt` / `lte`. `notEmpty` and `isEmpty` are rejected on the numeric event columns `volume` / `revenue` / `points`. ### `settings` for Funnel Charts | Field | Type | Default | Description | | ------------------ | ---------------------- | ---------- | -------------------------------------------------------------------------------------------------------------- | | `funnelType` | `"closed"` \| `"open"` | `"closed"` | `closed` - strict ordering, no events between steps; `open` - ordered but other events may occur between steps | | `conversionWindow` | `ConversionWindow` | 2 days | Max time from Step 1 for a user to complete all steps | | `breakdown` | string | - | Split each funnel bar by this dimension | #### `ConversionWindow` ```json theme={null} { "value": 7, "unit": "day" } ``` `unit` must be one of: `hour` · `day` · `week` (7 days). #### `breakdown` values `device` · `browser` · `os` · `location` · `referrer` · `ref` · `utm_source` · `utm_medium` · `utm_campaign` · `utm_term` · `utm_content` · `builder_codes` The top categories are shown individually. The rest collapse into **"Others"**. *** ### Funnel Examples #### Basic 3-step closed funnel ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "Onboarding Funnel", "steps": [ { "type": "event", "event": "page" }, { "type": "event", "event": "connect" }, { "type": "event", "event": "transaction" } ], "settings": { "funnelType": "closed", "conversionWindow": { "value": 7, "unit": "day" } } } ``` #### With per-step property filters Filter step 2 to MetaMask wallet connections on Ethereum mainnet: ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "MetaMask Conversion Funnel", "steps": [ { "type": "event", "event": "page" }, { "type": "event", "event": "connect", "filters": [ { "field": "rdns", "op": "eq", "value": "io.metamask" }, { "field": "chain_id", "op": "eq", "value": "1" } ] }, { "type": "event", "event": "transaction" } ], "settings": { "funnelType": "closed", "conversionWindow": { "value": 7, "unit": "day" } } } ``` #### With breakdown by device ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "Onboarding Funnel by Device", "steps": [ { "type": "event", "event": "page" }, { "type": "event", "event": "connect" }, { "type": "event", "event": "signature" } ], "settings": { "funnelType": "closed", "conversionWindow": { "value": 30, "unit": "day" }, "breakdown": "device" } } ``` #### Open funnel with multi-value `in` filter ```json theme={null} { "projectId": "proj_abc", "query": "SELECT 1", "chart_type": "funnel", "title": "Mobile Onboarding Funnel", "steps": [ { "type": "event", "event": "page", "filters": [{ "field": "device", "op": "eq", "value": "mobile" }] }, { "type": "event", "event": "connect", "filters": [ { "field": "provider_name", "op": "in", "value": ["metamask", "rainbow", "coinbase"] } ] }, { "type": "event", "event": "transaction" } ], "settings": { "funnelType": "open", "conversionWindow": { "value": 30, "unit": "day" }, "breakdown": "device" } } ``` *** ## User Paths Charts Visualize how users navigate your app through an ordered list of path anchors. `settings.anchors` requires at least one entry. One anchor produces an open-ended exploration; two or more constrain the path in order. ### `settings` for User Paths Charts | Field | Type | Default | Description | | ------------------ | ------------------ | ------- | ---------------------------------------------------------------- | | `anchors` | `FunnelStep[]` | - | **Required.** Ordered path anchors; one entry is open-ended | | `maxSteps` | integer (2 to 5) | `3` | Max steps to show in the flow. Values above 5 are clamped to 5 | | `nodesPerStep` | integer (2 to 8) | `5` | Max unique event nodes per step. Values above 8 are clamped to 8 | | `conversionWindow` | `ConversionWindow` | 2 weeks | Time window to group the flow | | `filters` | string | - | JSON-encoded string of additional path filters | ```json theme={null} { "projectId": "proj_abc", "chart_type": "user_paths", "title": "Post-Connect User Flow", "settings": { "anchors": [ { "type": "event", "event": "connect" }, { "type": "event", "event": "transaction" } ], "maxSteps": 5, "conversionWindow": { "value": 2, "unit": "week" } } } ``` *** ## Retention Charts Measures how often users return over time. Omit the `query` field; retention data is fetched automatically. ### `settings` for Retention Charts | Field | Type | Description | | ---------------------- | ----------------------- | ---------------------------------------------------------------------- | | `entryFilter` | `FunnelStep` \| `null` | **Required key.** Event that enters the cohort; `null` means any event | | `retentionFilter` | `FunnelStep` \| `null` | Event that qualifies a return as "retained"; `null` means any event | | `retentionUserFilters` | `RetentionUserFilter[]` | User-segment filters that narrow the retention cohort | 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`, minus the substring operators `startsWith` / `endsWith` / `contains`) | | `value` | string \| number | Value to compare against | ```json theme={null} { "projectId": "proj_abc", "chart_type": "retention", "title": "Weekly Retention - Desktop Users", "settings": { "entryFilter": { "type": "event", "event": "transaction" }, "retentionFilter": { "type": "event", "event": "transaction" }, "retentionUserFilters": [ { "field": "device", "op": "eq", "value": "desktop" }, { "field": "utm_source", "op": "neq", "value": "direct" } ] } } ``` For all-user retention with no event filters, explicitly set both event filters to `null`: ```json theme={null} { "projectId": "proj_abc", "chart_type": "retention", "title": "Overall Retention", "settings": { "entryFilter": null, "retentionFilter": null } } ``` *** ## Validation Reference | Chart Type | Validation Rule | | ------------ | ---------------------------------------------------------------------------------------- | | `table` | `query` must execute successfully | | `number` | `query` must return exactly 1 row × 1 column | | `bar` | `x_axis` required; `y_axis` requires ≥ 1 element | | `line` | `x_axis` required; `y_axis` requires ≥ 1 element | | `pie` | `y_axis` requires exactly 1 element | | `stacked` | `x_axis` required; `y_axis` requires exactly 1 element; `group_by` required | | `funnel` | `steps` requires ≥ 2 `FunnelStep` objects; `query` must be `"SELECT 1"` or any valid SQL | | `user_paths` | `settings.anchors` requires at least one entry; the query is generated from the anchors | | `retention` | `settings.entryFilter` key is required; omit `query` | *** ## Response **`201 Created`** - returns the full chart object. ```json theme={null} { "id": "chart_1a2b3c4d", "chart_type": "table", "title": "Recent Events", "description": null, "query": "SELECT * FROM events ORDER BY timestamp DESC LIMIT 10", "project_id": "proj_abc", "board_id": "board_xyz" } ``` Use the returned `id` with the [Get Chart](/api/boards/get-chart), [Update Chart](/api/boards/update-chart), and [Delete Chart](/api/boards/delete-chart) endpoints. **`400 Bad Request`** - validation failed (missing required fields, invalid SQL, wrong step count, etc.). Branch on `error.code`; see [Errors](/api/errors). ```json theme={null} { "error": { "code": "BAD_REQUEST", "message": "Funnel chart must have at least 2 steps", "doc_url": "https://docs.formo.so/api/errors#bad_request" } } ``` # Delete Board Source: https://docs.formo.so/api/boards/delete DELETE /v0/boards/{boardId} Permanently remove a dashboard board and all associated charts by board ID. This action cannot be undone once the board is deleted. # Delete Chart Source: https://docs.formo.so/api/boards/delete-chart DELETE /v0/boards/{boardId}/charts/{chartId} Permanently delete a chart from a dashboard board by board and chart ID. The chart and its visualization settings are removed from the layout. # Duplicate Chart Source: https://docs.formo.so/api/boards/duplicate-chart POST /v0/boards/{boardId}/charts/{chartId}/duplicate Create a copy of a chart on the same board. Returns the ID of the newly created chart. # Get Board Source: https://docs.formo.so/api/boards/get GET /v0/boards/{boardId} Retrieve a single dashboard board by ID. Returns the board title, description, and associated chart metadata. # Get Chart Source: https://docs.formo.so/api/boards/get-chart GET /v0/boards/{boardId}/charts/{chartId} Retrieve a single chart by board and chart ID. Returns the chart type, SQL query, visualization settings, and display configuration. # List Boards Source: https://docs.formo.so/api/boards/list GET /v0/boards Retrieve all dashboard boards for your project. Returns board IDs, titles, and descriptions for each analytics dashboard. # List Charts Source: https://docs.formo.so/api/boards/list-charts GET /v0/boards/{boardId}/charts Retrieve the charts attached to a dashboard board. Returns lightweight chart summaries by default; pass include=results to execute each chart query and receive full charts with results. # Move Chart Source: https://docs.formo.so/api/boards/move-chart PUT /v0/boards/{boardId}/charts/{chartId}/move Move a chart to a different board in the same project. The target board must differ from the current one. # Execute Saved Chart Query Source: https://docs.formo.so/api/boards/query-chart GET /v0/boards/{boardId}/charts/{chartId}/query Run a saved chart's query with a date range substituted for its {{date_from}} and {{date_to}} template variables. Dune-backed charts run against the project's configured Dune API key. # Reorder Charts Source: https://docs.formo.so/api/boards/reorder-charts PUT /v0/boards/{boardId}/charts/reorder Set the display order of a board's charts. Charts omitted from the list keep their relative order after the listed ones. # Update Board Source: https://docs.formo.so/api/boards/update PATCH /v0/boards/{boardId} Update a dashboard board by ID. Modify the board title or description for your analytics dashboards via the Formo API. # Update Chart Source: https://docs.formo.so/api/boards/update-chart PUT /v0/boards/{boardId}/charts/{chartId} Update an existing chart on a dashboard board. Modify the chart query, type, title, or visualization settings through the Formo API. # Add Contract Source: https://docs.formo.so/api/contracts/create POST /v0/contracts Register a new smart contract for event tracking by providing a chain ID, contract address, and an ABI for automatic event decoding. # Delete Contract Source: https://docs.formo.so/api/contracts/delete DELETE /v0/contracts/{chain}/{address} Remove a tracked smart contract by chain and address. Stops event ingestion for the contract and removes it from your project configuration. # Get Contract Source: https://docs.formo.so/api/contracts/get GET /v0/contracts/{chain}/{address} Fetch a single monitored smart contract by chain ID and address. Returns the contract address, chain ID, ABI, and event tracking configuration. # List Contracts Source: https://docs.formo.so/api/contracts/list GET /v0/contracts Retrieve all tracked smart contracts for your project. Returns contract addresses, chain IDs, ABIs, and event tracking configurations. # Update Contract Source: https://docs.formo.so/api/contracts/update PUT /v0/contracts/{chain}/{address} Update a tracked smart contract by chain and address. Modify the contract ABI, display name, or event tracking configuration through the Formo API. # Errors Source: https://docs.formo.so/api/errors Stable error codes returned by the Formo Public API. Branch on error.code for reliable client behavior; doc_url in every response anchors back to this page. Every non-2xx response from the Formo Public API uses the same envelope: ```json theme={null} { "error": { "code": "BAD_REQUEST", "message": "Trigger filters must contain at least one entry", "doc_url": "https://docs.formo.so/api/errors#bad_request", "param": "body.trigger_filters", "details": { "...": "..." } } } ``` | Field | Type | Notes | | --------- | ------- | ------------------------------------------------------------------------------------------------------------------- | | `code` | string | Stable, machine-readable identifier. Branch on this. | | `message` | string | Human-readable description. Wording may change between releases - never branch on `message`. | | `doc_url` | string | Anchored link into this page. Agents can fetch it for context. | | `param` | string? | Dotted path to the offending field, when applicable (`body.trigger_filters.0.value`). | | `details` | object? | Code-specific extras. For `INVALID_VALIDATION_REQUEST` this is a `{ fieldPath: message }` map of every Zod failure. | The HTTP status code carries success/failure; **success bodies are never wrapped** - `GET /v0/alerts/alrt_…` returns the alert directly. The events-gateway service (`events.formo.so`, used by [Track Event](/api/events/track) and `/v0/raw_events`) is a separate service and does not use this envelope. It returns a simpler plain-string error shape, for example `{"error": "Unauthorized"}`, with no `code`, `message`, or `doc_url`. ## Handling errors well 1. **Branch on `code`, not `message` or status alone.** Status families collide (most validation issues are `400 BAD_REQUEST`); the `code` enum disambiguates. 2. **Follow `doc_url` for context.** AI agents can fetch the matching section of this page to learn how to fix the request without escalating to a human. 3. **Retry only the safe codes.** `429 TOO_MANY_REQUESTS`, `503 SERVICE_UNAVAILABLE`, and `5xx INTERNAL_SERVER_ERROR` are retry-safe with exponential backoff. 4xx codes other than `429` indicate a client bug; retrying without changes will fail the same way. 4. **Pair retries with `Idempotency-Key`** on POST/PUT/PATCH/DELETE so the server can de-dupe duplicates. See [Idempotency](/api/idempotency). ## Reference ### `bad_request` **HTTP:** `400` The request was syntactically valid but semantically wrong - a constraint that Zod can't express was violated (e.g. trying to delete a board that has charts, exceeding the per-project board limit). `message` describes the specific rule that failed; do not retry without changes. ### `invalid_validation_request` **HTTP:** `400` A request field failed schema validation. `details` is a `{ fieldPath: message }` map of every failure across `body`, `query`, and `params`. ```json theme={null} { "error": { "code": "INVALID_VALIDATION_REQUEST", "message": "Invalid request data", "doc_url": "https://docs.formo.so/api/errors#invalid_validation_request", "details": { "body.name": "String must contain at least 1 character(s)", "body.trigger_type": "Invalid enum value. Expected 'event' | 'user'" } } } ``` ### `unauthorized` **HTTP:** `401` The API key is missing, malformed, or revoked. Check that the request includes `Authorization: Bearer formo_…` and that the key still exists in **Team Settings → API**. ### `forbidden` **HTTP:** `403` The API key is valid but lacks the required scope for this endpoint. The `message` names the missing scope (e.g. `API key missing required scope: alerts:write`). Issue a new key with the right scopes - scopes can't be added to an existing key. ### `not_found` **HTTP:** `404` The resource does not exist or is not visible to this API key's workspace. Note that workspace isolation makes "exists in another workspace" indistinguishable from "doesn't exist" - both return `404`. ### `conflict` **HTTP:** `409` The request conflicts with current resource state (e.g. creating a resource whose unique key already exists). ### `idempotency_in_progress` **HTTP:** `409` Another request with the same `Idempotency-Key` is currently in flight from this workspace. Wait for it to complete (typically under 1 second) and replay your request - the server will return the cached response. ### `invalid_idempotency_key` **HTTP:** `400` The `Idempotency-Key` header exceeded 255 characters, **or** the key was reused for a request whose method, path, or body differs from the original. Generate a fresh UUID v4 per logical operation. See [Idempotency](/api/idempotency). ### `invalid_chain_id` **HTTP:** `400` The `chain` parameter is not a supported EVM chain ID. See [Supported Chains](/chains/overview) for the full list. ### `too_many_requests` **HTTP:** `429` The per-workspace rate limit has been exceeded. Inspect the `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` response headers and back off until `RateLimit-Reset`. Use exponential backoff with jitter for retries. ### `context_limit_exceeded` **HTTP:** `400` An AI request (chat / Ask AI) carried more tokens than the model can accept. Start a fresh conversation or trim the prompt. ### `service_unavailable` **HTTP:** `503` A downstream service the endpoint depends on is offline or degraded. Retry with exponential backoff; the request itself is fine. ### `internal_server_error` **HTTP:** `5xx` An unexpected error inside Formo. The error has been captured in our monitoring; retry with exponential backoff. If it persists, include the response timestamp when contacting support so we can correlate it to the captured exception. # Track Event Source: https://docs.formo.so/api/events/track POST /v0/raw_events Send individual or batched events to Formo from any source via the Events API at events.formo.so. Supports custom events and all standard event types. Send events to Formo from any source. Sending custom events? See the [event specs](/data/events/track). This endpoint is hosted on `events.formo.so`, a separate service from the main API at `api.formo.so`. Sending events from the backend? Use the [server-side SDK](https://docs.formo.so/sdks/server). # Idempotency Source: https://docs.formo.so/api/idempotency Use the Idempotency-Key header to safely retry POST/PUT/PATCH/DELETE requests on the Formo Public API without double-creating or double-charging. Public unsafe methods (POST / PUT / PATCH / DELETE) on the Alerts, Charts, Contracts, Segments, and Import APIs honour an optional `Idempotency-Key` header. When present, Formo de-duplicates retries so a flaky network or a crashed client never causes a double-write. The header is **opt-in** - omit it and every request is processed independently. ## How it works ```http theme={null} POST /v0/alerts HTTP/1.1 Host: api.formo.so Authorization: Bearer formo_… Idempotency-Key: 8a3a2b1e-7c4d-4a5e-9c2f-1f8a3a2b1e9f Content-Type: application/json { "name": "Daily revenue drop", "trigger_type": "event", "...": "..." } ``` * The first request runs normally; the response (status code + body) is cached for **24 hours** under `idem:{teamId}:{projectId}:{key}` along with a fingerprint of the request (method + full mount path + body). * Subsequent requests with the same key **and** matching fingerprint replay the cached response **byte-for-byte** - retries can never double-create or double-charge. * The cache is scoped to `projectId` as well as `teamId`, so two project-scoped API keys in the same workspace can use the same key without colliding. ### What gets cached | Outcome | Cached? | Why | | ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `2xx` success | ✅ | Replay the original result - that's the whole point. | | `5xx` server error | ✅ | If the server failed transiently after partially executing, a retry should see the same failure rather than risk double-execution. | | `4xx` client error | ❌ | Validation failures aren't recorded. A corrected retry under the same key runs normally. | The in-flight lock is also released on `4xx` so a corrected retry under the same key can proceed immediately. ## Failure modes ### `400 INVALID_IDEMPOTENCY_KEY` - key reused for a different request Reusing the same key for a *different* operation (different method, path, or body) returns `400 INVALID_IDEMPOTENCY_KEY`. This prevents silent dropped writes when a client accidentally reuses a key across distinct requests. Also returned if the key exceeds 255 characters. ### `409 IDEMPOTENCY_IN_PROGRESS` - concurrent retry Two concurrent requests with the same key in flight return `409 IDEMPOTENCY_IN_PROGRESS`. Wait briefly (typically under 1 second) and replay - the server will return the cached response of the original request. ### Cache outage If the idempotency cache is unavailable, the request runs without caching - a cache outage doesn't block writes. Concurrent retries during such an outage may double-execute; the contract is best-effort. ## Recommended client patterns 1. **Generate a fresh UUID v4 per logical operation.** Don't reuse keys across different actions, even if the body looks similar. 2. **Persist the key before the first attempt.** Save it to disk / DB before calling the API so an in-flight crash doesn't lose it. On restart, retry with the same key. 3. **Scope to a single workspace.** Two API keys for *different* projects can safely share an idempotency key - but don't rely on that; scope per workspace anyway. 4. **Pair with retry-with-backoff for `429` / `503` / `5xx`.** Idempotency makes those safe to replay. 5. **Maximum length: 255 characters.** Use a UUID v4 (36 chars) or any random ≤255-char string. ## Examples ```bash curl theme={null} curl -X POST https://api.formo.so/v0/alerts \ -H "Authorization: Bearer formo_…" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Daily revenue drop", "trigger_type": "event", "trigger_filters": [ { "field": "event", "op": "eq", "value": "transaction" } ], "recipient": [{ "type": "webhook", "value": ["https://myapp.com/formo-webhook"] }] }' ``` ```javascript JavaScript theme={null} import { randomUUID } from "crypto"; const idempotencyKey = randomUUID(); async function createAlertWithRetry(body, attempt = 0) { const res = await fetch("https://api.formo.so/v0/alerts", { method: "POST", headers: { Authorization: `Bearer ${process.env.FORMO_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (res.status === 409) { const { error } = await res.json(); if (error.code === "IDEMPOTENCY_IN_PROGRESS" && attempt < 5) { await new Promise((r) => setTimeout(r, 250 * 2 ** attempt)); return createAlertWithRetry(body, attempt + 1); } } if (res.status >= 500 && attempt < 5) { await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); return createAlertWithRetry(body, attempt + 1); } return res.json(); } ``` ```python Python theme={null} import os import time import uuid import requests idempotency_key = str(uuid.uuid4()) def create_alert_with_retry(body, attempt=0): res = requests.post( "https://api.formo.so/v0/alerts", headers={ "Authorization": f"Bearer {os.environ['FORMO_API_KEY']}", "Idempotency-Key": idempotency_key, "Content-Type": "application/json", }, json=body, ) if res.status_code == 409: err = res.json().get("error", {}) if err.get("code") == "IDEMPOTENCY_IN_PROGRESS" and attempt < 5: time.sleep(0.25 * (2 ** attempt)) return create_alert_with_retry(body, attempt + 1) if res.status_code >= 500 and attempt < 5: time.sleep(0.5 * (2 ** attempt)) return create_alert_with_retry(body, attempt + 1) return res.json() ``` See [Errors](/api/errors) for the full list of error codes and remediation guidance. # API overview Source: https://docs.formo.so/api/overview Explore the Formo API suite including the Events API for data ingestion, Query API for SQL analytics, and Profiles API for wallet data and personalization. Formo ## Events API * Send individual or batches of events via `events.formo.so` to Formo. * High-throughput streaming ingestion with an easy-to-use HTTP API. * Supports 1000 requests/s and 20MB/s. ## Profiles API You can use the Profiles API for real-time personalization. Once you fetch a profile, you can use them in your app for real-time personalization. * Fetch wallet profile properties including net worth and wallet labels * Segment and target users based on location, device, and referrer * Real-time activation API for personalized user experiences * Available via [x402](https://www.x402.org/) (`formo.x402.paysponge.com`) and [MPP](https://mpp.dev/) (`formo.mpp.paysponge.com`): pay per request, no API key required ## Query API * Connect BI tools and SQL clients via [BI integration](/data/bi). * Query and filter raw analytics events, materialized views, and metrics. * Return the activity feed / event stream for an anonymous visitor or wallet. * Export data periodically to your data warehouse. ## Charts API * Create and manage custom dashboards and charts. * Add, update, and remove charts with SQL-based queries and visualization options (bar, line, pie, funnel, stacked, retention, and more). ## Segments API * Create and manage user segments based on filter conditions. * Segment users by DeFi positions, net worth, transaction count, device, browser, lifetime volume, and other attributes. ## Contracts API * Monitor blockchain smart contract events across all major chains. * Add contracts by chain ID, address, and ABI to get fully-decoded transaction and smart contract events. ## Alerts API * Create, update, and manage project alerts programmatically. * Configure alert conditions and a webhook notification channel (including Slack, via a Slack incoming-webhook URL). ## Response shape Successful responses return the **resource directly** - there is no envelope, no `isSuccess` discriminator. HTTP status alone (`200/201/204` vs `4xx/5xx`) carries success. For example, creating an alert returns the alert itself: ```json theme={null} { "id": "alrt_…", "name": "Daily revenue drop", "trigger_type": "event", "status": "active", "created_at": "2026-04-12T09:32:18.000Z", "updated_at": "2026-04-25T14:01:55.000Z" } ``` `DELETE` returns `204 No Content` with an empty body - don't try to parse JSON on those. ### Pagination List endpoints use a Stripe-style envelope only because they need pagination metadata: ```json theme={null} { "data": [ { "id": "a" }, { "id": "b" } ], "page": 1, "size": 50, "total": 173, "has_more": true } ``` | Field | Meaning | | ---------- | ----------------------------------------------- | | `data` | The page of rows | | `total` | Total count across all pages | | `page` | 1-indexed page number echoed from the request | | `size` | Page size echoed from the request | | `has_more` | `true` if there are pages after the current one | ## Idempotency Pass an `Idempotency-Key` header on POST/PUT/PATCH/DELETE requests to the Alerts, Charts, Contracts, Segments, and Import APIs so retries can't double-create or double-charge. The first response is cached for 24 hours and replayed byte-for-byte on retry. ```http theme={null} POST /v0/alerts HTTP/1.1 Authorization: Bearer formo_… Idempotency-Key: 8a3a2b1e-7c4d-4a5e-9c2f-1f8a3a2b1e9f ``` The header is opt-in. See [Idempotency](/api/idempotency) for the full contract, failure modes, and client patterns. ## Errors Every non-2xx response uses the same envelope: ```json theme={null} { "error": { "code": "BAD_REQUEST", "message": "Trigger filters must contain at least one entry", "doc_url": "https://docs.formo.so/api/errors#bad_request", "param": "body.trigger_filters" } } ``` Branch on `error.code` (a stable enum), not `message` or HTTP status alone. The `doc_url` field anchors to the matching section of the [Errors reference](/api/errors), so AI agents can fetch remediation context without escalating to a human. See [Errors](/api/errors) for the full list of codes. ## Versioning The public API is mounted under `/v0/`. We treat the path version as a major version; we will add `/v1/` (rather than break `/v0/`) for incompatible changes. While under `/v0/` the contract is settling - breaking changes can land before `/v1/` ships, and we will announce them in the changelog. Within a version we may add new endpoints, new optional request fields, new response fields (clients should ignore unknown fields), and new error codes within an existing HTTP status family. We will not remove or rename existing endpoints/fields, change the HTTP status of an existing error condition, or repurpose an existing `code` value. ## FAQ | Layer | Limit | Window | | --------------------- | --------------- | ---------- | | API (except Profiles) | 1,000 requests | 15 minutes | | Profiles API | 10,000 requests | 15 minutes | | Workspace | 100 requests | 1 second | # Add Labels Source: https://docs.formo.so/api/profiles/add-labels POST /v0/profiles/{address}/labels Add or update one or more labels on a wallet profile. Labels tag wallets with custom attributes like VIP tier, airdrop eligibility, or verification status. ## Historical (backfilled) labels Each label accepts an optional `timestamp` (ISO-8601). When set, the label is recorded at that time instead of the server's current time, so you can backfill historical values (for example, an `open_interest` reading from a past week). Label-based retention then evaluates each value at the correct point in time. ```bash theme={null} curl -sS -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "tag_id": "open_interest", "value": "1200", "timestamp": "2026-01-05T00:00:00Z" }' \ "https://api.formo.so/v0/profiles/
/labels" ``` `timestamp` must not be in the future; future timestamps are rejected with `400`. When omitted, the label is recorded at server time. The response echoes the caller-supplied `timestamp` when present, otherwise the server write time. ## Backdated removals (historical tombstones) To record that a label was *removed* at a past point in time, set the optional `_is_deleted` flag to `1` together with a past `timestamp`. This writes a tombstone: a soft-delete row that marks the label as removed at time `T`. Point-in-time retention then drops the wallet from that historical week, rather than only from now. `_is_deleted` is an optional integer, either `0` or `1`, and defaults to `0` (a live label) when omitted. A tombstone's `value` is irrelevant and can be left out. ```bash theme={null} curl -sS -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "tag_id": "open_interest", "_is_deleted": 1, "timestamp": "2026-03-15T00:00:00Z" }' \ "https://api.formo.so/v0/profiles/
/labels" ``` A common pattern is to import a label's full history as a value time series ending in a removal: ```bash theme={null} curl -sS -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "tag_id": "open_interest", "value": "high", "timestamp": "2026-01-15T00:00:00Z" }, { "tag_id": "open_interest", "value": "low", "timestamp": "2026-02-15T00:00:00Z" }, { "tag_id": "open_interest", "_is_deleted": 1, "timestamp": "2026-03-15T00:00:00Z" } ]' \ "https://api.formo.so/v0/profiles/
/labels" ``` A tombstone is just a backdated write, so the same rules apply: a future `timestamp` is rejected with [`400 BAD_REQUEST`](/api/errors#bad_request). Order of ingest does not matter, since reads resolve to the value with the latest `timestamp`: a backdated tombstone never clobbers a newer live value, and a backfilled value never resurrects a label after a later removal. For a **chain-scoped** label, the tombstone must carry the same `chain_id` as the label it removes; `chain_id` is part of the label's identity. The storage-only `_is_deleted` flag is never echoed back in the [`UserLabel`](/api/profiles/get) response. To remove a label at the *current* server time, use [Delete Label](/api/profiles/delete-label) instead. # Batch Add Labels Source: https://docs.formo.so/api/profiles/batch-add-labels POST /v0/profiles/labels Add or update up to 100 labels across many wallets in a single request. Each item carries its own address; rows with an invalid address are quarantined and reported rather than failing the whole batch. ## Historical (backfilled) labels Each item extends the single-wallet label schema, so it accepts the same optional `timestamp` (ISO-8601). When set, that row is recorded at the given time instead of the server's current time, letting you backfill historical values per wallet in one batch. Label-based retention then evaluates each value at the correct point in time. ```bash theme={null} curl -sS -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "tag_id": "open_interest", "value": "1200", "timestamp": "2026-01-05T00:00:00Z" }, { "address": "EPjFWaYbrgqCC2Qbg4EV4FjUreEMKwMn1zNbiboXXKV", "tag_id": "open_interest", "value": "800", "timestamp": "2026-01-12T00:00:00Z" } ]' \ "https://api.formo.so/v0/profiles/labels" ``` `timestamp` must not be in the future. Unlike an invalid address (which is quarantined for that row only), a future timestamp fails request validation and rejects the whole batch with `400`. When omitted, the row is recorded at server time. ## Backdated removals (historical tombstones) Set the optional `_is_deleted` flag to `1` on a row to backfill a *removal* at that row's `timestamp` instead of writing a live value. This records a tombstone marking the label as removed at time `T`, so point-in-time retention drops the wallet from that historical week. `_is_deleted` is an optional integer, either `0` or `1`, and defaults to `0` (a live label). This is the same per-row flag as on the single-wallet endpoint, so a batch can carry a full value-then-removal time series across many wallets in one request: ```bash theme={null} curl -sS -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "tag_id": "open_interest", "value": "high", "timestamp": "2026-01-15T00:00:00Z" }, { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "tag_id": "open_interest", "value": "low", "timestamp": "2026-02-15T00:00:00Z" }, { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "tag_id": "open_interest", "_is_deleted": 1, "timestamp": "2026-03-15T00:00:00Z" } ]' \ "https://api.formo.so/v0/profiles/labels" ``` A tombstone follows the same validation as any backfilled row: a future `timestamp` rejects the whole batch with [`400 BAD_REQUEST`](/api/errors#bad_request). Order of ingest does not matter, since reads resolve to the value with the latest `timestamp`. For a **chain-scoped** label, the tombstone must carry the same `chain_id` as the label it removes; a tombstone's `value` is irrelevant. The storage-only `_is_deleted` flag is never echoed back in the response. # Batch Update Properties Source: https://docs.formo.so/api/profiles/batch-update-properties POST /v0/profiles/properties Set or unset first-party profile properties for up to 100 wallets in a single request. Each item is a flat object with a required address; null values delete properties, unknown keys are ignored, and invalid rows are quarantined rather than failing the whole batch. ## Deleting properties in a batch A `null` value deletes (unsets) that property, exactly as on the [single-wallet endpoint](/api/profiles/update-properties#deleting-properties). Set and unset can mix freely within and across rows: ```bash theme={null} curl -sS -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "display_name": "alice.eth", "email": null }, { "address": "EPjFWaYbrgqCC2Qbg4EV4FjUreEMKwMn1zNbiboXXKV", "twitter": null } ]' \ "https://api.formo.so/v0/profiles/properties" ``` `user_id` cannot be unset (it participates in identity stitching); a row with `"user_id": null` fails request validation with [`400 BAD_REQUEST`](/api/errors#bad_request). The literal string `_is_deleted` is a reserved internal value and is rejected wherever a property value is accepted. # Delete Label Source: https://docs.formo.so/api/profiles/delete-label DELETE /v0/profiles/{address}/labels Delete a label from a wallet profile by tag_id, optionally scoped to a specific chain. This endpoint tombstones the label at the current server time. To record a removal at a *past* point in time (for example, when importing a label's history), use [Add Labels](/api/profiles/add-labels) with `_is_deleted: 1` and a past `timestamp` instead. # Get Profile Source: https://docs.formo.so/api/profiles/get GET /v0/profiles/{address} Retrieve a comprehensive wallet profile by address, including onchain labels, token holdings, DeFi positions, web demographics, and lifecycle data. Retrieves wallet profile information for a given address. Returns comprehensive profile data including wallet properties, labels, tokens, and apps. If you have Formo installed on your website and app, you will also get the user's web demographics (country, device, browser, operating system), lifecycle, volume, and revenue. Profiles API architecture diagram showing user device, Profiles API, and Wallet Profiles interaction ## Authentication This endpoint requires a **Workspace API Key** with `profiles:read` permission. Include the API key in your request headers: ```bash theme={null} Authorization: Bearer ``` ```bash theme={null} curl -sS \ -H "Authorization: Bearer " \ "https://api.formo.so/v0/profiles/
" ``` Create a key with **profiles:read** scope in **Team Settings > API**. AI agents can access this endpoint through the **x402 gateway** with no Formo API key required. The agent pays per request using the [x402 protocol](https://www.x402.org/). **Gateway URL:** ``` https://formo.x402.paysponge.com/v0/profiles/{address} ``` **Price:** 0.05 USDC per request (Base network) The gateway issues a `402 Payment Required` challenge on the first request. A compliant x402 client handles the payment automatically. **Using the Sponge wallet SDK:** ```typescript theme={null} import { SpongeWallet } from '@paysponge/sdk'; const wallet = await SpongeWallet.connect({ apiKey: process.env.SPONGE_API_KEY }); const response = await wallet.x402Fetch({ url: 'https://formo.x402.paysponge.com/v0/profiles/
', method: 'GET', }); console.log(response.data); // wallet profile object ``` **Raw HTTP (after payment):** ```http theme={null} GET /v0/profiles/
HTTP/1.1 Host: formo.x402.paysponge.com PAYMENT-SIGNATURE: ``` The response shape is identical to the standard endpoint. The `expand` query parameter works the same way. AI agents can also access this endpoint through the **MPP gateway** using the [MPP protocol](https://mpp.dev/). **Gateway URL:** ``` https://formo.mpp.paysponge.com/v0/profiles/{address} ``` **Price:** 0.05 USDC.e per request (Tempo network) The gateway issues a `402 Payment Required` challenge. A compliant MPP client handles the payment authorization automatically. **Using the mppx SDK:** ```typescript theme={null} import { createTempoClient } from 'mppx'; const client = createTempoClient({ privateKey: process.env.TEMPO_PRIVATE_KEY, }); const response = await client.fetch( 'https://formo.mpp.paysponge.com/v0/profiles/
', ); const profile = await response.json(); ``` **Raw HTTP (after payment):** ```http theme={null} GET /v0/profiles/
HTTP/1.1 Host: formo.mpp.paysponge.com Authorization: Payment ``` The response shape is identical to the standard endpoint. The `expand` query parameter works the same way. ## Path parameters * `address` (string): A wallet address or ENS name. Accepts an EVM address (e.g. `0x0000000000000000000000000000000000000000`), a Solana address, or an ENS name (e.g. `vitalik.eth`). ENS names are resolved to an address before lookup. ## Query parameters ### `expand` Comma-separated list of optional sections to include in the response. Supported values: * `apps` - DeFi app interactions and balances * `chains` - Per-chain activity metrics (net worth, tx count, first/last activity per chain) * `tokens` - Token holdings with balances and prices * `labels` - Wallet labels from various sources Examples: ```bash theme={null} curl -sS \ -H "Authorization: Bearer " \ "https://api.formo.so/v0/profiles/0x0000000000000000000000000000000000000000?expand=apps,chains,tokens,labels" ``` The `apps`, `chains`, and `tokens` collections are capped at **50 items** each. ## Response Fields ### Core Profile Data | Field | Type | Description | | --------------- | -------------- | -------------------------------------------- | | `address` | string | The wallet address | | `net_worth_usd` | number | Total net worth in USD across all chains | | `tx_count` | integer | Total transaction count across all chains | | `first_onchain` | string \| null | First on-chain activity timestamp (ISO 8601) | | `last_onchain` | string \| null | Last on-chain activity timestamp (ISO 8601) | | `updated_at` | string \| null | Last profile update timestamp (ISO 8601) | ### Lifecycle | Field | Type | Description | | ----------- | -------------- | ----------------------------------------------- | | `lifecycle` | string \| null | User lifecycle stage based on activity patterns | Possible lifecycle values: * `New` - Recently acquired user * `Returning` - User who has returned after absence * `Power user` - Highly active user * `Resurrected` - User who returned after long absence * `At Risk` - Established user who is still active but going quiet * `Churned` - User who has stopped engaging ### Social & Identity | Field | Type | Description | | ----------- | -------------- | ------------------ | | `ens` | string \| null | ENS name | | `farcaster` | string \| null | Farcaster username | | `lens` | string \| null | Lens handle | | `basenames` | string \| null | Base names | | `linea` | string \| null | Linea identifier | | `discord` | string \| null | Discord username | | `telegram` | string \| null | Telegram username | | `twitter` | string \| null | Twitter/X handle | | `github` | string \| null | GitHub username | | `linkedin` | string \| null | LinkedIn profile | | `email` | string \| null | Email address | | `website` | string \| null | Website URL | | `instagram` | string \| null | Instagram handle | | `facebook` | string \| null | Facebook profile | | `tiktok` | string \| null | TikTok handle | | `youtube` | string \| null | YouTube channel | | `reddit` | string \| null | Reddit username | ### Profile Display | Field | Type | Description | | -------------- | -------------- | ------------------- | | `avatar` | string \| null | Avatar image URL | | `display_name` | string \| null | Display name | | `description` | string \| null | Profile description | ### User Engagement Data These fields are populated based on events tracked in your project: | Field | Type | Description | | ---------------- | --------------- | ------------------------------------------- | | `first_seen` | string \| null | First seen timestamp in your app (ISO 8601) | | `last_seen` | string \| null | Last seen timestamp in your app (ISO 8601) | | `num_sessions` | integer \| null | Total number of sessions | | `revenue` | number \| null | Total revenue | | `volume` | number \| null | Total volume | | `points` | number \| null | Total points | | `activity_dates` | array \| null | Array of activity dates (YYYY-MM-DD format) | ### Device & Location | Field | Type | Description | | ---------- | -------------- | ---------------------------- | | `location` | string \| null | User location (country code) | | `device` | string \| null | Device type | | `browser` | string \| null | Browser name | | `os` | string \| null | Operating system | ### Attribution & UTM | Field | Type | Description | | -------------------- | -------------- | ----------------------- | | `first_utm_source` | string \| null | First UTM source | | `last_utm_source` | string \| null | Last UTM source | | `first_utm_medium` | string \| null | First UTM medium | | `last_utm_medium` | string \| null | Last UTM medium | | `first_utm_campaign` | string \| null | First UTM campaign | | `last_utm_campaign` | string \| null | Last UTM campaign | | `first_utm_content` | string \| null | First UTM content | | `last_utm_content` | string \| null | Last UTM content | | `first_utm_term` | string \| null | First UTM term | | `last_utm_term` | string \| null | Last UTM term | | `first_referrer` | string \| null | First referrer domain | | `last_referrer` | string \| null | Last referrer domain | | `first_referrer_url` | string \| null | First referrer full URL | | `last_referrer_url` | string \| null | Last referrer full URL | | `first_ref` | string \| null | First referral code | | `last_ref` | string \| null | Last referral code | ### Last Event | Field | Type | Description | | ----------------- | -------------- | ----------------------------------- | | `last_type` | string \| null | Last event type | | `last_event` | string \| null | Last event name | | `last_properties` | string \| null | Last event properties (JSON string) | ### Expanded Fields When using the `expand` parameter, these additional fields are included: #### `chains` (when `expand=chains`) Array of per-chain wallet data: | Field | Type | Description | | --------------- | ------- | ------------------------------- | | `chain_id` | string | Chain ID | | `net_worth_usd` | number | Net worth on this chain | | `tx_count` | integer | Transaction count on this chain | | `first_onchain` | string | First activity on this chain | | `last_onchain` | string | Last activity on this chain | #### `labels` (when `expand=labels`) Array of wallet labels: | Field | Type | Description | | ---------- | ------ | -------------------------------------- | | `id` | string | Label ID | | `value` | string | Label value | | `chain_id` | string | Chain ID where label applies | | `source` | string | Label source (e.g., manual, automated) | #### `apps` (when `expand=apps`) Array of DeFi app interactions: | Field | Type | Description | | ------------- | -------------- | --------------------------- | | `chain_id` | string | Chain ID | | `id` | string | App ID | | `name` | string | App name | | `img` | string \| null | App image URL | | `url` | string \| null | App URL | | `balance_usd` | number | Balance in USD for this app | #### `tokens` (when `expand=tokens`) Array of token holdings: | Field | Type | Description | | --------------- | -------------- | ----------------------------------------------- | | `chain_id` | string | Chain ID | | `token_address` | string | Token contract address | | `app_id` | string | App ID | | `name` | string | Token name | | `symbol` | string | Token symbol | | `img` | string \| null | Token image URL | | `decimals` | integer | Token decimals | | `price` | number | Token price in USD | | `balance` | string | Token balance (as string to preserve precision) | | `balance_usd` | number | Token balance value in USD | ## Example Response ```json theme={null} { "address": "0x9CC3cB28cd94eB4423B15cdA73346e204f59a407", "net_worth_usd": 125000.5, "ens": "vitalik.eth", "farcaster": null, "lens": null, "tx_count": 1234, "first_onchain": "2024-01-15T10:30:00Z", "last_onchain": "2025-01-20T14:22:00Z", "lifecycle": "Returning", "first_seen": "2024-01-15T10:30:00Z", "last_seen": "2025-01-20T14:22:00Z", "num_sessions": 45, "revenue": 1500.75, "volume": 50000.0, "points": 2500, "chains": [ { "chain_id": "1", "net_worth_usd": 100000.0, "tx_count": 1000, "first_onchain": "2024-01-15T10:30:00Z", "last_onchain": "2025-01-20T14:22:00Z" }, { "chain_id": "137", "net_worth_usd": 25000.5, "tx_count": 234, "first_onchain": "2024-03-01T08:00:00Z", "last_onchain": "2025-01-18T12:00:00Z" } ], "labels": [ { "id": "label_123", "value": "Whale", "chain_id": "1", "source": "manual" } ], "apps": [ { "chain_id": "1", "id": "uniswap", "name": "Uniswap", "img": "https://example.com/uniswap.png", "url": "https://uniswap.org", "balance_usd": 50000.0 } ], "tokens": [ { "chain_id": "1", "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "app_id": "usdc", "name": "USD Coin", "symbol": "USDC", "img": "https://example.com/usdc.png", "decimals": 6, "price": 1.0, "balance": "1000000000", "balance_usd": 1000.0 } ] } ``` ## Error Responses | Status | Code | Description | | ------ | ----------------------- | ------------------------------------------------------------------ | | 400 | `BAD_REQUEST` | Invalid address or ENS name, or the ENS name could not be resolved | | 401 | `UNAUTHORIZED` | Missing or invalid API key | | 403 | `FORBIDDEN` | API key does not have `profiles:read` permission | | 404 | `NOT_FOUND` | Profile does not exist | | 500 | `INTERNAL_SERVER_ERROR` | Failed to fetch wallet profile data | ### Still processing On a cold or first-time wallet lookup, the profile may not be built yet. In that case the endpoint returns `202 Accepted` instead of an error: ```json theme={null} { "status": "processing", "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "message": "Wallet profile is being generated. Retry shortly.", "retry_after": 3 } ``` A `Retry-After` header is also set to the same value (in seconds). This is a normal, common path for first-time wallet lookups: wait `retry_after` seconds and request the same endpoint again. # Import Wallets Source: https://docs.formo.so/api/profiles/import POST /v0/import Bulk import wallet addresses into your Formo project via the API. Enrich imported wallets with onchain data, labels, and profile information automatically. # Search Profiles Source: https://docs.formo.so/api/profiles/search GET /v0/profiles Search and filter wallet profiles by address, socials, labels, net worth, chain activity, and more. Returns paginated results with full profile data. Search and filter wallet profiles across your project with advanced filtering capabilities. You can look up an exact wallet address, run a free-text search across addresses and social fields, and combine that with structured filters in the request body. ## Authentication This endpoint requires authentication using a **Workspace API Key** with `profiles:read` permission. Include the API key in your request headers: ```bash theme={null} Authorization: Bearer ``` The API key needs to have the **read permission for profiles** in the API settings. You can configure this in your workspace API settings. ## Query Parameters ### `address` Filter by a specific wallet address (exact match). Accepts an EVM address, a Solana address, or an ENS name (e.g. `vitalik.eth`) which is resolved to an address before filtering. An unresolvable ENS name or an otherwise invalid value returns a `400 BAD_REQUEST`. ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ "https://api.formo.so/v0/profiles?address=0x1234..." # ENS names are resolved automatically curl -sS -X GET \ -H "Authorization: Bearer " \ "https://api.formo.so/v0/profiles?address=vitalik.eth" ``` ### `search` Case-insensitive free-text search across wallet addresses and all supported social fields. This includes values from both global wallet profiles and project-scoped identify overrides. ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ "https://api.formo.so/v0/profiles?search=bob_override" ``` ### `expand` Comma-separated list of optional sections to include in each profile response. Supported values: * `apps` - DeFi app interactions and balances * `chains` - Per-chain activity metrics * `tokens` - Token holdings * `labels` - Wallet labels ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ "https://api.formo.so/v0/profiles?expand=apps,chains,labels" ``` Expanding fields like `chains`, `tokens`, or `apps` increases response size and latency. The collections are capped at **50 items** each. ### `order_by` Field to sort results by. Supported values: * `last_onchain` (default) - Last on-chain activity timestamp * `first_onchain` - First on-chain activity timestamp * `net_worth_usd` - Total net worth * `updated_at` - Last profile update timestamp * `tx_count` - Total transaction count * `first_seen` - First seen timestamp in your app * `last_seen` - Last seen timestamp in your app * `num_sessions` - Number of sessions * `revenue` - Total revenue * `volume` - Total volume * `points` - Total points ### `order_dir` Sort direction: * `desc` (default) - Descending order * `asc` - Ascending order ### `page` 1-indexed page number to return. * Default: `1` ### `size` Page size - number of profiles per page. * Default: `100` * Maximum: `1000` ## Request Body (Filters) The search endpoint supports rich filtering capabilities through a JSON request body. The body contains a filter object with `filters` and `logic`. ### Filter Schema ```json theme={null} { "filters": [ { "field": "users.net_worth_usd", "op": "gt", "value": 1000 } ], "logic": "and" } ``` | Field | Type | Description | | --------- | ------ | ------------------------------------------------------------------------------------------ | | `filters` | array | Array of filter conditions | | `logic` | string | How to combine conditions: `and` (all must match) or `or` (any must match). Default: `and` | ### Filter Condition Each filter in the `filters` array carries the canonical `{field, op, value}` core, plus named **qualifier** properties for resource filters: | Field | Type | Description | | --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `field` | string | The stable field path to filter on (see Field Reference below). User/profile/social fields use `users.{attribute}`. Resource metrics use the fixed paths `chains.balance`, `apps.balance`, `tokens.balance`, or `labels.value`. Identifiers belong in the qualifier properties below and must **not** be embedded in the field path. | | `op` | string | The comparison operator | | `value` | string \| number \| boolean \| array | The value to compare against. Balance fields (`chains.balance`, `apps.balance`, `tokens.balance`) require a JSON number; numeric strings and empty strings are rejected. `labels.value` rejects an empty string. | | `chain_id` | string | (Optional) Chain qualifier for `chains.balance`, `apps.balance`, `tokens.balance`, and `labels.value`. Omit to compare across all chains. | | `app_id` | string | Required for `apps.balance`, and for `tokens.balance` with `scope: "protocol"` (e.g., `aave-v3`, `compound-v3`). | | `token_address` | string | Required token address for `tokens.balance`. | | `scope` | string | Required for `tokens.balance`: `any` (wallet + all protocols) or `protocol` (a specific protocol; requires `app_id`). | | `tag_id` | string | Required label tag for `labels.value` (e.g., `coinbase.verified_account`). | Unknown properties and qualifiers that don't apply to the targeted `field` are rejected with a `400`. ### Operators | Operator | Description | Example | | ---------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `eq` | Equals | `{"field": "users.device", "op": "eq", "value": "Desktop"}` | | `neq` | Not equals | `{"field": "users.os", "op": "neq", "value": "Windows"}` | | `gt` | Greater than | `{"field": "users.net_worth_usd", "op": "gt", "value": 1000}` | | `gte` | Greater than or equal | `{"field": "users.volume", "op": "gte", "value": 100}` | | `lt` | Less than | `{"field": "users.net_worth_usd", "op": "lt", "value": 50000}` | | `lte` | Less than or equal | `{"field": "chains.balance", "op": "lte", "value": 10000, "chain_id": "1"}` | | `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 and `labels.value` | `{"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"}` | **Legacy spellings are retired.** The long-form spellings `equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, and `includes` (and symbol forms like `=` / `!=` / `like`) are no longer accepted on this endpoint. A request carrying one is rejected with a `400` whose message names the offending token. Send only the canonical operators above. `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. **Dynamic field paths are retired.** Identifier-in-path spellings such as `chains.1.balance`, `apps.aave-v3.balance`, `tokens.0x….balance`, and `labels.coinbase.verified_account` are no longer accepted and return a `400`. Use the stable field (`chains.balance`, `apps.balance`, `tokens.balance`, `labels.value`) with the matching qualifier property (`chain_id`, `app_id`, `token_address`, `tag_id`) instead. Balance fields support only the comparison operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`); `labels.value` supports comparisons plus case-insensitive `contains`. *** ## Field Reference ### User Fields (`users.*`) Filter by user engagement and profile data. Use the format `users.{attribute}`. #### Profile Fields | Field | Type | Description | | --------------------- | ------ | ---------------------- | | `users.net_worth_usd` | number | Total net worth in USD | | `users.volume` | number | Total trading volume | | `users.revenue` | number | Total revenue | | `users.points` | number | Total points | **Example - Find users with net worth > \$10,000:** ```json theme={null} { "filters": [ { "field": "users.net_worth_usd", "op": "gt", "value": 10000 } ], "logic": "and" } ``` **Example - Find high-volume users:** ```json theme={null} { "filters": [ { "field": "users.volume", "op": "gt", "value": 5000 } ], "logic": "and" } ``` #### Engagement Fields | Field | Type | Description | | ---------------- | ------ | ------------------------------------------- | | `users.device` | string | Device type (e.g., `Desktop`, `Mobile`) | | `users.browser` | string | Browser name (e.g., `Chrome`, `Safari`) | | `users.os` | string | Operating system (e.g., `macOS`, `Windows`) | | `users.location` | string | Country code (e.g., `US`, `NG`) | **Example - Find mobile users from the US:** ```json theme={null} { "filters": [ { "field": "users.device", "op": "eq", "value": "Mobile" }, { "field": "users.location", "op": "eq", "value": "US" } ], "logic": "and" } ``` #### UTM & Referral Fields | Field | Type | Description | | -------------------------- | ------ | ----------------------- | | `users.first_utm_source` | string | First UTM source | | `users.last_utm_source` | string | Last UTM source | | `users.first_utm_medium` | string | First UTM medium | | `users.last_utm_medium` | string | Last UTM medium | | `users.first_utm_campaign` | string | First UTM campaign | | `users.last_utm_campaign` | string | Last UTM campaign | | `users.first_utm_content` | string | First UTM content | | `users.last_utm_content` | string | Last UTM content | | `users.first_utm_term` | string | First UTM term | | `users.last_utm_term` | string | Last UTM term | | `users.first_referrer` | string | First referrer domain | | `users.last_referrer` | string | Last referrer domain | | `users.first_referrer_url` | string | First referrer full URL | | `users.last_referrer_url` | string | Last referrer full URL | | `users.first_ref` | string | First referral code | | `users.last_ref` | string | Last referral code | **Example - Find users from Google Ads campaign:** ```json theme={null} { "filters": [ { "field": "users.first_utm_source", "op": "eq", "value": "google" }, { "field": "users.first_utm_medium", "op": "eq", "value": "cpc" } ], "logic": "and" } ``` #### Lifecycle Filter Filter by user lifecycle stage. Valid values: `At Risk`, `Churned`, `New`, `Power user`, `Resurrected`, `Returning`. | Field | Type | Description | | ----------------- | ------ | -------------------- | | `users.lifecycle` | string | User lifecycle stage | **Example - Find new and power users:** ```json theme={null} { "filters": [ { "field": "users.lifecycle", "op": "in", "value": ["New", "Power user"] } ], "logic": "and" } ``` #### Social Fields Social fields support two modes: * 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 | | ----------------- | ------------------------- | | `users.ens` | ENS name | | `users.farcaster` | Farcaster username | | `users.lens` | Lens handle | | `users.basenames` | Base names | | `users.linea` | Linea identifier | | `users.discord` | Discord username | | `users.telegram` | Telegram username | | `users.website` | Website URL | | `users.twitter` | Twitter/X handle | | `users.github` | GitHub username | | `users.linkedin` | LinkedIn profile | | `users.email` | Email address | | `users.instagram` | Instagram handle | | `users.facebook` | Facebook profile | | `users.tiktok` | TikTok handle | | `users.youtube` | YouTube channel or handle | | `users.reddit` | Reddit username | **Example - Find users with any email present:** ```json theme={null} { "filters": [ { "field": "users.email", "op": "notEmpty" } ], "logic": "and" } ``` **Example - Find users with both ENS and Farcaster present:** ```json theme={null} { "filters": [ { "field": "users.ens", "op": "notEmpty" }, { "field": "users.farcaster", "op": "notEmpty" } ], "logic": "and" } ``` **Example - Find users whose Twitter contains `bob`:** ```json theme={null} { "filters": [ { "field": "users.twitter", "op": "contains", "value": "bob" } ], "logic": "and" } ``` **Example - Find an exact email match:** ```json theme={null} { "filters": [ { "field": "users.email", "op": "eq", "value": "alice@formo.so" } ], "logic": "and" } ``` *** ### Chain Filters (`chains.balance`) Filter by per-chain net worth using the stable field `chains.balance` and an optional `chain_id` qualifier. This is distinct from `users.net_worth_usd`, which compares the profile's **total** net worth. #### All Chains Omit `chain_id` to filter across all chains (returns profiles where **any** chain matches). **Example - Find users with >\$1,000 on any chain:** ```json theme={null} { "filters": [ { "field": "chains.balance", "op": "gt", "value": 1000 } ], "logic": "and" } ``` #### Specific Chain Set the `chain_id` qualifier to filter on a specific chain. Common chain IDs: * `1` - Ethereum Mainnet * `137` - Polygon * `42161` - Arbitrum One * `10` - Optimism * `8453` - Base * `56` - BNB Chain * `43114` - Avalanche **Example - Find users with >\$5,000 on Ethereum:** ```json theme={null} { "filters": [ { "field": "chains.balance", "op": "gt", "value": 5000, "chain_id": "1" } ], "logic": "and" } ``` **Example - Find users with >\$1,000 on both Ethereum and Polygon:** ```json theme={null} { "filters": [ { "field": "chains.balance", "op": "gt", "value": 1000, "chain_id": "1" }, { "field": "chains.balance", "op": "gt", "value": 1000, "chain_id": "137" } ], "logic": "and" } ``` *** ### App Filters (`apps.balance`) Filter by DeFi app balances using the stable field `apps.balance` with a required `app_id` qualifier and an optional `chain_id` qualifier. #### All Chains Omit `chain_id` to filter by app balance across all chains. **Example - Find users with >\$1,000 in Uniswap:** ```json theme={null} { "filters": [ { "field": "apps.balance", "op": "gt", "value": 1000, "app_id": "uniswap" } ], "logic": "and" } ``` #### Specific Chain Set the `chain_id` qualifier to filter by app balance on a specific chain. **Example - Find users with >\$500 in Aave on Ethereum:** ```json theme={null} { "filters": [ { "field": "apps.balance", "op": "gt", "value": 500, "app_id": "aave", "chain_id": "1" } ], "logic": "and" } ``` *** ### Token Filters (`tokens.balance`) Filter by token holdings using the stable field `tokens.balance` with required `token_address` and `scope` qualifiers, plus an optional `chain_id` and, for protocol scope, a required `app_id`. #### Token Filter Qualifiers | Qualifier | Type | Description | | --------------- | ------ | ----------------------------------------------------------------------------------------------------- | | `token_address` | string | Required. The token's contract address. | | `scope` | string | Required. `any` = wallet + all protocols, `protocol` = a specific protocol only (requires `app_id`) | | `app_id` | string | Required for `scope: "protocol"`. The DeFi protocol ID (e.g., `aave-v3`, `compound-v3`, `uniswap-v3`) | | `chain_id` | string | Optional. Omit to compare across all chains. | #### All Chains Omit `chain_id` to filter by token balance across all chains. **Example - Find users holding >1000 USDC (any chain):** ```json theme={null} { "filters": [ { "field": "tokens.balance", "op": "gt", "value": 1000, "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "scope": "any" } ], "logic": "and" } ``` #### Specific Chain Set the `chain_id` qualifier to filter by token balance on a specific chain. **Example - Find users with >500 USDC on Ethereum:** ```json theme={null} { "filters": [ { "field": "tokens.balance", "op": "gt", "value": 500, "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "scope": "any", "chain_id": "1" } ], "logic": "and" } ``` #### Protocol-Specific Token Filters Use `scope: "protocol"` with `app_id` to filter for tokens deposited in a specific DeFi protocol. **Example - Find users with USDC deposited in Aave V3:** ```json theme={null} { "filters": [ { "field": "tokens.balance", "op": "gt", "value": 1000, "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "scope": "protocol", "app_id": "aave-v3" } ], "logic": "and" } ``` **Example - Find users with ETH staked in Lido:** ```json theme={null} { "filters": [ { "field": "tokens.balance", "op": "gt", "value": 1, "token_address": "0x0000000000000000000000000000000000000000", "scope": "protocol", "app_id": "lido" } ], "logic": "and" } ``` *** ### Label Filters (`labels.value`) Filter by wallet labels using the stable field `labels.value` with a required `tag_id` qualifier (and an optional `chain_id`). Supported operators are the comparisons (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`) and case-insensitive `contains`; the `value` must be non-empty. Common label tags: * `coinbase.verified_account` - Coinbase verified account (boolean) * `coinbase.verified_country` - Coinbase verified country code * `coinbase.verified_coinbase_one` - Coinbase One membership (boolean) * `sanctions.designated` - Sanctioned address (boolean) * `passport.models_aggregate_score` - Passport aggregate score (0-100) * `passport.unique_humanity_score` - Passport uniqueness score **Example - Find Coinbase verified users:** ```json theme={null} { "filters": [ { "field": "labels.value", "op": "eq", "value": "true", "tag_id": "coinbase.verified_account" } ], "logic": "and" } ``` **Example - Find US-verified Coinbase users:** ```json theme={null} { "filters": [ { "field": "labels.value", "op": "eq", "value": "US", "tag_id": "coinbase.verified_country" } ], "logic": "and" } ``` **Example - Find users with high Passport score:** ```json theme={null} { "filters": [ { "field": "labels.value", "op": "gte", "value": 50, "tag_id": "passport.models_aggregate_score" } ], "logic": "and" } ``` *** ## Combined Filter Examples ### High-Value Web3 Users Find users with >\$10,000 net worth, ENS name, and activity on Ethereum: ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "filters": [ { "field": "users.net_worth_usd", "op": "gt", "value": 10000 }, { "field": "users.ens", "op": "notEmpty" }, { "field": "chains.balance", "op": "gt", "value": 0, "chain_id": "1" } ], "logic": "and" }' \ "https://api.formo.so/v0/profiles?expand=chains,labels" ``` ### Active DeFi Users Find users with activity in Uniswap or Aave: ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "filters": [ { "field": "apps.balance", "op": "gt", "value": 0, "app_id": "uniswap" }, { "field": "apps.balance", "op": "gt", "value": 0, "app_id": "aave" } ], "logic": "or" }' \ "https://api.formo.so/v0/profiles?expand=apps" ``` ### Verified Power Users Find Coinbase-verified power users from specific countries: ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "filters": [ { "field": "labels.value", "op": "eq", "value": "true", "tag_id": "coinbase.verified_account" }, { "field": "labels.value", "op": "eq", "value": "US", "tag_id": "coinbase.verified_country" }, { "field": "users.lifecycle", "op": "eq", "value": "Power user" } ], "logic": "and" }' \ "https://api.formo.so/v0/profiles" ``` ### Multi-Chain Whales Find users with significant holdings across multiple chains: ```bash theme={null} curl -sS -X GET \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "filters": [ { "field": "chains.balance", "op": "gt", "value": 10000, "chain_id": "1" }, { "field": "chains.balance", "op": "gt", "value": 5000, "chain_id": "137" }, { "field": "chains.balance", "op": "gt", "value": 5000, "chain_id": "42161" } ], "logic": "and" }' \ "https://api.formo.so/v0/profiles?expand=chains&order_by=net_worth_usd&order_dir=desc" ``` *** ## Response The response is a [paginated envelope](/api/overview#pagination) of wallet profiles. ```json theme={null} { "data": [ { "address": "0x9CC3cB28cd94eB4423B15cdA73346e204f59a407", "net_worth_usd": 125000.5, "ens": "example.eth", "tx_count": 1234, "first_onchain": "2024-01-15T10:30:00Z", "last_onchain": "2025-01-20T14:22:00Z", "lifecycle": "Returning" } ], "total": 150, "page": 1, "size": 100, "has_more": true } ``` ### Response Fields | Field | Type | Description | | ---------- | ------- | -------------------------------------------------------------- | | `data` | array | Array of wallet profiles matching the search filters | | `total` | integer | Total number of profiles matching the filters across all pages | | `page` | integer | 1-indexed page number echoed from the request | | `size` | integer | Page size echoed from the request | | `has_more` | boolean | `true` if there are pages after the current one | ### Profile Fields Each profile in the `data` array includes: | Field | Type | Description | | --------------- | --------------- | ------------------------------------------------------------------------------------------- | | `address` | string | Wallet address | | `net_worth_usd` | number | Total net worth in USD | | `tx_count` | integer | Total transaction count | | `first_onchain` | string | First on-chain activity timestamp (ISO 8601) | | `last_onchain` | string | Last on-chain activity timestamp (ISO 8601) | | `lifecycle` | string | User lifecycle stage: `At Risk`, `Churned`, `New`, `Power user`, `Resurrected`, `Returning` | | `ens` | string \| null | ENS name | | `farcaster` | string \| null | Farcaster username | | `twitter` | string \| null | Twitter/X handle | | `discord` | string \| null | Discord username | | `telegram` | string \| null | Telegram username | | `email` | string \| null | Email address | | `first_seen` | string \| null | First seen timestamp in your app | | `last_seen` | string \| null | Last seen timestamp in your app | | `num_sessions` | integer \| null | Total number of sessions | | `revenue` | number \| null | Total revenue | | `volume` | number \| null | Total volume | | `points` | number \| null | Total points | | `chains` | array \| null | Per-chain data (when expanded) | | `apps` | array \| null | DeFi app data (when expanded) | | `tokens` | array \| null | Token holdings (when expanded) | | `labels` | array \| null | Wallet labels (when expanded) | ## Error Responses | Status | Code | Description | | ------ | ----------------------- | ---------------------------------------------------------- | | 400 | `BAD_REQUEST` | Invalid query parameters or malformed JSON in request body | | 401 | `UNAUTHORIZED` | Missing or invalid API key | | 403 | `FORBIDDEN` | API key does not have `profiles:read` permission | | 500 | `INTERNAL_SERVER_ERROR` | Failed to fetch wallet profile data | # Update Properties Source: https://docs.formo.so/api/profiles/update-properties PUT /v0/profiles/{address}/properties Merge-update profile properties for a wallet. Set display name, email, socials, avatar, location, and other identity fields, or send null to delete a property, via a single PUT request. ## Deleting properties Send `null` as a property value to delete (unset) it. Set and unset compose in a single call, and every key you did not mention is left untouched: ```bash theme={null} curl -sS -X PUT \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "display_name": "alice.eth", "email": null }' \ "https://api.formo.so/v0/profiles/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/properties" ``` A deleted property reads as `null` everywhere (profile reads, the Users table, search, and filters such as `notEmpty`), including over any globally-enriched fallback value, until a new value is set. One exception: a deleted `location` falls back to device geo rather than reading as absent. `user_id` cannot be unset; it participates in identity stitching, so `{ "user_id": null }` fails validation with [`400 BAD_REQUEST`](/api/errors#bad_request). The literal string `_is_deleted` is a reserved internal value and is rejected wherever a property value is accepted. Wallet-enrichment snapshots are versioned, but first-party property overrides (and their deletions) are always current. A point-in-time read with `timestamp` reflects the latest overrides, so a deletion also masks reads of snapshots taken before it. # Send Query Source: https://docs.formo.so/api/query POST /v0/query Execute read-only SQL queries against your Formo analytics data warehouse via the API. Returns structured results for events, users, and sessions. Run a read-only SQL query to read your data. See examples on the [Explorer page](/features/product-analytics/explore). Query API architecture diagram showing your service sending SQL queries to the Query API and receiving data from the Data Warehouse ## Authentication Use a **Workspace API Key** with `query:read` permission. Send it in the `Authorization` header: ```bash theme={null} Authorization: Bearer ``` ## Request * Method: `POST` * Path: `/v0/query` * Body: ```json theme={null} { "query": "SELECT * FROM events LIMIT 100" } ``` Limits: * Query must be a single `SELECT`/`WITH` statement (read-only). * No comments or multiple statements. * `LIMIT` is optional (defaults to 100 rows if omitted) and must be `<= 1000000`. ## Response `200 OK` returns rows plus pagination metadata reflecting the `LIMIT` / `OFFSET` you wrote into the SQL. ```json theme={null} { "data": [ { "event": "page", "address": "0x123...", "timestamp": "2025-01-20T10:00:00Z" } ], "total": 120, "limit": 100, "offset": 0, "has_more": true } ``` | Field | Type | Description | | ---------- | ------- | ---------------------------------------- | | `data` | array | Result rows | | `total` | integer | Total rows before `LIMIT` was applied | | `limit` | integer | `LIMIT` echoed from the SQL | | `offset` | integer | `OFFSET` echoed from the SQL | | `has_more` | boolean | `true` if `total > offset + data.length` | This endpoint uses `limit`/`offset` (not `page`/`size`) because the client controls pagination directly through the SQL query - Formo just echoes the values back. ## Errors Branch on `error.code`; see [Errors](/api/errors) for the full reference. * `400 BAD_REQUEST`: missing query, invalid SQL, `LIMIT` over 1,000,000, or missing project id. * `401 UNAUTHORIZED`: missing/invalid authorization header or API key. * `403 FORBIDDEN`: API key lacks `query:read` scope. * `404 NOT_FOUND`: project or read token not found. * `429 TOO_MANY_REQUESTS`: per-workspace rate limit exceeded. * `500 INTERNAL_SERVER_ERROR`: failure executing the query. # Get Event Timeseries Source: https://docs.formo.so/api/query/event-timeseries GET /v0/event_timeseries Daily event count over the selected window. Returns total event counts per day. Combine with `filters` to scope to a specific event, source, or location. 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 `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. # Get Flow Source: https://docs.formo.so/api/query/flow GET /v0/flow Session-scoped user-flow transitions for Sankey-style charts. Returns the same Sankey transitions the dashboard renders on the Flows chart. For each session that fires the start step inside `[date_from, date_to]`, the pipe rebuilds the ordered sequence of subsequent events within the conversion window, normalises them into flow-node labels, and emits one row per `(step, source, target)` transition with counts and per-step percentages. When `end_step` is set, only sessions that reached the end event within the conversion window are kept (converter-only mode), and the matched event is suffixed with `__END_MATCH__`. ## Defining start and end steps Both `start_step` and `end_step` are JSON-encoded objects of shape `{type, event, resolved_event, filters?: [...], status_type?, status_value?}`: * **`type`** - event type (`event`, `track`, `transaction`, `signature`, `decoded_log`). * **`event`** - the event name. * **`resolved_event`** - the value matched against the events table. Use the sentinel `"__ALL_PAGE_VIEWS__"` to match any page view, or the page path / event name otherwise. * **`filters`** - optional `[{field, op, value, values?}]`. Use `values` for the `in` / `nin` operators. Operators use the canonical tokens only (`eq`, `neq`, `in`, `nin`, `gt`, `lt`, `gte`, `lte`, `startsWith`, `endsWith`, `contains`, `notEmpty`, `isEmpty`). Retired long-form spellings like `equals` / `notIn` / `includes` are rejected with a `400` naming the token. Pass `global_filters` as a JSON-encoded array to apply additional `{field, op, value, values?}` constraints to both the start-session scan and the relevant-events scan. ## Conversion window and depth `window_seconds` (default `7200`, or 2 hours) bounds how long after the start event the path is allowed to extend. `max_steps` (default `4`, clamped to `2..10`) caps the Sankey depth. ## Example ```bash theme={null} curl -G "https://api.formo.so/v0/flow" \ -H "Authorization: Bearer $FORMO_API_KEY" \ --data-urlencode "date_from=2026-07-01" \ --data-urlencode "date_to=2026-07-28" \ --data-urlencode "window_seconds=7200" \ --data-urlencode "max_steps=4" \ --data-urlencode 'start_step={"type":"event","event":"page","resolved_event":"__ALL_PAGE_VIEWS__","filters":[]}' \ --data-urlencode 'end_step={"type":"track","event":"swap","resolved_event":"swap","filters":[]}' \ --data-urlencode 'global_filters=[{"field":"referrer","op":"contains","value":"google"}]' ``` # Get Funnel Source: https://docs.formo.so/api/query/funnel GET /v0/funnel Multi-step conversion funnel with per-step user counts, conversion ratios, and median time-to-convert. Returns the same funnel data the dashboard renders on the Funnels page. For an ordered list of step specs, you get 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. Use `funnel_type=closed` (default) for ordered, in-window conversions (the default that powers the dashboard) or `funnel_type=open` to count whoever fired step k regardless of order - open mode also returns a `dropped_off_users` column. Set `group_by` to a dimension (`device`, `browser`, `os`, `location`, `referrer`, `ref`, `builder_codes`, or any UTM column) to group each step by per-user attribution; `limit` controls how many categories are kept (default `5`) before bucketing the rest as `Others`. Attribution defaults to first-touch; pass `attribution=last_touch` to bucket each user by their latest value instead. The `group_by`, `limit`, and `attribution` params replace the former `breakdown` and `breakdown_top_n` params. Direct callers using `?breakdown=` / `?breakdown_top_n=` must migrate - there are no aliases. ## Defining steps The `steps` query parameter is a JSON-encoded array of 2 to 10 step specs. Each step is `{type, event, name, filters?: [...]}`: * **`type`** - event type (`event` for page views, `track` for custom events, `transaction`, `signature`, `decoded_log`). * **`event`** - the event name to match. * **`name`** - a unique step id. Use `::` (e.g. `"connect::1"`) so the same event re-used at multiple steps can be told apart in the response. * **`filters`** - optional `[{field, op, value}]`. * **Operators:** * `eq` * `neq` * `in` * `nin` * `gt` * `lt` * `gte` * `lte` * `startsWith` * `endsWith` * `contains` * `notEmpty` / `isEmpty` - value-less existence checks (the `value` is ignored; rejected on the numeric event columns `volume`/`revenue`/`points`) * Canonical tokens only: the retired long-form spellings (`equals`, `notEquals`, `notIn`, `includes`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`) are rejected with a `400` naming the token. * For `in` / `nin`, pass the values as a `|`-separated string in `value` (e.g. `"ethereum|polygon|base"`). * **`field`** may target a standard event column or a JSON property on `properties`. ## Example ```bash theme={null} curl -G "https://api.formo.so/v0/funnel" \ -H "Authorization: Bearer $FORMO_API_KEY" \ --data-urlencode "dateFrom=2026-04-01" \ --data-urlencode "dateTo=2026-04-30" \ --data-urlencode "window_seconds=86400" \ --data-urlencode 'steps=[ {"type":"event","event":"page","name":"page::0","filters":[]}, {"type":"track","event":"connect","name":"connect::1","filters":[]}, {"type":"track","event":"swap","name":"swap::2","filters":[{"field":"chain","op":"eq","value":"ethereum"}]} ]' ``` # Get KPIs Source: https://docs.formo.so/api/query/kpis GET /v0/kpis Time-series traffic KPIs (visitors, pageviews, bounce rate, session duration), with optional dimension breakdown and previous-period comparison. Returns the same KPI series that powers the Formo dashboard's overview chart. Here `sessions` is the per-day session count (the overview's **Sessions** card), while `visitors` (and `visitors_current` / `visitors_previous`) are unique anonymous visitors (the overview's **Visitors** card), returned only when `include_previous_period=true` and no `group_by` is set. Use `group_by` to break results down by `referrer`, `location`, `device`, `browser`, `os`, or any UTM dimension. Set `include_previous_period=true` to also receive the equivalent prior window for week-over-week comparisons. ## Filtering Pass `filters` as a JSON-encoded array of `{ field, op, value }` objects. Multiple filters are combined with implicit AND. For example, return KPIs only for traffic whose referrer contains `google`: ```bash theme={null} curl -G "https://api.formo.so/v0/kpis" \ -H "Authorization: Bearer $FORMO_API_KEY" \ --data-urlencode "date_from=2026-07-01" \ --data-urlencode "date_to=2026-07-28" \ --data-urlencode 'filters=[{"field":"referrer","op":"contains","value":"google"}]' ``` To filter by page path, use the `page` field: ```bash theme={null} curl -G "https://api.formo.so/v0/kpis" \ -H "Authorization: Bearer $FORMO_API_KEY" \ --data-urlencode "date_from=2026-07-01" \ --data-urlencode "date_to=2026-07-28" \ --data-urlencode 'filters=[{"field":"page","op":"eq","value":"/pricing"}]' \ --data-urlencode "page_scope=page" ``` `page_scope=page` is the default. It counts `pageviews` only on the filtered page and calculates `bounce_rate` and `avg_session_sec` for sessions that landed there. Use `page_scope=session` for the legacy behavior, which includes all activity in any session that viewed the page. The scope only changes requests that include a `page` filter. Every public filter leaf uses `{"field", "op", "value"}`, and every collection is named `filters`. Query endpoints and saved segments combine their arrays with implicit AND. Profile search adds a sibling `logic` field, so its body is `{"filters": [...], "logic": "and" | "or"}`. # Get Retention Source: https://docs.formo.so/api/query/retention GET /v0/retention User retention cohorts by signup week. Returns the retention cohort table the dashboard renders on the Retention page. Each row is a cohort; columns are subsequent activity buckets. # Get Revenue by Metric Source: https://docs.formo.so/api/query/revenue-by-metric GET /v0/revenue_by_metric Revenue grouped by a chosen column (e.g. pathname, referrer). Returns total revenue broken down by `metric_column`. The required `metric_column` query parameter selects the dimension to group by - for example `pathname`, `referrer`, `utm_source`, or `location`. Use `paid_source` to group by acquiring ad network (per-event sticky attribution; rows with no paid touch are excluded). # Get Revenue Overview Source: https://docs.formo.so/api/query/revenue-overview GET /v0/revenue_overview Revenue and transaction volume time-series with optional grouping and previous-period comparison. Returns combined revenue and transaction-volume series for the selected window. Use `group_by` and `rank_by` to break the chart down by another dimension, and `include_previous_period=true` for week-over-week comparison. # Get Revenue Timeseries Source: https://docs.formo.so/api/query/revenue-timeseries GET /v0/revenue_timeseries Per-event revenue and volume trend for a single wallet. Wallet-scoped: `address` is required. For project-wide revenue trends, use [`revenue-overview`](/api/query/revenue-overview) instead. # Get Top Chains Source: https://docs.formo.so/api/query/top-chains GET /v0/top_chains Top blockchain chains by activity. Returns the most active blockchain chains for connected wallets in the selected window. # Get Top Events Source: https://docs.formo.so/api/query/top-events GET /v0/top_events Top events by frequency over the selected window. Returns the most frequently fired events in the project, by total event count. Pass `type=custom` to restrict results to custom events (events tracked via `formo.track(...)`). This replaces the previous `top_custom_events` endpoint. # Get Top Locations Source: https://docs.formo.so/api/query/top-locations GET /v0/top_locations Top countries by visits. Returns the top countries of visitors over the selected window. The `location` field is an ISO 3166-1 alpha-2 country code (for example, `US`, `GB`, `DE`). # Get Top Pages Source: https://docs.formo.so/api/query/top-pages GET /v0/top_pages Top pages by visits and users over the selected window. Returns the most-visited pages, ranked by visits, with unique visitor counts. Use `limit` and `offset` for pagination. # Get Top Sources Source: https://docs.formo.so/api/query/top-sources GET /v0/top_sources Top traffic sources by referrer or UTM dimension over the selected window. Returns the top traffic sources for the project. Pass `metric_column` to switch between `referrer`, `referrer_url`, `ref`, `origin`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `device`, `browser`, `os`, and `channel`. Defaults to `referrer`. `channel` is a distinct option: instead of returning a raw dimension value, it classifies each session into a marketing acquisition channel (e.g. Organic Search, Paid Social, Direct, Referral) using Formo's channel classifier, so you can see top-level channel performance without building the grouping logic yourself. # Get Top Wallets Source: https://docs.formo.so/api/query/top-wallets GET /v0/top_wallets Top wallet types (e.g. MetaMask, Coinbase Wallet) by connections. Returns the most popular wallet providers used to connect, by connection count. # Get User Frequency Source: https://docs.formo.so/api/query/user-frequency GET /v0/frequency Distribution of users by visit count over the selected window. Returns how many users visited 1, 2, 3+ times in the selected window - the data behind the dashboard's frequency histogram. # Get User Lifecycle Source: https://docs.formo.so/api/query/user-lifecycle GET /v0/lifecycle Wallet user counts by lifecycle stage (New, Returning, Power user, Resurrected, At Risk, Churned). Counts wallet users in each lifecycle stage, computed against the activity window ending at `date_to`. To return only one stage, add a lifecycle entry to the `filters` array, for example `filters=[{"field":"lifecycle","op":"in","value":"Churned"}]`. Pass `include_previous_period=true` to also receive the prior window. # Get Volume by Metric Source: https://docs.formo.so/api/query/volume-by-metric GET /v0/volume_by_metric Transaction volume grouped by a chosen column (e.g. pathname, referrer). Returns total transaction volume broken down by `metric_column`. The required `metric_column` query parameter selects the dimension - for example `pathname`, `referrer`, `utm_source`, or `location`. Use `paid_source` to group by acquiring ad network (per-event sticky attribution; rows with no paid touch are excluded). # Create Segment Source: https://docs.formo.so/api/segments/create POST /v0/segments Create a new audience segment by defining filter rules based on wallet properties, onchain behavior, and user demographics via the Formo API. # Delete Segment Source: https://docs.formo.so/api/segments/delete DELETE /v0/segments/{segmentId} Permanently remove an audience segment from your project by segment ID. Deleted segments can no longer be used in filters or exports. # List Segments Source: https://docs.formo.so/api/segments/list GET /v0/segments Retrieve all audience segments for your project using the Formo API. Returns segment names and filters. # x402 / MPP Source: https://docs.formo.so/api/x402 Let AI agents call the Formo API and pay per request, with no API key required, using the x402 and MPP payment protocols. AI agents can access Formo endpoints without a Workspace API key by paying per request through a payment gateway. Two protocols are supported: * **[x402](https://www.x402.org/)** via `formo.x402.paysponge.com`: the agent pays 0.05 USDC per request on Base. * **[MPP](https://mpp.dev/)** via `formo.mpp.paysponge.com`: the agent pays 0.05 USDC.e per request on Tempo. In both cases the gateway issues a `402 Payment Required` challenge on the first request, and a compliant client handles the payment automatically. The response shape is identical to the standard, API-key authenticated endpoint. ## How it works 1. The agent sends a request to the gateway URL for the endpoint it wants. 2. The gateway responds with `402 Payment Required` and the payment terms. 3. A compliant x402 or MPP client signs and submits the payment, then retries. 4. The gateway proxies the request to Formo and returns the response. ## Supported endpoints Retrieve a comprehensive wallet profile by address, including onchain labels, token holdings, DeFi positions, web demographics, and lifecycle data. # Chains Source: https://docs.formo.so/chains/overview View all EVM and non-EVM blockchain networks supported by Formo, including Ethereum, Arbitrum, Optimism, Polygon, Base, Solana, and more. ## EVM Formo supports builders on Ethereum and all major EVM chains. * [Analytics](https://docs.formo.so/features/product-analytics/overview) autocaptures wallet events from page visit to transaction for all chains. * [Wallet profiles](https://docs.formo.so/features/wallet-intelligence/wallet-profiles) are available for nearly all mainnet chains. * [Contract events](https://docs.formo.so/features/product-analytics/contract-events#contract-events) are available on major chains, more available on request. * [Token-gated forms](https://docs.formo.so/features/token-gated-forms/token-gating) is available on all major chains. [Book a chat](https://cal.com/formo/15min) if you have queries. | Chain | Analytics | Wallet profiles | Contract events | Token gating | | :----------------------- | :-------- | :-------------- | :-------------- | :----------- | | Abstract Mainnet | ✅ | ✅ | ✅ | ✅ | | Abstract Testnet | ✅ | ✖️ | ✅ | ✅ | | Arbitrum Mainnet | ✅ | ✅ | ✅ | ✅ | | Arbitrum Nova | ✅ | ✅ | ✅ | ✅ | | Arbitrum Sepolia | ✅ | ✖️ | ✅ | ✅ | | Arc Testnet | ✅ | ✖️ | ✖️ | ✅ | | Avalanche Mainnet | ✅ | ✅ | ✅ | ✅ | | Avalanche Fuji | ✅ | ✖️ | ✖️ | ✅ | | Base Mainnet | ✅ | ✅ | ✅ | ✅ | | Base Sepolia | ✅ | ✖️ | ✅ | ✅ | | Blast Mainnet | ✅ | ✅ | ✖️ | ✅ | | Blast Sepolia | ✅ | ✖️ | ✖️ | ✅ | | BOB | ✅ | ✅ | ✖️ | ✖️ | | BOB Testnet | ✅ | ✖️ | ✅ | ✅ | | BNB Smart Chain Mainnet | ✅ | ✅ | ✅ | ✅ | | BNB Smart Chain Testnet | ✅ | ✖️ | ✅ | ✅ | | Celo Mainnet | ✅ | ✅ | ✅ | ✅ | | Celo Alfajores | ✅ | ✖️ | ✖️ | ✅ | | Ethereum Mainnet | ✅ | ✅ | ✅ | ✅ | | Ethereum Sepolia | ✅ | ✖️ | ✅ | ✅ | | Etherlink | ✅ | ✅ | ✖️ | ✖️ | | Flare Network Mainnet | ✅ | ✅ | ✅ | ✖️ | | Flare Network Testnet | ✅ | ✖️ | ✅ | ✖️ | | Gnosis Mainnet | ✅ | ✅ | ✅ | ✅ | | Gnosis Testnet | ✅ | ✖️ | ✖️ | ✅ | | HyperEVM Mainnet | ✅ | ✅ | ✅ | ✅ | | HyperEVM Testnet | ✅ | ✖️ | ✅ | ✅ | | Immutable zkEVM Mainnet | ✅ | ✅ | ✅ | ✅ | | Immutable zkEVM Testnet | ✅ | ✖️ | ✅ | ✅ | | Ink Mainnet | ✅ | ✅ | ✅ | ✅ | | Ink Sepolia | ✅ | ✖️ | ✅ | ✅ | | Kaia Mainnet | ✅ | ✅ | ✅ | ✅ | | Kaia Kairos | ✅ | ✖️ | ✖️ | ✅ | | Katana | ✅ | ✅ | ✖️ | ✅ | | Linea Mainnet | ✅ | ✅ | ✅ | ✅ | | Linea Sepolia | ✅ | ✖️ | ✅ | ✅ | | Mantle Mainnet | ✅ | ✅ | ✅ | ✅ | | Mantle Sepolia | ✅ | ✖️ | ✖️ | ✅ | | MegaETH Mainnet | ✅ | ✅ | ✅ | ✅ | | MegaETH Testnet | ✅ | ✖️ | ✅ | ✅ | | Mode Mainnet | ✅ | ✅ | ✖️ | ✅ | | Mode Testnet | ✅ | ✖️ | ✅ | ✅ | | Monad Mainnet | ✅ | ✅ | ✅ | ✅ | | Monad Testnet | ✅ | ✖️ | ✅ | ✅ | | Morph | ✅ | ✅ | ✖️ | ✖️ | | Morph Testnet | ✅ | ✖️ | ✖️ | ✅ | | Optimism Mainnet | ✅ | ✅ | ✅ | ✅ | | Optimism Sepolia | ✅ | ✖️ | ✅ | ✅ | | Orderly | ✅ | ✅ | ✖️ | ✖️ | | Plasma Mainnet | ✅ | ✅ | ✅ | ✅ | | Plasma Testnet | ✅ | ✖️ | ✅ | ✅ | | Plume Mainnet | ✅ | ✅ | ✅ | ✖️ | | Polygon Mainnet | ✅ | ✅ | ✅ | ✅ | | Polygon Amoy | ✅ | ✖️ | ✅ | ✅ | | Robinhood Chain Mainnet | ✅ | ✅ | ✅ | ✅ | | Ronin | ✅ | ✅ | ✅ | ✅ | | Ronin Saigon | ✅ | ✖️ | ✖️ | ✅ | | Scroll Mainnet | ✅ | ✅ | ✅ | ✅ | | Scroll Sepolia | ✅ | ✖️ | ✅ | ✅ | | Sei | ✅ | ✅ | ✅ | ✅ | | Sei Testnet | ✅ | ✖️ | ✖️ | ✅ | | Soneium | ✅ | ✅ | ✅ | ✅ | | Soneium Minato | ✅ | ✖️ | ✅ | ✅ | | Sonic Mainnet | ✅ | ✅ | ✅ | ✅ | | Sonic Blaze Testnet | ✅ | ✖️ | ✖️ | ✅ | | Stable Mainnet | ✅ | ✅ | ✖️ | ✅ | | Stable Testnet | ✅ | ✖️ | ✖️ | ✅ | | Tempo Mainnet | ✅ | ✅ | ✅ | ✅ | | Tempo Testnet | ✅ | ✖️ | ✅ | ✅ | | Taiko | ✅ | ✅ | ✖️ | ✖️ | | Unichain | ✅ | ✅ | ✅ | ✅ | | Unichain Sepolia Testnet | ✅ | ✖️ | ✅ | ✅ | | World Chain | ✅ | ✅ | ✅ | ✅ | | World Chain Sepolia | ✅ | ✖️ | ✖️ | ✅ | | zkSync Era Mainnet | ✅ | ✅ | ✅ | ✅ | | zkSync Era Sepolia | ✅ | ✖️ | ✖️ | ✅ | > Don't see your chain? [Let us know](https://formo.so/support). ## SVM Formo supports builders on Solana. | Chain | Analytics | Wallet profiles | Contract events | Token gating | | :------------- | :-------- | :-------------- | :-------------- | :----------- | | Solana Mainnet | ✅ | ✅ | ⏳ | ✅ | | Solana Devnet | ✅ | ✖️ | ⏳ | ✅ | # Alerts Source: https://docs.formo.so/cli/alerts Manage project alerts from the terminal with the Formo CLI. Create, list, update, test, delete, and toggle alerts that notify your team about matching events or users. The `formo alerts` command group manages project alerts. Alerts notify a webhook (including Slack, via a Slack incoming-webhook URL) when event or user filters match. ## `formo alerts list` List alerts for the active project. Requires `alerts:read` scope on your API key. ### Options | Option | Type | Required | Description | | -------- | -------- | -------- | ---------------------- | | `--page` | `number` | ❌ | Page number, 1-indexed | | `--size` | `number` | ❌ | Page size | ```bash theme={null} formo alerts list --size 25 ``` *** ## `formo alerts get ` Get a single alert by ID. Requires `alerts:read` scope on your API key. ```bash theme={null} formo alerts get alert_abc123 ``` *** ## `formo alerts create` Create a new project alert. Requires `alerts:write` scope on your API key. ### Options | Option | Type | Required | Description | | ----------------------- | -------- | -------- | ----------------------------------------------------------------- | | `--name` | `string` | ✅ | Alert name | | `--trigger-type` | `enum` | ✅ | Trigger type: `event` or `user` | | `--trigger-filters` | `string` | ❌ | JSON array of trigger filter objects | | `--recipient` | `string` | ❌ | JSON array of recipient objects | | `--secret` | `string` | ❌ | Webhook secret for the alert | | `--slack-property-keys` | `string` | ❌ | JSON array of event/user property keys to include in Slack alerts | ### Recipient Shape `--recipient` is a JSON array. Only `type: "webhook"` is currently delivered to; use a `hooks.slack.com` URL as the `value` to post to Slack. `email`/`slack` type values are accepted by the schema but not delivered; use `webhook` instead. ```json theme={null} [ { "type": "webhook", "value": ["https://example.com/formo-webhook"] } ] ``` ### Examples ```bash theme={null} formo alerts create --name "High value tx" --trigger-type event formo alerts create \ --name "Whale activity" \ --trigger-type user \ --trigger-filters '[{"field":"net_worth_usd","op":"gt","value":"100000"}]' \ --recipient '[{"type":"webhook","value":["https://example.com/formo-webhook"]}]' formo alerts create \ --name "Slack revenue alert" \ --trigger-type user \ --trigger-filters '[{"field":"revenue","op":"gt","value":"","numericThreshold":"1000"}]' \ --recipient '[{"type":"webhook","value":["https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"]}]' \ --slack-property-keys '["event","revenue","address"]' ``` *** ## `formo alerts update ` Update an existing alert. Requires `alerts:write` scope on your API key. The API expects the full alert configuration, so provide the required alert fields again when updating. ### Options | Option | Type | Required | Description | | ----------------------- | -------- | -------- | ----------------------------------------------------------------- | | `--name` | `string` | ✅ | Alert name | | `--trigger-type` | `enum` | ✅ | Trigger type: `event` or `user` | | `--trigger-filters` | `string` | ❌ | JSON array of trigger filter objects | | `--recipient` | `string` | ❌ | JSON array of recipient objects | | `--secret` | `string` | ❌ | Webhook secret for the alert | | `--slack-property-keys` | `string` | ❌ | JSON array of event/user property keys to include in Slack alerts | ```bash theme={null} formo alerts update alert_abc123 \ --name "Renamed alert" \ --trigger-type event \ --recipient '[{"type":"webhook","value":["https://example.com/formo-webhook"]}]' ``` *** ## `formo alerts toggle ` Toggle an alert status. Requires `alerts:write` scope on your API key. | Option | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------------------------------------------------------------------------- | | `--status` | `enum` | ✅ | New status: `active` or `inactive`. `paused` is accepted as a deprecated alias for `inactive`. | ```bash theme={null} formo alerts toggle alert_abc123 --status inactive formo alerts toggle alert_abc123 --status active ``` *** ## `formo alerts delete ` Delete an alert. Requires `alerts:write` scope on your API key. ```bash theme={null} formo alerts delete alert_abc123 ``` Deleting an alert is permanent. # Analytics Source: https://docs.formo.so/cli/analytics Run Formo's pre-built analytics pipes (KPIs, funnels, retention, revenue, and top-N breakdowns) directly from the terminal, without writing SQL. The `formo analytics` command exposes Formo's pre-built analytics pipes (the same data that powers the Formo dashboard) as terminal commands. Each pipe is a subcommand: `formo analytics `. Requires `query:read` scope on your API key. ## `formo analytics ` ### Pipes | Pipe | Description | | -------------------- | ---------------------------------------------------------------------------- | | `kpis` | Traffic KPIs: visitors, pageviews, bounce rate, session duration | | `event_timeseries` | Event counts over time | | `funnel` | Conversion funnel across ordered steps | | `flow` | User path / flow analysis between steps | | `frequency` | Engagement frequency distribution | | `lifecycle` | User lifecycle stages (new, returning, power, resurrected, at risk, churned) | | `retention` | Retention cohort analysis | | `revenue_overview` | Revenue overview with optional breakdown | | `revenue_by_metric` | Revenue ranked by a metric column | | `revenue_timeseries` | Revenue over time (requires `address`) | | `volume_by_metric` | Trading volume ranked by a metric column | | `top_chains` | Top chains by activity | | `top_events` | Top events by count | | `top_locations` | Top countries | | `top_pages` | Top pages by traffic | | `top_sources` | Top acquisition sources | | `top_wallets` | Top wallets by activity | ### Options | Option | Type | Required | Description | | ------------- | -------- | -------- | ---------------------------------------------------------------------------------- | | `--date-from` | `string` | ❌ | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) | | `--date-to` | `string` | ❌ | Inclusive end date `YYYY-MM-DD` (default: today) | | `--filters` | `string` | ❌ | JSON array of filter conditions: `[{"field": "...", "op": "...", "value": "..."}]` | | `--params` | `string` | ❌ | JSON object of pipe-specific params merged into the query | `--params` may **not** set `date_from`/`date_to`/`filters`; use the dedicated `--date-from`/`--date-to`/`--filters` flags for those. Object/array values in `--params` are JSON-encoded automatically (e.g. `funnel`'s `steps`). ### `--filters` A JSON array of `{ field, op, value }` filters, e.g. `[{"field":"location","op":"eq","value":"US"}]`. For multi-value matching, use `in` / `nin` with an array value (e.g. `["chrome","firefox"]`); pipe-delimited strings remain supported by the analytics query boundary. This one array carries every predicate. On the user-aggregate pipes (`lifecycle`, `frequency`) it also takes profile metrics, social identity fields, a lifecycle entry (`{"field":"lifecycle","op":"in","value":["New","Power user"]}`), and resource entries using the stable fields `chains.balance`, `apps.balance`, `tokens.balance` and `labels.value` with named qualifiers (`chain_id`, `app_id`, `token_address`, `scope`, `tag_id`). The retired per-family params — `socials`, `chain_filters`, `app_filters`, `token_filters`, `label_filters`, `profile_filters`, `lifecycle_filter` — are rejected with a `400` if passed through `--params`. Forwarding them would silently drop the predicate and broaden the result set, so the API fails loud instead. Send a canonical entry in `--filters`. ### `--params` (pipe-specific) Some pipes take additional, pipe-specific parameters. Pass them as a JSON object via `--params`: | Pipe | Key params | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `kpis` | `group_by`, `include_previous_period` | | `revenue_overview` | `group_by`, `rank_by`, `include_previous_period` | | `funnel` | `steps` (required, a JSON array of `{type,event,name,filters?}`), `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution` | | `flow` | `start_step` (required, a JSON `{type,event,resolved_event,...}`), `end_step`, `global_filters`, `window_seconds`, `max_steps` | | `retention` | `id_type`, `event_type`, `event_name`, `min_users` | | `revenue_timeseries` | `address` (required) | | `revenue_by_metric`, `volume_by_metric`, `top_sources` | `metric_column`, `limit`, `offset` | | `top_chains`, `top_events`, `top_locations`, `top_pages`, `top_wallets` | `limit`, `offset` | | `kpis`, `top_*`, `revenue_*`, `volume_by_metric` | `page_scope` — `page` (default) or `session` | `page_scope` only affects requests that carry a `page` filter. The default scopes metrics to activity on that page; `session` restores the legacy behaviour where metrics include all activity in any session that viewed the page. ### Examples ```bash theme={null} # Traffic KPIs for the last 7 days (default range) formo analytics kpis # KPIs for April 2026, broken down by device formo analytics kpis --date-from 2026-04-01 --date-to 2026-04-30 --params '{"group_by":"device"}' # Conversion funnel across ordered steps formo analytics funnel \ --date-from 2026-04-01 --date-to 2026-04-30 \ --params '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]}],"window_seconds":86400}' # Top 10 wallets by activity last month 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":"eq","value":"US"}]' # Revenue timeseries for a specific wallet formo analytics revenue_timeseries \ --date-from 2026-04-01 --date-to 2026-04-30 \ --params '{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"}' # Pipe results to jq for processing formo analytics kpis --json | jq '.data' ``` The response shape matches the dashboard data: `{ meta, data, rows, statistics }`. # Boards Source: https://docs.formo.so/cli/boards Manage analytics dashboard boards from the terminal with the Formo CLI. Create, list, update, and delete boards that organize your charts and reports. The `formo boards` command group manages dashboard boards for the active project. Boards are containers for charts. ## `formo boards list` List all boards for the project. Requires `boards:read` scope on your API key. ### Options | Option | Type | Required | Description | | -------- | -------- | -------- | ---------------------- | | `--page` | `number` | ❌ | Page number, 1-indexed | | `--size` | `number` | ❌ | Page size | ```bash theme={null} formo boards list --size 25 ``` *** ## `formo boards get ` Get a single board by ID. Requires `boards:read` scope on your API key. ```bash theme={null} formo boards get board_abc123 ``` *** ## `formo boards create` Create a new dashboard board. Requires `boards:write` scope on your API key. ### Options | Option | Type | Required | Description | | --------------- | --------- | -------- | -------------------------------------- | | `--title` | `string` | ✅ | Board title | | `--name` | `string` | ❌ | Deprecated alias for `--title` | | `--description` | `string` | ❌ | Board description | | `--is-public` | `boolean` | ❌ | Whether the board is publicly viewable | ### Examples ```bash theme={null} formo boards create --title "KPI Dashboard" formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking" --is-public false ``` *** ## `formo boards update ` Update an existing board. Provide at least one updatable option. Requires `boards:write` scope on your API key. ### Options | Option | Type | Required | Description | | --------------- | --------- | -------- | -------------------------------------- | | `--title` | `string` | ❌ | New board title | | `--name` | `string` | ❌ | Deprecated alias for `--title` | | `--description` | `string` | ❌ | New board description | | `--is-public` | `boolean` | ❌ | Whether the board is publicly viewable | ```bash theme={null} formo boards update board_abc123 --title "Renamed Board" formo boards update board_abc123 --description "Updated weekly metrics" --is-public true ``` *** ## `formo boards delete ` Delete a board. Requires `boards:write` scope on your API key. ```bash theme={null} formo boards delete board_abc123 ``` Deleting a board is permanent and also removes its charts. # Charts Source: https://docs.formo.so/cli/charts Create, list, query, move, duplicate, reorder, update, and delete charts within dashboard boards using the Formo CLI. The `formo charts` command group manages charts within dashboard boards. Every chart command is scoped to a board with `--board-id`. ## `formo charts list` List charts for a board. Returns lightweight summaries by default; pass `--results` to execute each chart’s query and include full results. Requires `boards:read` scope on your API key. | Option | Type | Required | Description | | ------------ | --------- | -------- | ----------------------------------------------------------------------------------- | | `--board-id` | `string` | ✅ | Board ID to list charts from | | `--results` | `boolean` | ❌ | Execute each chart’s query and include results (slower; hits the analytics backend) | | `--page` | `number` | ❌ | Page number, 1-indexed | | `--size` | `number` | ❌ | Page size | ```bash theme={null} formo charts list --board-id board_abc123 --size 25 ``` *** ## `formo charts meta` List lightweight chart metadata for a board without query results. Requires `boards:read` scope on your API key. ```bash theme={null} formo charts meta --board-id board_abc123 ``` *** ## `formo charts get ` Get a single chart by ID. Requires `boards:read` scope on your API key. ```bash theme={null} formo charts get chart_abc123 --board-id board_abc123 ``` *** ## `formo charts query ` Execute a saved chart query after substituting `{{date_from}}` and `{{date_to}}`. Requires `boards:read` scope and a chart query that uses both date variables. | Option | Type | Required | Description | | ------------- | -------- | -------- | --------------------------------- | | `--board-id` | `string` | ✅ | Board ID the chart belongs to | | `--date-from` | `string` | ✅ | Date variable value, `YYYY-MM-DD` | | `--date-to` | `string` | ✅ | Date variable value, `YYYY-MM-DD` | ```bash theme={null} formo charts query chart_abc123 --board-id board_abc123 --date-from 2026-04-01 --date-to 2026-04-30 ``` *** ## `formo charts create` Create a chart from typed flags or a raw JSON body. Requires `boards:write` scope on your API key. ### Options | Option | Type | Required | Description | | --------------- | -------- | -------- | -------------------------------------------------------------------------------------------------- | | `--board-id` | `string` | ✅ | Board ID to add the chart to | | `--body` | `string` | ❌ | Raw JSON chart body. Typed flags override matching keys. | | `--title` | `string` | ❌ | Chart title | | `--description` | `string` | ❌ | Optional chart description | | `--chart-type` | `enum` | ❌ | `table`, `number`, `funnel`, `bar`, `line`, `area`, `pie`, `stacked`, `user_paths`, or `retention` | | `--query` | `string` | ❌ | SQL query for SQL-backed charts | | `--x-axis` | `string` | ❌ | Column used as the x-axis | | `--y-axis` | `string` | ❌ | Comma-separated or JSON array of y-axis columns | | `--group-by` | `string` | ❌ | Column used to group or stack series | | `--steps` | `string` | ❌ | JSON array of funnel step objects | | `--settings` | `string` | ❌ | JSON chart settings. User Paths require `anchors`; retention requires an `entryFilter` key. | Provide either `--body` or typed chart fields such as `--title`, `--chart-type`, and `--query`. ### Examples ```bash theme={null} # Create a line chart from typed flags formo charts create \ --board-id board_abc123 \ --title "Daily active users" \ --chart-type line \ --query "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date" \ --x-axis date \ --y-axis users # Create from raw JSON formo charts create \ --board-id board_abc123 \ --body '{"title":"Recent events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}' # Create an open-ended User Paths chart; omit --query formo charts create \ --board-id board_abc123 \ --title "Post-connect paths" \ --chart-type user_paths \ --settings '{"anchors":[{"type":"event","event":"connect"}],"maxSteps":5,"nodesPerStep":3}' ``` *** ## `formo charts update ` Update an existing chart. You can pass partial typed fields; the CLI fetches the current chart and sends the full body required by the API. Requires `boards:write` scope on your API key. ```bash theme={null} formo charts update chart_abc123 --board-id board_abc123 --title "Updated chart name" ``` The same typed options as `create` are available. *** ## `formo charts move ` Move a chart to another board. Requires `boards:write` scope on your API key. ```bash theme={null} formo charts move chart_abc123 --board-id source_board --target-board-id target_board ``` *** ## `formo charts duplicate ` Duplicate a chart within its board. The CLI returns the new chart ID. Requires `boards:write` scope on your API key. ```bash theme={null} formo charts duplicate chart_abc123 --board-id board_abc123 ``` *** ## `formo charts reorder` Reorder charts in a board. Requires `boards:write` scope on your API key. ```bash theme={null} formo charts reorder --board-id board_abc123 --chart-ids chart_a,chart_b,chart_c ``` `--chart-ids` may be comma-separated text or a JSON array. *** ## `formo charts delete ` Delete a chart. Requires `boards:write` scope on your API key. ```bash theme={null} formo charts delete chart_abc123 --board-id board_abc123 ``` Deleting a chart is permanent. # Contracts Source: https://docs.formo.so/cli/contracts Register, list, inspect, update, and remove tracked smart contracts from the terminal. Manage ABIs, monitored events, and pipeline inclusion. The `formo contracts` command group manages smart contracts tracked by your project for decoding and event ingestion. ## `formo contracts list` List tracked contracts for the project. Requires `contracts:read` scope on your API key. ### Options | Option | Type | Required | Description | | -------- | -------- | -------- | ---------------------- | | `--page` | `number` | ❌ | Page number, 1-indexed | | `--size` | `number` | ❌ | Page size | ```bash theme={null} formo contracts list --size 25 ``` *** ## `formo contracts get
` Get a tracked contract by chain ID and address. Requires `contracts:read` scope on your API key. ```bash theme={null} formo contracts get 1 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 ``` *** ## `formo contracts create` Register a new smart contract to track. Requires `contracts:write` scope on your API key. ### Options | Option | Type | Required | Description | | ----------------------- | --------- | -------- | ------------------------------------------------------- | | `--address` | `string` | ✅ | Contract address (`0x...`) | | `--chain` | `number` | ✅ | Chain ID, for example `1` for Ethereum | | `--name` | `string` | ✅ | Human-readable contract name | | `--abi` | `string` | ✅ | Contract ABI as a JSON array string | | `--events` | `string` | ✅ | JSON array of ABI event objects to monitor, max 10 | | `--start-block` | `number` | ❌ | Optional start block | | `--include-in-pipeline` | `boolean` | ❌ | Whether to include this contract in the events pipeline | ### Example ```bash theme={null} formo contracts create \ --address 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ --chain 1 \ --name "USDC" \ --abi '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}]}]' \ --events '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}]}]' \ --start-block 6082465 \ --include-in-pipeline true ``` `--abi` is sent to the API as an ABI JSON string. `--events` is sent as an array of ABI event objects. *** ## `formo contracts update
` Update a tracked contract. Requires `contracts:write` scope on your API key. ### Options | Option | Type | Required | Description | | ----------------------- | --------- | -------- | ------------------------------------------------------- | | `--name` | `string` | ✅ | Updated contract name | | `--abi` | `string` | ✅ | Updated ABI as a JSON array string | | `--events` | `string` | ✅ | Updated JSON array of ABI event objects to monitor | | `--start-block` | `number` | ❌ | Optional start block | | `--include-in-pipeline` | `boolean` | ❌ | Whether to include this contract in the events pipeline | ```bash theme={null} formo contracts update 1 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ --name "USD Coin" \ --abi '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]' \ --events '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]' ``` *** ## `formo contracts delete
` Remove a tracked contract. Requires `contracts:write` scope on your API key. ```bash theme={null} formo contracts delete 1 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 ``` Removing a tracked contract is permanent. Historical events already captured are not deleted, but new events will no longer be tracked. # Events Source: https://docs.formo.so/cli/events Send raw analytics events to Formo from the CLI using a project SDK write key. The `formo events` command group sends raw analytics events to the Formo events API. This uses a project SDK write key, not a workspace API key. ## Authentication Pass a write key with `--write-key`, or set it once in your shell: ```bash theme={null} export FORMO_WRITE_KEY=formo_write_key_xxx ``` For local development or proxying, override the events API base URL: ```bash theme={null} export FORMO_EVENTS_BASE_URL=http://localhost:3002 ``` *** ## `formo events ingest` Send one or more raw events. ### Options | Option | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------------------------- | | `--event` | `string` | ❌ | Single event as a JSON object; wrapped in an array before sending | | `--events` | `string` | ❌ | JSON array of event objects to send | | `--write-key` | `string` | ❌ | Project SDK write key. Defaults to `FORMO_WRITE_KEY`. | Provide either `--event` or `--events`. ### Examples ```bash theme={null} # Send one event with FORMO_WRITE_KEY formo events ingest \ --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}' # Send a batch formo events ingest \ --events '[{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_1","event":"Signed Up","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}]' # Pass the write key inline formo events ingest \ --write-key formo_write_key_xxx \ --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}' ``` ### Common Event Fields | Field | Description | | -------------------- | -------------------------------------------------- | | `type` | Event type, such as `track`, `identify`, or `page` | | `channel` | Source channel, for example `cli` | | `version` | Event schema version | | `anonymous_id` | Anonymous visitor or session identifier | | `address` | Optional wallet address | | `event` | Event name | | `context` | Event context object | | `properties` | Event properties object | | `original_timestamp` | When the event happened | | `sent_at` | When the event was sent | | `message_id` | Client-generated event identifier | # Import Source: https://docs.formo.so/cli/import Bulk import wallet addresses into your Formo project from the CLI. Imported wallets are enriched with onchain data and may include first-party profile properties. The `formo import` command group bulk imports wallet addresses into your project. Requires `profiles:write` scope on your API key. This endpoint is available on **Scale** and **Enterprise** plans. ## `formo import wallets` Bulk import wallet addresses into the project. ### Options | Option | Type | Required | Description | | ------------- | -------- | -------- | ---------------------------------------------------------------------------------- | | `--addresses` | `string` | ❌ | JSON array of wallet address strings to import | | `--rows` | `string` | ❌ | JSON array of `{address, properties?}` objects for imports with profile properties | | `--write-key` | `string` | ❌ | Deprecated and ignored; import now uses the project write key server-side | Provide either `--addresses` or `--rows`. ### Examples ```bash theme={null} # Import two wallet addresses formo import wallets \ --addresses '["0xabc123...","0xdef456..."]' # Import wallets with profile properties formo import wallets \ --rows '[{"address":"0xabc123...","properties":{"display_name":"Alice"}}]' # Import a list of addresses from a file formo import wallets \ --addresses "$(cat wallets.json)" ``` ### Tips * `--addresses` must be a JSON array of wallet address strings. * `--rows` must be a JSON array of objects with a non-empty `address`. * Use this command to backfill historical wallet addresses into your Formo project. # CLI overview Source: https://docs.formo.so/cli/overview Query wallet profiles, run SQL analytics, manage dashboards, alerts, contracts, segments, and more - directly from your terminal or via AI agents. The Formo CLI (`@formo/cli`) gives you full access to the Formo API from the command line. Use it interactively in your terminal, in shell scripts, or as a tool for AI agents. Fetch, search, and update wallet profiles and labels Run SQL queries on your data Run pre-built analytics pipes (KPIs, funnels, retention, revenue) Send raw analytics events with a project SDK write key Create and manage dashboard boards Create and manage charts within custom dashboards Configure project alerts Register and manage tracked smart contracts Create and manage user segments Import wallet addresses ## Command Coverage The CLI covers the main Formo API resources: | Command group | What you can do | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `formo profiles` | Get and search profiles, tune lifecycle thresholds, update properties, batch update properties with `profiles properties batch`, and manage labels | | `formo query` | Run SQL against your Formo analytics data | | `formo analytics` | Run pre-built KPI, funnel, retention, revenue, and top-N analytics pipes | | `formo events` | Send raw events with `events ingest` using a project SDK write key | | `formo boards` | List, get, create, update, and delete dashboard boards | | `formo charts` | List, get, create, update, query, move, duplicate, reorder, and delete charts | | `formo alerts` | List, get, create, update, toggle, and delete project alerts | | `formo contracts` | List, get, create, update, and delete tracked contracts (pipeline inclusion via `update --include-in-pipeline`) | | `formo segments` | List, create, and delete user segments | | `formo import` | Bulk import wallet addresses, optionally with profile properties | ## Installation Install the CLI globally via npm: ```bash theme={null} npm install -g @formo/cli ``` Or run without installing using `npx`: ```bash theme={null} npx @formo/cli ``` ## Quick start ```bash theme={null} # 1. Authenticate with your API key formo login formo_abc123 # 2. Get a wallet profile formo profiles get vitalik.eth # 3. Run a SQL query formo query run "SELECT count(*) FROM events" # 4. List your dashboard boards formo boards list # 5. Send a raw event with a project SDK write key FORMO_WRITE_KEY=formo_write_key_xxx formo events ingest \ --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}' ``` ## Authentication The CLI supports two authentication methods. The environment variable takes precedence over the saved config file. Save your API key to the local config file (`~/.config/formo/config.json`): ```bash theme={null} formo login formo_abc123 ``` The CLI validates your key against the API, saves the key along with your workspace and project info, and confirms that you're authenticated. Set the `FORMO_API_KEY` environment variable: ```bash theme={null} export FORMO_API_KEY=formo_abc123 ``` This takes precedence over a saved config file and is useful for CI/CD pipelines and scripts. Get your API key from **Settings → API** in the [Formo dashboard](https://app.formo.so). ## Global commands ### `formo login` Authenticate with your Formo API key. Validates the key against the API and saves it locally. | Argument | Type | Required | Description | | -------- | -------- | -------- | ------------------------------------------------------ | | `apiKey` | `string` | ❌ | Your `formo_` API key (shows a setup guide if omitted) | ```bash theme={null} formo login formo_abc123 ``` *** ### `formo logout` Remove saved API key and clear authentication from `~/.config/formo/config.json`. ```bash theme={null} formo logout ``` If the `FORMO_API_KEY` environment variable is set, `logout` will warn you to also run `unset FORMO_API_KEY`. *** ### `formo status` Show the current authentication and CLI status, including the active API key source, workspace, and project ID. ```bash theme={null} formo status ``` **Output includes:** * Whether you are authenticated * API key source (`config file` or `FORMO_API_KEY env var`) * Workspace name * Project ID ## Configuration The CLI stores configuration at `~/.config/formo/config.json` with restricted permissions (`600`). The config file contains: | Field | Description | | ----------- | ------------------------------------------- | | `apiKey` | Your Formo API key | | `workspace` | Workspace name (set automatically on login) | | `projectId` | Project ID (set automatically on login) | ### Environment Overrides | Environment variable | Description | | ----------------------- | ------------------------------------------------------------------ | | `FORMO_API_KEY` | Workspace API key. Takes precedence over saved config. | | `FORMO_API_BASE_URL` | Override the API base URL for local development or proxies. | | `FORMO_WRITE_KEY` | Project SDK write key for `formo events ingest`. | | `FORMO_EVENTS_BASE_URL` | Override the events API base URL for local development or proxies. | ## Output format By default, commands print compact, human-readable TOON output. Pass one of the standard output flags to change the format: | Flag | Description | | ---------------------------------------- | --------------------------------------------------------- | | `--format ` | Output format (default: `toon`) | | `--json` | Shorthand for `--format json` | | `--verbose` | Include the full response envelope (`ok`, `data`, `meta`) | | `--filter-output ` | Filter output by key paths (e.g. `data,meta.duration`) | Status messages (login confirmation, errors, and similar) are written to stderr in interactive terminals, so they never pollute piped output. ```bash theme={null} # Default TOON output formo profiles get vitalik.eth # JSON output, piped to jq formo profiles get vitalik.eth --json | jq '.net_worth_usd' ``` ## AI agent usage The CLI is designed to work as a tool for AI agents. It includes built-in suggestions for natural language queries: * "get the profile for wallet 0xabc" * "search profiles with net worth > 10000" * "run a SQL query on my analytics data" * "show traffic KPIs for the last 7 days" * "get the conversion funnel for the last month" * "list the top wallets by activity" * "search profiles ordered by last\_onchain desc" * "list all project alerts" * "create an alert for high-value transactions" * "list charts in a board" * "create a line chart in a board" * "move or duplicate a dashboard chart" * "list all tracked contracts" * "register a new smart contract" * "list user segments" * "import wallet addresses" * "batch update profile properties with profiles properties batch" * "batch upsert labels for wallets" * "send raw analytics events" ### Agent Integrations The CLI is built with `incur`, so agents can discover and call commands through structured metadata: ```bash theme={null} # Print an LLM-readable command manifest formo --llms # Register the CLI as an MCP server formo mcp add # Start an MCP stdio server directly formo --mcp # Sync Formo CLI skill files for compatible agents formo skills add ``` # Profiles Source: https://docs.formo.so/cli/profiles Look up wallet profiles, search your user base, update identity properties, and manage labels using the Formo CLI profiles commands. The `formo profiles` command group lets you look up individual wallet profiles, search your user base, merge-update identity properties, and manage labels. ## Lifecycle Threshold Options `profiles get` and `profiles search` accept optional lifecycle threshold overrides: | Option | Type | Description | | --------------------------------------- | -------- | ------------------------------------------------------ | | `--new-window-days` | `number` | Override lifecycle new-user window in days | | `--churn-window-days` | `number` | Override lifecycle churn window in days | | `--power-user-min-active-days` | `number` | Override lifecycle power-user minimum active days | | `--power-user-window-days` | `number` | Override lifecycle power-user window in days | | `--resurrected-gap-days` | `number` | Override lifecycle resurrected gap in days | | `--at-risk-min-days-inactive` | `number` | Override lifecycle at-risk minimum inactive days | | `--at-risk-prior-active-days-threshold` | `number` | Override lifecycle at-risk prior active days threshold | *** ## `formo profiles get
` Fetch a single wallet profile by address or ENS name. Requires `profiles:read` scope on your API key. ### Options | Option | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------------------------------------------ | | `--expand` | `string` | ❌ | Comma-separated fields to expand: `apps`, `chains`, `tokens`, `labels` | | `--timestamp` | `string` | ❌ | ISO-8601 timestamp; return the closest stored wallet-enrichment snapshot | The lifecycle threshold options listed above are also supported. ### Examples ```bash theme={null} formo profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 formo profiles get vitalik.eth --expand labels,chains formo profiles get vitalik.eth --expand apps,chains,tokens,labels --churn-window-days 30 formo profiles get vitalik.eth --timestamp 2025-06-21T10:03:00Z ``` *** ## `formo profiles search` Search wallet profiles with optional filters, sorting, free-text search, and pagination. Requires `profiles:read` scope on your API key. ### Options | Option | Type | Required | Description | | ------------- | -------- | -------- | -------------------------------------------------------------------------------------------------- | | `--address` | `string` | ❌ | Filter by wallet address | | `--search` | `string` | ❌ | Free-text search across address and identity fields | | `--timestamp` | `string` | ❌ | ISO-8601 timestamp; requires `--address` and returns the closest stored wallet-enrichment snapshot | | `--page` | `number` | ❌ | Page number, 1-indexed | | `--size` | `number` | ❌ | Page size | | `--order-by` | `enum` | ❌ | Field to sort by | | `--order-dir` | `enum` | ❌ | Sort direction: `asc` or `desc` | | `--expand` | `string` | ❌ | Comma-separated fields to expand | | `--filters` | `string` | ❌ | JSON array of canonical `{field, op, value}` filter objects | | `--logic` | `enum` | ❌ | Combine filters with `and` or `or` | The lifecycle threshold options listed above are also supported. ### `--order-by` Values `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` ### Examples ```bash theme={null} # List first 10 profiles formo profiles search --size 10 # Search text fields formo profiles search --search vitalik --size 5 # Return the closest stored snapshot for one wallet formo profiles search \ --address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --timestamp 2025-06-21T10:03:00Z # Top 5 profiles by net worth formo profiles search --order-by net_worth_usd --order-dir desc --size 5 # Search with a typed filter condition formo profiles search \ --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' \ --size 20 # Search profiles matching either condition formo profiles search \ --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' \ --logic or \ --size 20 # Filter by onchain balance on Ethereum formo profiles search \ --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' \ --size 20 ``` The response is a paginated envelope: `{ data, total, page, size, has_more }`. When `--timestamp` is present, wallet-enrichment fields come from the stored base snapshot closest to that instant. If two snapshots are equally close, the later snapshot is returned. Expanded chains, apps, and tokens come from the selected profiling batch. Project engagement fields, project-defined identity overrides, and labels remain current. Omitting `--timestamp` returns the latest profile. ### Filters `--filters` accepts a JSON array of canonical filter objects. Resource filters use a stable `field` plus named qualifier properties (`chain_id`, `app_id`, `token_address`, `scope`, `tag_id`): ```json theme={null} [ { "field": "users.net_worth_usd", "op": "gt", "value": 10000 }, { "field": "chains.balance", "op": "gte", "value": 1000, "chain_id": "1" }, { "field": "apps.balance", "op": "gt", "value": 500, "app_id": "uniswap-v3" }, { "field": "tokens.balance", "op": "gt", "value": 0, "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "scope": "any" }, { "field": "labels.value", "op": "eq", "value": "tier-1", "tag_id": "vip" } ] ``` `field` must be a stable typed path: `users.{attribute}` for user/profile/social fields, or one of `chains.balance`, `apps.balance`, `tokens.balance`, `labels.value` for resource filters. Bare names like `net_worth_usd` are rejected because the API ignores them, and the retired identifier-in-path spellings (`chains.1.balance`, `apps.uniswap-v3.balance`, `tokens.0x….balance`, `labels.vip`) are rejected with a `400`; put identifiers in the qualifier properties instead. ### Filter Operators `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `notEmpty`, `isEmpty` The long-form spellings (`equals`, `notEquals`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `notIn`, `includes`) are retired. The API rejects them with a `400` naming the token. Use only the canonical operators above. The vocabulary is shared, but each field implements a subset — an unsupported pairing is a `400`: | Field class | Supported operators | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `chains.balance`, `apps.balance`, `tokens.balance` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` — the value must be a JSON number, not a numeric string | | `labels.value` | comparison operators plus `contains` (case-insensitive) | | Numeric profile metrics (`users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`) | comparison operators | | Routable string attributes (`users.device`, `users.os`, `users.referrer`, `users.utm_*`, `users.click_id`, and the `first_*`/`last_*` attribution variants) | the full vocabulary; `contains`, `startsWith` and `endsWith` match case-sensitively | | Social fields (`users.twitter`, `users.email`, `users.farcaster`, …) | `contains` (case-insensitive) and `notEmpty`; `startsWith`, `endsWith` and `isEmpty` are rejected | | `users.paid_source` (and its `first_`/`last_` variants) | `eq`, `neq`, `in`, `nin`, `notEmpty`, `isEmpty` — it is a fixed ad-network enum | | `users.lifecycle` | `eq` (one stage) and `in` (a list of stages) | `notEmpty` and `isEmpty` are value-less existence checks; the `value` is ignored. *** ## `formo profiles update
` Merge-update identity properties on a single wallet profile. Provide `--properties`, `--unset`, or both. Requires `profiles:write` scope on your API key. ### Options | Option | Type | Required | Description | | -------------- | -------- | -------------- | ------------------------------------------------------------------------------------------------------------- | | `--properties` | `string` | One of the two | JSON object of properties to merge; a `null` value deletes (unsets) that property | | `--unset` | `string` | One of the two | Comma-separated property keys to delete, e.g. `email,twitter` (shorthand for `null` values in `--properties`) | Allowed property keys: `user_id`, `display_name`, `email`, `farcaster`, `discord`, `twitter`, `telegram`, `instagram`, `website`, `github`, `linkedin`, `facebook`, `tiktok`, `youtube`, `reddit`, `avatar`, `description`, `location`, `ens`, `lens`, `basenames`, `linea` ```bash theme={null} formo profiles update 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --properties '{"display_name":"Vitalik","twitter":"VitalikButerin"}' ``` Delete properties by key, or mix set and unset in one call: ```bash theme={null} formo profiles update 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --unset email,twitter formo profiles update 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --properties '{"display_name":"alice.eth"}' --unset email ``` A deleted property reads as `null` everywhere, including over any globally-enriched fallback value, until a new value is set. `user_id` cannot be unset (it participates in identity stitching). *** ## `formo profiles properties batch` Batch update first-party profile properties for up to 100 wallets. Requires `profiles:write` scope on your API key. ### Options | Option | Type | Required | Description | | -------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | | `--rows` | `string` | ✅ | JSON array of flat `{address, ...properties}` objects; a `null` value deletes (unsets) that property (`user_id` cannot be unset) | ```bash theme={null} formo profiles properties batch \ --rows '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","display_name":"alice.eth","email":"alice@example.com"}]' ``` Delete properties in a batch with `null` values: ```bash theme={null} formo profiles properties batch \ --rows '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","email":null}]' ``` ENS names are not resolved in batch requests. Use wallet addresses in each `address` field. *** ## `formo profiles labels create
` Upsert one or more labels on a wallet profile. Provide either a single label with `--tag-id` or a wallet-local batch with `--labels`. Requires `profiles:write` scope on your API key. ### Options | Option | Type | Required | Description | | -------------- | --------- | -------- | -------------------------------------------------------- | | `--tag-id` | `string` | ❌ | Label identifier, required unless `--labels` is provided | | `--value` | `string` | ❌ | Optional label value | | `--chain-id` | `string` | ❌ | Optional chain identifier the label applies to | | `--timestamp` | `string` | ❌ | Optional historical ISO-8601 timestamp for the label row | | `--is-deleted` | `boolean` | ❌ | Backfill a historical label removal tombstone | | `--labels` | `string` | ❌ | JSON array of `UserLabelInput` objects for this wallet | ### Examples ```bash theme={null} formo profiles labels create 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --tag-id vip formo profiles labels create 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --tag-id tier --value gold --chain-id 1 formo profiles labels create 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --tag-id tier --timestamp 2024-03-15T00:00:00.000Z --is-deleted formo profiles labels create 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' ``` *** ## `formo profiles labels batch` Batch upsert labels across up to 100 wallets. Requires `profiles:write` scope on your API key. ### Options | Option | Type | Required | Description | | ---------- | -------- | -------- | -------------------------------------------------------------------------------------- | | `--labels` | `string` | ✅ | JSON array of `{address, tag_id, value?, chain_id?, timestamp?, _is_deleted?}` objects | ```bash theme={null} formo profiles labels batch \ --labels '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","tag_id":"vip","value":"tier-1"}]' ``` ENS names are not resolved in batch requests. Use wallet addresses in each `address` field. *** ## `formo profiles labels delete
` Delete a label from a wallet profile. Pass `--chain-id` to scope deletion to a chain-specific label. Requires `profiles:write` scope on your API key. ### Options | Option | Type | Required | Description | | ------------ | -------- | -------- | ----------------------------------------------- | | `--tag-id` | `string` | ✅ | Label identifier to delete | | `--chain-id` | `string` | ❌ | Optional chain identifier to scope the deletion | ```bash theme={null} formo profiles labels delete 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --tag-id vip formo profiles labels delete 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \ --tag-id tier --chain-id 1 ``` # Query Source: https://docs.formo.so/cli/query Run ClickHouse SQL queries against your Formo analytics data warehouse directly from the terminal, with results in TOON, JSON, or other output formats. The `formo query run` command lets you execute SQL queries against your Formo analytics data warehouse directly from the terminal. ## `formo query run ` Run a SQL query against your Formo analytics data. Requires `query:read` scope on your API key. ### Arguments | Argument | Type | Required | Description | | -------- | -------- | -------- | --------------------------- | | `sql` | `string` | ✅ | SQL query string to execute | ### Examples ```bash theme={null} # Count all events formo query run "SELECT count(*) FROM events" # Top 10 wallets by net worth formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10" # Daily active users over the last 7 days formo query run "SELECT DATE(timestamp) as day, COUNT(DISTINCT address) as dau FROM events WHERE timestamp > now() - INTERVAL 7 DAY GROUP BY day ORDER BY day" # Pipe results to jq for processing formo query run "SELECT count(*) as total FROM events" --json | jq '.data' ``` ### Tips * SQL queries execute against your project's analytics data warehouse. * Wrap your query in double quotes to prevent shell interpretation. * Use single quotes inside your SQL for string literals. * Pass `--json` and pipe the output to `jq` for processing in scripts (default output is TOON). # Segments Source: https://docs.formo.so/cli/segments Manage audience segments from the terminal with the Formo CLI. Create segments with filter rules, list existing segments, and delete segments by ID. The `formo segments` command group lets you manage user segments. Segments are saved audience filters that you can use throughout the Formo platform. ## `formo segments list` List all user segments for the project. Requires `segments:read` scope on your API key. ### Options | Option | Type | Required | Description | | -------- | -------- | -------- | ---------------------- | | `--page` | `number` | ❌ | Page number, 1-indexed | | `--size` | `number` | ❌ | Page size | ```bash theme={null} formo segments list --size 25 ``` *** ## `formo segments create` Create a new user segment. Requires `segments:write` scope on your API key. ### Options | Option | Type | Required | Description | | ----------- | -------- | -------- | ----------------------------------------------------------- | | `--title` | `string` | ✅ | Segment title | | `--filters` | `string` | ✅ | JSON array of canonical `{field, op, value}` filter objects | ### Examples ```bash theme={null} # Create a high-value segment formo segments create \ --title "Whales" \ --filters '[{"field":"net_worth_usd","op":"gt","value":100000}]' # Create a power-user segment formo segments create \ --title "Power Users" \ --filters '[{"field":"tx_count","op":"gt","value":100},{"field":"num_sessions","op":"gt","value":10}]' ``` *** ## `formo segments delete` Delete a user segment. Requires `segments:write` scope on your API key. ### Arguments | Argument | Type | Required | Description | | ----------- | -------- | -------- | -------------------- | | `segmentId` | `string` | ✅ | Segment ID to delete | ```bash theme={null} formo segments delete seg_abc123 ``` Deleting a segment is permanent. # How attribution works Source: https://docs.formo.so/data/attribution Learn why onchain attribution matters for sustainable growth and how Formo attributes conversions from first click to wallet transaction. ### Overview User acquisition is one of the biggest problems in crypto. For an app to succeed, it must have **sustainable unit economics** when it comes to user acquisition. The lifetime revenue of a user (LTV) should be greater than the cost to acquire them (CAC) at a healthy multiple. Formo Without accurate attribution, you are lost in the dark forest. Attribution helps answer: * Where did users come from? * What meaningful activity did users perform on my app? * How well are users monetizing? How much revenue did I make? (ARPU, LTV) * How long are users sticking around? (Retention, Churn) * Is my ROI for an acquisition channel positive? (LTV > CAC) ### Example Attribution in web3 is *complex*. Consider the following example user journey for a DEX called FooSwap with many touchpoints: * A user sees a tweet thread about an app on X (*"referrer"*) clicks on a referral link * The user visits the app's website (fooswap.com) * The user visits the app (app.fooswap.com) * The user connects their wallet on the app * The user signs a token approval message * The user starts a swap transaction but the wallet has insufficient gas * The user abandons their transaction (*"dropoff"*) * The user revisits the app from another channel on Farcaster (*"referrer"*) * The user completes a swap transaction (*"conversion"*) emitting an onchain event As you've seen in the above example, not everything you care about is onchain. In this example, two touchpoints contribute to the successful conversion: X and Farcaster. Using an [attribution model](/data/attribution#attribution-models), we can determine which touchpoint to credit for the conversion: * Using the first-touch model, the complete conversion is attributed to X and the referral * Using the last-touch model, the complete conversion is attributed to Farcaster ### How attribution works in Formo To understand the full user journey, **we must navigate two different worlds: offchain and onchain.** It's imperative to trace the event sequence from initial engagement offchain to conversion onchain. Here's a high-level overview of how it works: ```mermaid theme={null} sequenceDiagram participant User participant Website participant App participant Wallet participant Chain participant Formo note over User,App: Detect timezone, UTM, referrer, referral activate Formo %% Session start User->>App: visit (via links / ads / campaigns) Website->>Formo: record session, wallet events, and offchain context note over Website,Wallet: Detect wallet address, rdns provider, identity note over Wallet,Formo: Index transactions, wallets, contracts User->>Wallet: connect wallet Formo->>Wallet: index wallet activate Wallet Wallet-->>Formo: record wallet metadata & activity deactivate Wallet Formo->>Formo: identity resolution User->>Wallet: sign transactions App->>Chain: contract calls (e.g. swap, mint, stake) Formo->>Chain: index contract events activate Chain Chain-->>Formo: record tx receipt and logs deactivate Chain Formo->>Formo: match txs with context Formo->>Formo: calculate attribution Formo-->>App: unified identity and attribution for apps Formo-->>User: wallet-aware personalized experiences for users deactivate Formo ``` Formo solves two core functions: attribution and identity. * **Attribution** refers to [event-based analytics](/data/events/overview) about where, how, and when users interact with links, sites, and apps (touchpoint trackers, UTM & referral parameters, events, ingestion). * **Identity** refers to the resolution of user activity into a single [unified profile](/features/wallet-intelligence/wallet-profiles) (sessions, demographics, wallets, onchain data). Spend less time building analytics and leave the complex data engineering to us. ### Ad click IDs Paid acquisition is attributed with ad-platform click IDs (`gclid`, `fbclid`, `twclid`, and friends): the identifier each ad network appends to your landing page URL. Formo captures them automatically, classifies the acquiring network (Google Ads, Meta Ads, X Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, Microsoft Ads), and uses them for channel classification even when the referrer is missing (common with in-app browsers). See [Ads](/features/product-analytics/ads) for the supported platforms and every dashboard surface they power. ### Attribution models Formo helps you understand the impact of touchpoints in each user journey using single-touch attribution, crediting either the first or the last touchpoint within the lookback window. | Model | Description | | :---------- | :------------------------------------------------------------------------------- | | First Touch | Gives 100% credit to the first touchpoint within the attribution lookback window | | Last Touch | Gives 100% credit to the last touchpoint within the attribution lookback window | > Looking for more attribution models? [Let us know](mailto:support@formo.so). ### Glossary | Term | Definition | | :-------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | User journey | Consists of touchpoints and the conversion event. It is possible for a conversion event to have no corresponding touchpoints (eg. utm parameters). In this case we consider it a 'direct' conversion | | Conversion | The primary event you're interested in attributing. Typically a volume or revenue generating conversion event such as "Mint" or "Swap" or "Stake". | | Touchpoint | Actions (events) a user's taken or exposed to along the journey before doing the conversion event. \[Eg. does event A → B → C → D (conversion event) in a 7 day period; For a lookback window of 7 days, A, B, C are all considered touchpoints] | | Lookback window | The time window where a user's events with this attribution property are counted towards the calculation. The window ends when the conversion metric happens. | # BI integration Source: https://docs.formo.so/data/bi Connect Metabase, Grafana, Tableau, and other BI tools to your Formo data warehouse using the HTTP interface for custom SQL queries and dashboards. Formo is compatible with BI tools through our HTTP interface, enabling you to connect popular BI tools, SQL clients, and data visualization platforms directly to Formo. This interface provides a standardized way to query your data using SQL, making it easy to integrate Formo with your existing analytics workflow and third-party tools. ## Configuration To connect to Formo, use these standard connection parameters: ### Connection settings * **Protocol**: HTTPS * **Host**: `clickhouse.eu-central-1.aws.tinybird.co` * **Port**: `443` * **SSL/TLS**: Required (enabled) ### Authentication settings * **Database name**: `formo` * **Username**: `formo` * **Password**: `` (Your Formo BI Read Token) Find your **BI Read key** in the project settings page under 'Credentials'. BI read keys expire in one week, but you can get a fresh key on the project settings page. ## Compatible tools Formo works with various BI tools and SQL clients: * Grafana * Hex * Metabase * Superset * Power BI * Tableau Any tool that supports ClickHouse® HTTP interface can potentially work with Formo. ## Limitations The BI interface to Formo is read-only. You can use it to query, visualize, and analyze data from your Formo Data Sources, but you cannot modify data through this connection. You cannot perform `INSERT`, `UPDATE`, `DELETE`, or any DDL operations (`CREATE`, `ALTER`, `DROP`). ## Troubleshooting **Authentication failures** Error: Authentication failed * Check your BI Read Token is valid and not expired * Ensure the token has read permissions for the workspace * Verify the token is entered in the password field **SSL/TLS errors** Error: SSL connection error * Ensure SSL/TLS is enabled in your client * Use port 443 with HTTPS protocol * Check that your client supports TLS 1.2 or higher **Query timeouts** Error: Query timeout * Add appropriate WHERE clauses to filter data * Use LIMIT to reduce result set size * Consider querying materialized views instead of raw data # Data catalog Source: https://docs.formo.so/data/catalog Full data catalog for the Formo warehouse covering events, users, sessions, revenue, and wallet profiles queryable with ClickHouse SQL. Formo’s data warehouse gives you direct SQL access to events, users, sessions, revenue, and wallet profiles across chains. * **Events**, **Users**, **Sessions**, **Revenue**, and **Sources** are first-party data scoped to your project. * **Wallet Profiles** are based on public onchain data (net worth, apps, tokens, chains, social profiles, etc.) and are globally accessible across all projects. Data catalog **Key details:** * **SQL dialect**: [ClickHouse SQL](https://clickhouse.com/docs/en/sql-reference) (not standard SQL) * **Default row limit**: 100 rows (add your own `LIMIT` clause to override) * **Project scoping**: All queries are automatically filtered to your project - you never need to add `WHERE project_id = ...` * **Aggregate tables**: Several tables use ClickHouse `AggregateFunction` types that require `-Merge` suffix functions (see [Working with Aggregate Tables](#working-with-aggregate-tables)) * *Functions and variables*: Formo supports dynamic date [variables](#variables) and other built-in [functions](#functions). Query data with the [Query API](/api/overview#query-api) and [Profiles API](/api/overview#profiles-api), explore with the [BI integration](/data/bi), export with [Data sync](/data/data-sync), or write SQL directly in the [Explorer](/features/product-analytics/explore). ## Data flow ``` raw_events (landing zone) │ ▼ events (deduplicated + parsed) │ ├──▶ users (wallet-identified profiles) ├──▶ anonymous_users (pre-wallet profiles) ├──▶ sessions (session-level engagement) ├──▶ sources (traffic attribution) ├──▶ revenue (financial metrics) └──▶ identities (anonymous ↔ wallet mapping) wallet_profiles_events (blockchain data) │ ├──▶ wallet_profiles_mv (global wallet profiles) ├──▶ wallet_profiles_chains_mv (per-chain data) ├──▶ wallet_profiles_chains_tokens_mv (token holdings) └──▶ wallet_profiles_chains_apps_mv (DeFi app usage) ``` ## Tables | Table | Description | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | [`events`](#events) | All user interaction events (page views, wallet events, custom events, contract events) | | [`users`](#users) | Aggregated wallet user profiles identified by `address`, with attribution and engagement metrics | | [`anonymous_users`](#anonymous_users) | Profiles for users who haven't connected a wallet yet, identified by `anonymous_id` | | [`sessions`](#sessions) | Aggregated user session data with engagement metrics and attribution | | [`sources`](#sources) | Daily aggregated traffic source metrics and attribution data | | [`revenue`](#revenue) | Financial metrics and revenue with full attribution context | | [`identities`](#identities) | Identity graph linking anonymous user IDs to wallet addresses and user IDs | | [`user_profiles_mv`](#user_profiles_mv) | First-party user profile traits captured from `identify` events | | [`wallet_profiles_mv`](#wallet_profiles_mv) | Comprehensive global wallet profile data aggregated across all chains | | [`wallet_profiles_chains_mv`](#wallet_profiles_chains_mv) | Wallet profile data broken down by individual blockchain networks | | [`wallet_profiles_chains_tokens_mv`](#wallet_profiles_chains_tokens_mv) | Wallet token holdings data broken down by blockchain | | [`wallet_profiles_chains_apps_mv`](#wallet_profiles_chains_apps_mv) | Wallet DeFi application usage data broken down by blockchain | | [`wallet_profiles_events`](#wallet_profiles_events) | Timestamped wallet profiling events from external offchain and onchain analysis | | [`wallet_profiles_labels`](#wallet_profiles_labels) | Global wallet labels and tags supplied by Formo's wallet profiler | | [`user_labels`](#user_labels) | Project-scoped labels and tags you assign to wallet addresses | ### events The core event log. Contains all user interaction events - page views, custom events, wallet connects, and onchain contract events (`decoded_log`). **Use cases:** Event-level analysis, user journey tracking, custom event queries, debugging | Column | Type | Description | | ----------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session_id` | String | User session identifier | | `channel` | String | Event channel | | `type` | String | Event category: `page`, `track`, `identify`, `detect`, `connect`, `disconnect`, `chain`, `signature`, `transaction`, `decoded_log` | | `anonymous_id` | String | Auto-generated anonymous user ID | | `user_id` | String | Optional user identifier | | `address` | String | Wallet address (empty if not connected) | | `event` | String | Specific event name for `track` events | | `context` | String | JSON metadata: user\_agent, page info, referrer. See [contextual fields](/data/events/common#contextual-fields) | | `properties` | String | JSON with event-specific custom properties. See [event spec](/data/events/overview) | | `version` | String | SDK version | | `timestamp` | DateTime | Event timestamp | | `message_id` | String | Unique event ID for deduplication | | `origin` | String | Domain (without www) | | `locale` | String | User locale | | `location` | String | Country code (e.g., `US`, `DE`) | | `timezone` | String | User timezone | | `page_path` | String | URL path | | `page_title` | String | Page title | | `page_url` | String | Full page URL | | `page_query` | String | URL query string | | `page_hash` | String | URL hash fragment | | `library_name` | String | SDK library name | | `library_version` | String | SDK library version | | `referrer_url` | String | Full referrer URL | | `referrer` | String | Referrer domain (without www) | | `ref` | String | Ref parameter | | `utm_source` | String | UTM source | | `utm_medium` | String | UTM medium | | `utm_campaign` | String | UTM campaign | | `utm_term` | String | UTM term | | `utm_content` | String | UTM content | | `gclid` | String | Google Ads click ID | | `gad_source` | String | Google Ads source parameter | | `fbclid` | String | Meta (Facebook) click ID | | `msclkid` | String | Microsoft Ads click ID | | `ttclid` | String | TikTok Ads click ID | | `twclid` | String | X (Twitter) Ads click ID | | `li_fat_id` | String | LinkedIn Ads click ID | | `rdt_cid` | String | Reddit Ads click ID | | `builder_codes` | String | Builder/referral codes | | `user_agent` | String | Raw user agent string | | `device` | String | Device type: `desktop`, `mobile-ios`, `mobile-android`, `tablet`, `bot` | | `browser` | String | Browser: `chrome`, `safari`, `firefox`, or Web3 wallets | | `os` | String | OS: `windows`, `ios`, `android`, `macos`, `linux` | | `volume` | Float32 | Transaction volume extracted from `properties.volume` at ingest. Values above 100M are zeroed as anomalies. Always query this flat column instead of `JSONExtractFloat(properties, 'volume')` | | `revenue` | Float32 | Revenue extracted from `properties.revenue` at ingest. Values above 100M are zeroed as anomalies. Always query this flat column instead of `JSONExtractFloat(properties, 'revenue')` | | `points` | Float32 | Points extracted from `properties.points` at ingest. Values above 100M are zeroed as anomalies. Always query this flat column instead of `JSONExtractFloat(properties, 'points')` | | `channel_type` | LowCardinality(String) | Marketing channel type derived from attribution data | **Example:** ```sql theme={null} SELECT * FROM events ORDER BY timestamp DESC LIMIT 10 ``` ### users Aggregated wallet user profiles with attribution, engagement, and revenue metrics. Each row represents one wallet address. **Use cases:** User segmentation, attribution analysis, lifecycle tracking, wallet analytics This is an **aggregate table**. Most columns require `-Merge` functions. Always `GROUP BY address`. Never use `SELECT *`. See [Working with Aggregate Tables](#working-with-aggregate-tables). | Column | Type | Query Function | | --------------------- | ------------------------------------------------ | ------------------------------------------------------- | | `address` | String | Direct access | | `first_seen` | SimpleAggregateFunction(min, DateTime) | `min(first_seen)` | | `last_seen` | SimpleAggregateFunction(max, DateTime) | `max(last_seen)` | | `first_utm_source` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_utm_source)` | | `last_utm_source` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_utm_source)` | | `first_utm_medium` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_utm_medium)` | | `last_utm_medium` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_utm_medium)` | | `first_utm_campaign` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_utm_campaign)` | | `last_utm_campaign` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_utm_campaign)` | | `first_utm_content` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_utm_content)` | | `last_utm_content` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_utm_content)` | | `first_utm_term` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_utm_term)` | | `last_utm_term` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_utm_term)` | | `first_referrer` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_referrer)` | | `last_referrer` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_referrer)` | | `first_referrer_url` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_referrer_url)` | | `last_referrer_url` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_referrer_url)` | | `first_ref` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_ref)` | | `last_ref` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_ref)` | | `first_builder_codes` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_builder_codes)` | | `last_builder_codes` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_builder_codes)` | | `first_paid_source` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_paid_source)` | | `last_paid_source` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_paid_source)` | | `first_click_id` | AggregateFunction(argMin, String, DateTime) | `argMinMerge(first_click_id)` | | `last_click_id` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_click_id)` | | `num_sessions` | AggregateFunction(uniq, String) | `uniqMerge(num_sessions)` | | `revenue` | SimpleAggregateFunction(sum, Float64) | `sum(revenue)` | | `volume` | SimpleAggregateFunction(sum, Float64) | `sum(volume)` | | `points` | SimpleAggregateFunction(sum, Float64) | `sum(points)` | | `wallets_state` | AggregateFunction(groupUniqArray, String) | `groupUniqArrayMerge(wallets_state)` (users table only) | | `last_type` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_type)` | | `last_event` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_event)` | | `last_properties` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(last_properties)` | | `location` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(location)` | | `device` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(device)` | | `browser` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(browser)` | | `os` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(os)` | | `activity_dates` | AggregateFunction(groupUniqArrayIf, Date, UInt8) | `groupUniqArrayIfMerge(activity_dates)` | **Lifecycle definitions** (based on `activity_dates`, computed against a reference date that defaults to today): * **New**: `first_seen` within the last 30 days and still active * **Power**: `first_seen` more than 30 days ago and 5+ unique active days in the last 30 days * **Resurrected**: `first_seen` more than 30 days ago and re-engaged within the last 30 days after a 30+ day gap * **At Risk**: `first_seen` more than 30 days ago, still active, but `last_seen` 14+ days ago with fewer than 5 active days in the last 30 days, no 30+ day gap, and 1+ active day in the prior window (days 30 to 60 ago) * **Churned**: `last_seen` more than 30 days ago * **Returning**: default (first seen more than 30 days ago, active recently, but not Power, Resurrected, or At Risk) **Example:** ```sql theme={null} SELECT address, min(first_seen) AS first_seen, max(last_seen) AS last_seen, argMinMerge(first_utm_source) AS first_utm_source, argMaxMerge(last_utm_source) AS last_utm_source, uniqMerge(num_sessions) AS num_sessions, sum(revenue) AS revenue, sum(volume) AS volume, argMaxMerge(location) AS location, argMaxMerge(device) AS device, argMaxMerge(browser) AS browser FROM users GROUP BY address LIMIT 10 ``` ### anonymous\_users Profiles for users who haven’t connected a wallet yet. Same structure as `users` but keyed by `anonymous_id` instead of `address`. **Use cases:** Pre-wallet user analysis, conversion funnel tracking, anonymous visitor segmentation Total users = `users` (wallet-connected) + `anonymous_users` (pre-wallet) | Column | Type | Query Function | | -------------- | ------ | -------------- | | `anonymous_id` | String | Direct access | All other columns are identical to the [users](#users) table, with `anonymous_id` replacing `address` as the primary key, except `activity_dates`. In `anonymous_users`, `activity_dates` is `AggregateFunction(groupUniqArray, Date)`, read with `groupUniqArrayMerge(activity_dates)`, not `groupUniqArrayIfMerge()` as in `users`. ### sessions Aggregated session data with engagement metrics and attribution. Each row represents one session on a given date. **Use cases:** Session analysis, bounce rate calculations, session duration, user attribution | Column | Type | Query Function | | -------------- | --------------------------------------------------------- | --------------------------- | | `origin` | String | Direct access | | `session_id` | String | Direct access | | `date` | Date | Direct access | | `device` | AggregateFunction(argMin, LowCardinality(String), String) | `argMinMerge(device)` | | `browser` | AggregateFunction(argMin, LowCardinality(String), String) | `argMinMerge(browser)` | | `os` | AggregateFunction(argMin, LowCardinality(String), String) | `argMinMerge(os)` | | `location` | AggregateFunction(argMin, LowCardinality(String), String) | `argMinMerge(location)` | | `referrer` | AggregateFunction(argMin, String, String) | `argMinMerge(referrer)` | | `referrer_url` | AggregateFunction(argMin, String, String) | `argMinMerge(referrer_url)` | | `ref` | AggregateFunction(argMin, String, String) | `argMinMerge(ref)` | | `utm_medium` | AggregateFunction(argMin, String, String) | `argMinMerge(utm_medium)` | | `utm_source` | AggregateFunction(argMin, String, String) | `argMinMerge(utm_source)` | | `utm_campaign` | AggregateFunction(argMin, String, String) | `argMinMerge(utm_campaign)` | | `utm_content` | AggregateFunction(argMin, String, String) | `argMinMerge(utm_content)` | | `utm_term` | AggregateFunction(argMin, String, String) | `argMinMerge(utm_term)` | | `channel_type` | AggregateFunction(argMin, LowCardinality(String), String) | `argMinMerge(channel_type)` | | `paid_source` | AggregateFunction(argMin, String, String) | `argMinMerge(paid_source)` | | `click_id` | AggregateFunction(argMin, String, String) | `argMinMerge(click_id)` | | `first_hit` | SimpleAggregateFunction(min, DateTime) | `min(first_hit)` | | `latest_hit` | SimpleAggregateFunction(max, DateTime) | `max(latest_hit)` | | `hits` | AggregateFunction(count) | `countMerge(hits)` | `device`, `browser`, `os`, `location`, and the attribution columns (`referrer`, `referrer_url`, `ref`, `utm_*`, `channel_type`) are `argMin` states that resolve to the session's first-touch (entry) value. Read them with `argMinMerge(...)`, not by selecting the column directly. `paid_source` (acquiring ad network) and `click_id` (raw click ID) resolve independently at the first non-empty paid touch, so they can come from different events within the same session; don't assume `click_id` belongs to the `paid_source` network. **Example:** ```sql theme={null} SELECT AVG(latest_hit - first_hit) AS avg_session_length FROM sessions ``` ### sources Daily aggregated traffic source metrics and attribution data. Each row represents one day of activity from a specific referrer/UTM combination. **Use cases:** Marketing attribution, referrer performance, traffic source analysis, campaign ROI | Column | Type | Description | | -------------- | ------------------------------- | ----------------------------------------------- | | `date` | Date | Activity date | | `origin` | String | Site domain | | `referrer` | String | Referrer domain | | `referrer_url` | String | Full referrer URL | | `device` | String | Device type | | `browser` | String | Browser | | `os` | String | Operating system | | `location` | String | Country code | | `ref` | String | Ref parameter | | `utm_medium` | String | UTM medium | | `utm_source` | String | UTM source | | `utm_campaign` | String | UTM campaign | | `utm_content` | String | UTM content | | `utm_term` | String | UTM term | | `visits` | AggregateFunction(uniq, String) | Unique sessions - use `uniqMerge(visits)` | | `users` | AggregateFunction(uniq, String) | Unique anonymous users - use `uniqMerge(users)` | | `hits` | AggregateFunction(count) | Page views - use `countMerge(hits)` | ### revenue Financial metrics with full attribution context. Tracks revenue, transaction volume, and points. **Use cases:** Revenue attribution, ROI analysis, financial performance by source/campaign | Column | Type | Description | | --------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `origin` | String | Site domain | | `pathname` | String | Page path | | `date` | Date | Transaction date | | `device` | String | Device type | | `browser` | String | Browser | | `os` | String | Operating system | | `location` | String | Country code | | `referrer_url` | String | Full referrer URL | | `referrer` | String | Referrer domain | | `ref` | String | Ref parameter | | `utm_medium` | String | UTM medium | | `utm_source` | String | UTM source | | `utm_campaign` | String | UTM campaign | | `utm_content` | String | UTM content | | `utm_term` | String | UTM term | | `builder_codes` | String | Builder/referral codes | | `gclid` | String | Google Ads click ID | | `gad_source` | String | Google Ads source parameter | | `fbclid` | String | Meta (Facebook) click ID | | `msclkid` | String | Microsoft Ads click ID | | `ttclid` | String | TikTok Ads click ID | | `twclid` | String | X (Twitter) Ads click ID | | `li_fat_id` | String | LinkedIn Ads click ID | | `rdt_cid` | String | Reddit Ads click ID | | `event` | String | Revenue-generating event name | | `rdns` | String | Wallet RDNS identifier | | `provider_name` | SimpleAggregateFunction(any, String) | Wallet provider name. `SimpleAggregateFunction` values don't need a `-Merge` function on read, so this still reads directly like a plain string (see [Working with Aggregate Tables](#working-with-aggregate-tables)) | | `chain_id` | String | Blockchain chain ID | | `volume` | Float32 | Transaction volume (capped at 1B) | | `revenue` | Float32 | Revenue value (capped at 1B) | | `points` | Float32 | Points value (capped at 1B) | | `channel_type` | LowCardinality(String) | Marketing channel type derived from attribution data | **Example:** ```sql theme={null} SELECT utm_campaign, SUM(revenue) AS total_revenue FROM revenue GROUP BY utm_campaign ORDER BY total_revenue DESC ``` ### identities Identity graph linking anonymous sessions to wallet addresses. Each row represents a connection between an anonymous user and a wallet. **Use cases:** Conversion tracking, user journey analysis, linking pre/post-wallet activity | Column | Type | Query Function | | -------------- | -------------------------------- | ------------------------ | | `session_id` | String | Direct access | | `anonymous_id` | String | Direct access | | `address` | String | Wallet address | | `user_id` | String | Optional user identifier | | `first_seen` | AggregateFunction(min, DateTime) | `minMerge(first_seen)` | **Example:** ```sql theme={null} SELECT address, anonymous_id, session_id, minMerge(first_seen) AS connected_at FROM identities GROUP BY address, anonymous_id, session_id ORDER BY connected_at DESC LIMIT 20 ``` ### user\_profiles\_mv Project-scoped, first-party **user profile traits** captured from `identify` events. One row per `address`, holding the latest value of each trait. This is the identity-data counterpart to `wallet_profiles_mv` (which holds public onchain profile data); `user_profiles_mv` holds the traits *you* send. **Use cases:** User enrichment, first-party identity resolution, personalization, outreach This is an **aggregate table** (`AggregatingMergeTree`). Each trait is stored as an aggregate state, so read it with `-Merge` functions (e.g. `argMaxIfMerge(email)`) and always `GROUP BY address`. Never use `SELECT *`. See [Working with Aggregate Tables](#working-with-aggregate-tables). | Column | Type | Description | | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------ | | `address` | String | Wallet address (primary key within your project) | | `user_id` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Your own external user identifier | | `display_name` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Display name | | `email` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Email address | | `farcaster`, `discord`, `twitter`, `telegram`, `instagram`, `github`, `linkedin`, `facebook`, `tiktok`, `youtube`, `reddit` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Social handles | | `website` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Website URL | | `ens`, `lens`, `basenames`, `linea` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Web3 name-service handles | | `avatar` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Avatar image URL | | `description` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Bio / description | | `location` | AggregateFunction(argMaxIf, String, DateTime, UInt8) | Location | | `updated_at` | SimpleAggregateFunction(max, DateTime) | Last time any trait was updated. Read with `max(updated_at)` | **Example:** ```sql theme={null} SELECT address, argMaxIfMerge(display_name) AS display_name, argMaxIfMerge(email) AS email FROM user_profiles_mv GROUP BY address ``` ### wallet\_profiles\_mv Global wallet profile data aggregated across all chains. One row per wallet address with social profiles, contact info, and net worth. **Use cases:** Wallet intelligence, social identity resolution, user enrichment, outreach | Column | Type | Query Function | | --------------- | -------------------------------------------- | ---------------------------- | | `address` | String | Direct access | | `net_worth_usd` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(net_worth_usd)` | | `ens` | SimpleAggregateFunction(max, String) | Direct access | | `farcaster` | SimpleAggregateFunction(max, String) | Direct access | | `lens` | SimpleAggregateFunction(max, String) | Direct access | | `basenames` | SimpleAggregateFunction(max, String) | Direct access | | `linea` | SimpleAggregateFunction(max, String) | Direct access | | `discord` | SimpleAggregateFunction(max, String) | Direct access | | `telegram` | SimpleAggregateFunction(max, String) | Direct access | | `website` | SimpleAggregateFunction(max, String) | Direct access | | `github` | SimpleAggregateFunction(max, String) | Direct access | | `twitter` | SimpleAggregateFunction(max, String) | Direct access | | `linkedin` | SimpleAggregateFunction(max, String) | Direct access | | `email` | SimpleAggregateFunction(max, String) | Direct access | | `instagram` | SimpleAggregateFunction(max, String) | Direct access | | `facebook` | SimpleAggregateFunction(max, String) | Direct access | | `tiktok` | SimpleAggregateFunction(max, String) | Direct access | | `youtube` | SimpleAggregateFunction(max, String) | Direct access | | `reddit` | SimpleAggregateFunction(max, String) | Direct access | | `avatar` | SimpleAggregateFunction(max, String) | Direct access | | `description` | SimpleAggregateFunction(max, String) | Direct access | | `display_name` | SimpleAggregateFunction(max, String) | Direct access | | `location` | SimpleAggregateFunction(max, String) | Direct access | | `updated_at` | SimpleAggregateFunction(max, DateTime) | Direct access | ### wallet\_profiles\_chains\_mv Wallet profile data broken down by blockchain network. Each row represents a wallet’s activity on a specific chain. **Use cases:** Chain-specific analysis, cross-chain behavior tracking, multi-chain user segmentation | Column | Type | Query Function | | --------------- | ------------------------------------------------ | ---------------------------- | | `address` | String | Direct access | | `chain_id` | String | Blockchain identifier | | `net_worth_usd` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(net_worth_usd)` | | `tx_count` | AggregateFunction(argMax, UInt64, DateTime) | `argMaxMerge(tx_count)` | | `first_onchain` | SimpleAggregateFunction(min, Nullable(DateTime)) | Direct access | | `last_onchain` | SimpleAggregateFunction(max, Nullable(DateTime)) | Direct access | | `updated_at` | SimpleAggregateFunction(max, DateTime) | Direct access | **Example:** ```sql theme={null} SELECT address, chain_id, argMaxMerge(net_worth_usd) AS net_worth_usd, argMaxMerge(tx_count) AS tx_count, first_onchain, last_onchain FROM wallet_profiles_chains_mv GROUP BY address, chain_id, first_onchain, last_onchain ORDER BY net_worth_usd DESC LIMIT 50 ``` ### wallet\_profiles\_chains\_tokens\_mv Token holdings data per wallet per chain. Each row represents one token held by a wallet on a specific chain. **Use cases:** Token portfolio analysis, token-based segmentation, whale identification | Column | Type | Query Function | | --------------- | -------------------------------------------- | --------------------------------------- | | `address` | String | Direct access | | `chain_id` | String | Blockchain identifier | | `token_address` | String | Token contract address | | `app_id` | String | DeFi app ID (if token is in a protocol) | | `name` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(name)` | | `symbol` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(symbol)` | | `img` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(img)` | | `decimals` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(decimals)` | | `price` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(price)` | | `balance` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(balance)` | | `balance_usd` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(balance_usd)` | | `updated_at` | SimpleAggregateFunction(max, DateTime) | Direct access | *** ### wallet\_profiles\_chains\_apps\_mv DeFi app usage data per wallet per chain. Each row represents a wallet’s interaction with a specific DeFi protocol. **Use cases:** DeFi app adoption analysis, protocol usage tracking, user portfolio analysis | Column | Type | Query Function | | ------------- | -------------------------------------------- | -------------------------- | | `address` | String | Direct access | | `chain_id` | String | Blockchain identifier | | `id` | String | Unique app identifier | | `name` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(name)` | | `img` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(img)` | | `url` | AggregateFunction(argMax, String, DateTime) | `argMaxMerge(url)` | | `balance_usd` | AggregateFunction(argMax, Float64, DateTime) | `argMaxMerge(balance_usd)` | | `updated_at` | SimpleAggregateFunction(max, DateTime) | Direct access | ### wallet\_profiles\_events Raw timestamped wallet profile data from blockchain analysis. Contains the unprocessed profile updates that feed into the `wallet_profiles_*` materialized views. **Use cases:** Debugging wallet profile data, historical profile snapshots, raw data access | Column | Type | Description | | ------------ | -------- | -------------------------------------------------------------- | | `address` | String | Wallet address | | `type` | String | Event type: `wallet_profile_set` or `wallet_profile_chain_set` | | `chain_id` | String | Blockchain identifier | | `version` | String | Profile data version | | `properties` | String | JSON with detailed profile data (tokens, balances, tx history) | | `timestamp` | DateTime | When the profile was captured | ### wallet\_profiles\_labels Wallet labels and tags for categorizing and filtering wallet addresses. Each row is a label assigned to an address, optionally scoped to a chain. **Use cases:** Wallet categorization, risk scoring, user segmentation, compliance filtering | Column | Type | Description | | ----------- | -------- | --------------------------------------------------------------- | | `address` | String | Wallet address | | `chain_id` | String | Blockchain identifier (`-` for global labels across all chains) | | `tag_id` | String | Unique label identifier | | `value` | String | Optional label value/score | | `source` | String | System that provided the label | | `timestamp` | DateTime | When the label was last updated | ### user\_labels Project-scoped **labels and tags you assign to wallet addresses** (e.g. your own segments, scores, or categories). The latest value per `(tag_id, address, chain_id)` is kept automatically. This is the project-specific counterpart to `wallet_profiles_labels`, which holds **global** labels supplied by Formo's wallet profiler. **Use cases:** Custom segmentation, scoring, audience building, tagging | Column | Type | Description | | ----------- | -------- | ----------------------------------------------- | | `address` | String | Labeled wallet address | | `chain_id` | String | Chain the label applies to (`-` for all chains) | | `tag_id` | String | Label / tag identifier | | `value` | String | Optional label value or score | | `source` | String | System that supplied the label | | `timestamp` | DateTime | When the label was last updated | ## Table relationships ``` events.anonymous_id ──────▶ anonymous_users.anonymous_id events.address ──────▶ users.address events.session_id ──────▶ sessions.session_id identities.session_id identities.anonymous_id ───▶ anonymous_users.anonymous_id identities.address ────▶ users.address users.address ────▶ wallet_profiles_mv.address wallet_profiles_chains_mv.address wallet_profiles_chains_tokens_mv.address wallet_profiles_chains_apps_mv.address ``` The **identities** table is the bridge between anonymous and identified users. It links `anonymous_id` and `session_id` to `address`, enabling you to track user behavior before and after wallet connection. ## Variables Template variables are replaced with actual values at query time. Use them to build charts and dashboards that respond to a shared date picker instead of hardcoded ranges. ### Date range variables #### `{{date_from}}` Start date of the selected date range. Replaced with a `YYYY-MM-DD` string at query time. **Example:** ```sql theme={null} WHERE timestamp >= '{{date_from}}' ``` #### `{{date_to}}` End date of the selected date range. Replaced with a `YYYY-MM-DD` string at query time. **Example:** ```sql theme={null} WHERE timestamp < '{{date_to}}' ``` **Combined example:** ```sql theme={null} SELECT toDate(timestamp) AS date, COUNT(*) AS events FROM events WHERE timestamp >= '{{date_from}}' AND timestamp < '{{date_to}}' GROUP BY date ORDER BY date ``` ## Functions Custom SQL functions that resolve at query time. Functions use the `{{ function_name(...) }}` syntax - the macro is expanded into a numeric literal before your query runs, so results can be used anywhere a number would be (`SELECT`, `WHERE`, arithmetic, etc.). **Rules:** * Maximum 10 function calls per query * Results are cached for 15 minutes ### Price oracle functions Fetch live token prices directly inside your SQL queries. Use these to convert token amounts to USD values without joining external price data manually. #### `alchemy.token_price` Get token price by contract address and chain ID from Alchemy. **Signature:** `{{ alchemy.token_price('ADDRESS', CHAIN_ID, 'CURRENCY') }}` **Parameters:** * `ADDRESS` - EVM contract address (0x-prefixed, 42 characters) * `CHAIN_ID` - Numeric chain ID (e.g., `1` for Ethereum, `8453` for Base, `42161` for Arbitrum) * `CURRENCY` - One of `usd`, `eur`, `eth`, `btc` **Example:** ```sql theme={null} SELECT SUM(volume) * {{ alchemy.token_price('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 1, 'usd') }} AS volume_usd FROM revenue ``` See the [Alchemy Prices API - Get Token Prices by Address](https://www.alchemy.com/docs/data/prices-api/prices-api-endpoints/prices-api-endpoints/get-token-prices-by-address) reference. #### `alchemy.token_price_by_symbol` Get token price by symbol from Alchemy. **Signature:** `{{ alchemy.token_price_by_symbol('SYMBOL', 'CURRENCY') }}` **Parameters:** * `SYMBOL` - Token symbol (e.g., `ETH`, `BTC`, `SOL`) * `CURRENCY` - One of `usd`, `eur`, `eth`, `btc` **Example:** ```sql theme={null} SELECT SUM(revenue) * {{ alchemy.token_price_by_symbol('ETH', 'usd') }} AS revenue_usd FROM revenue ``` See the [Alchemy Prices API - Get Token Prices by Symbol](https://www.alchemy.com/docs/data/prices-api/prices-api-endpoints/prices-api-endpoints/get-token-prices-by-symbol) reference. #### `pyth.token_price` Get token price by pair name from Pyth. **Signature:** `{{ pyth.token_price('BASE/QUOTE') }}` **Parameters:** * `BASE/QUOTE` - Price pair (e.g., `ETH/USD`, `BTC/USD`, `SOL/USD`) **Example:** ```sql theme={null} SELECT SUM(revenue) * {{ pyth.token_price('ETH/USD') }} AS revenue_usd FROM revenue ``` See the [Pyth Hermes API - Latest Price Updates](https://hermes.pyth.network/docs/#/rest/latest_price_updates) reference. ## Working with aggregate tables Several tables (`users`, `anonymous_users`, `sessions`, `sources`, `identities`, `user_profiles_mv`) use ClickHouse [AggregateFunction](https://clickhouse.com/docs/en/sql-reference/data-types/aggregatefunction) types. These store intermediate aggregation states, not final values. ### Rules 1. **Always `GROUP BY` the primary key** when querying aggregate tables 2. **Use `-Merge` suffix** for `AggregateFunction` columns (e.g., `argMaxMerge(last_utm_source)`) 3. **Use standard functions** for `SimpleAggregateFunction` columns (e.g., `min(first_seen)`, `max(last_seen)`) 4. **Never `SELECT *`** on aggregate tables - it returns binary aggregate states, not readable values ### Quick reference | Aggregate Type | Query Pattern | Example | | ------------------------------------------------- | ---------------------------- | --------------------------------------- | | `AggregateFunction(argMax, T, DateTime)` | `argMaxMerge(col)` | `argMaxMerge(location)` | | `AggregateFunction(argMaxIf, T, DateTime, UInt8)` | `argMaxIfMerge(col)` | `argMaxIfMerge(email)` | | `AggregateFunction(argMin, T, DateTime)` | `argMinMerge(col)` | `argMinMerge(first_utm_source)` | | `AggregateFunction(uniq, T)` | `uniqMerge(col)` | `uniqMerge(num_sessions)` | | `AggregateFunction(count)` | `countMerge(col)` | `countMerge(hits)` | | `AggregateFunction(groupUniqArray, T)` | `groupUniqArrayMerge(col)` | `groupUniqArrayMerge(wallets_state)` | | `AggregateFunction(groupUniqArrayIf, T, UInt8)` | `groupUniqArrayIfMerge(col)` | `groupUniqArrayIfMerge(activity_dates)` | | `AggregateFunction(min, T)` | `minMerge(col)` | `minMerge(first_seen)` | | `SimpleAggregateFunction(min, T)` | `min(col)` | `min(first_seen)` | | `SimpleAggregateFunction(max, T)` | `max(col)` | `max(last_seen)` | | `SimpleAggregateFunction(sum, T)` | `sum(col)` | `sum(revenue)` | | `SimpleAggregateFunction(any, T)` | Direct access | `device` | ## Example queries ### Most active wallets ```sql theme={null} SELECT address, COUNT(*) AS event_count FROM events WHERE address != '' GROUP BY address ORDER BY event_count DESC LIMIT 10 ``` ### Daily active users ```sql theme={null} SELECT toDate(timestamp) AS date, COUNT(DISTINCT address) AS daily_active_users FROM events WHERE timestamp >= now() - INTERVAL 30 DAY GROUP BY date ORDER BY date DESC ``` ### Average session duration ```sql theme={null} SELECT AVG(latest_hit - first_hit) AS avg_session_length FROM sessions ``` ### Events by type ```sql theme={null} SELECT type, COUNT(*) AS event_count FROM events GROUP BY type ORDER BY event_count DESC ``` ### Revenue by UTM campaign ```sql theme={null} SELECT utm_campaign, SUM(revenue) AS total_revenue FROM revenue GROUP BY utm_campaign ORDER BY total_revenue DESC ``` ### Top countries by session count ```sql theme={null} SELECT location, COUNT(*) AS session_count FROM ( SELECT session_id, argMinMerge(location) AS location FROM sessions GROUP BY session_id ) GROUP BY location ORDER BY session_count DESC LIMIT 20 ``` ### Sessions by device and browser ```sql theme={null} WITH session_attrs AS ( SELECT session_id, argMinMerge(device) AS device, argMinMerge(browser) AS browser FROM sessions GROUP BY session_id ), top_browsers AS ( SELECT browser FROM session_attrs GROUP BY browser ORDER BY COUNT(*) DESC LIMIT 10 ) SELECT device, browser, COUNT(*) AS session_count FROM session_attrs WHERE browser IN (SELECT browser FROM top_browsers) GROUP BY device, browser ORDER BY session_count DESC ``` ### Enrich users with social profiles ```sql theme={null} SELECT u.address, min(u.first_seen) AS first_seen, max(u.last_seen) AS last_seen, argMaxMerge(u.location) AS location, wp.ens, wp.farcaster, wp.twitter, argMaxMerge(wp.net_worth_usd) AS net_worth_usd FROM users u LEFT JOIN wallet_profiles_mv wp ON u.address = wp.address GROUP BY u.address, wp.ens, wp.farcaster, wp.twitter ORDER BY net_worth_usd DESC LIMIT 20 ``` ### Top DeFi apps of US users ```sql theme={null} WITH user_locations AS ( SELECT address, argMaxMerge(location) AS location FROM users GROUP BY address ), app_balances AS ( SELECT id, chain_id, argMaxMerge(name) AS name, argMaxMerge(url) AS url, argMaxMerge(balance_usd) AS balance_usd, address FROM wallet_profiles_chains_apps_mv WHERE address IN ( SELECT address FROM user_locations WHERE location = 'US' ) GROUP BY id, chain_id, address ) SELECT id, chain_id, any(name) AS name, any(url) AS url, sum(balance_usd) AS total_balance_usd, count(DISTINCT address) AS user_count FROM app_balances GROUP BY id, chain_id ORDER BY total_balance_usd DESC LIMIT 20 ``` ### High net worth DeFi positions ```sql theme={null} SELECT address, id, argMaxMerge(name) AS name, chain_id, argMaxMerge(balance_usd) AS balance_usd FROM wallet_profiles_chains_apps_mv GROUP BY address, chain_id, id HAVING balance_usd > 500 ORDER BY balance_usd DESC LIMIT 100 ``` ### Track user journey across anonymous and wallet sessions ```sql theme={null} SELECT i.address, i.anonymous_id, i.session_id, minMerge(i.first_seen) AS connected_at FROM identities i GROUP BY i.address, i.anonymous_id, i.session_id ORDER BY connected_at DESC LIMIT 20 ``` # Core concepts Source: https://docs.formo.so/data/concepts Understand the three core concepts in analytics: events that capture key touchpoints, users behind those events, and properties that add context. ### Overview Here are the 3 core concepts you need to know: 1. **Events** are the actions that happen in your app. 2. **Users** are the people who performed those actions. 3. **Properties** are attributes that add context to your users and events. ### 1. Events An event is **a data point that represents an interaction between a user and your app.** [Events](/data/events/overview) capture the details of an action the moment it happens, whether that's a user viewing a page, connecting a wallet, or submitting a transaction on a DeFi app. The Formo SDK automatically captures and tracks web and wallet events. You can also track [custom in-app events](/features/product-analytics/custom-events) and [smart contract events](/features/product-analytics/contract-events). ### 2. Users A user is **the specific individual who completed an interaction within your app.** Each user has a unique identifier that you can use to track their activity. Formo distinguishes between anonymous users and wallets: * Anonymous users aren't tied to any wallets. All users start as anonymous users until they connect a wallet. * Wallets are tied to a unique Ethereum or Solana address. When the user connects to these identities, their wallets are identified and tracked. Multiple wallets that belong to the same user are automatically linked when they are used in the same session. You can see wallet clusters and linked addresses on the Users page. ### 3. Properties Properties capture **additional context about events and users.** * Event properties are attributes that describe details specific to a particular action. For example, a Swap Completed event may include the token pair, input amount, output amount, volume, and protocol fee as event properties. * User properties describing a wallet or user and apply across all their future events until the properties are modified. Formo's SDK captures user properties automatically, and you can also set your own custom properties. Examples: * A Connect Wallet event has wallet address and chain ID properties. * A Page View event has page URL, referrer, and other page properties. * A User has net worth, social handles, lifetime revenue, and attribution (referrers, referral, UTM) properties. # Data sync Source: https://docs.formo.so/data/data-sync Configure data pipelines to export events, users, wallet profiles, and query results to Google Cloud Storage or Amazon S3 on a schedule or on-demand. Set up a pipeline to export data to one of the destinations below, on a schedule or on-demand. ## Supported destinations * **Google Cloud Storage (GCS):** Export directly to a GCS bucket. From there, you can load data into BigQuery for warehousing and analysis. * **Amazon S3:** Export directly to an S3 bucket. ## What you can export Export any data available in the Formo data warehouse, including: * **Events:** Raw and processed event data. * **Users:** User profiles with wallet data, attribution, lifecycle classification, and activity history. * **Wallet Profiles:** Net worth, social profiles, onchain activity, token holdings, app interactions, and labels. * **Segments:** Filtered user cohorts based on lifecycle, behavior, or custom filters. * **Arbitrary SQL:** If you can run it on the [Explorer](/features/product-analytics/explore), you can export it. ## Scheduling Exports run on a cron schedule (e.g. `0 * * * *` for hourly) or can be triggered on-demand. ## Export formats * CSV (default) * NDJSON * Parquet Compression options: gzip, brotli, LZMA, zstd, or none. ## How it works 1. Configure a data pipeline with a SQL query defining the data to export. 2. Set a destination (GCS or S3 bucket) and schedule (cron expression). 3. Exports run automatically on schedule, writing files to the destination. > 🚧 In development. Reach out to get early access. 🚧 # Chain event Source: https://docs.formo.so/data/events/chain Reference for the chain event emitted when a user switches blockchain networks, capturing the new chain ID and wallet address in the payload. The `chain` event is emitted whenever the user's chain network changes. ## Properties | Property | Type | Description | | :--------- | :----- | :-------------------------------------------- | | `chain_id` | Number | Chain ID of the network the user switched to. | ## Sample Payload Here’s the payload of a typical call with most common fields removed: ```json theme={null} { "type": "chain", "address": "0x8e6ca77a7e044ba836a97beb796c124ca3a6a154", "properties": { "chain_id": 1 } } ``` # Common fields Source: https://docs.formo.so/data/events/common Reference for the common and contextual fields shared across all Formo events, including event type, timestamps, device context, and session identifiers. Formo defines some common fields (event type, timestamps, and more) across all API calls that make up the core event data structure. This guide covers the common and contextual fields in detail. ## Common Fields > The Formo SDKs populate the required information automatically. | Name | Datatype | Required | Description | | :------------------- | :-------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `type` | String | ✓ | Captures the type of event. Values can be either identify, track, connect, signature, transaction, and others. | | `channel` | String | ✓ | Identifies the source of the event. The Formo SDKs emit `web`, `mobile`, or `server`. Other values such as `onchain`, `import`, and `api` are reserved for platform and import-pipeline ingestion, not SDK-emitted events. | | `version` | String | ✓ | Version of the event spec. | | `project_id` | String | ✓ | Unique identification for the project in the database. | | `session_id` | String | ✓ | Session identifier. On **web** this is a privacy-friendly, daily-changing value derived server-side at ingestion, so no cookie is required. The **mobile** SDK supplies its own instead, which rotates after 30 minutes of inactivity; see [Session management](/sdks/mobile#session-management). | | `anonymous_id` | String | ✓ | Pseudo-identifier for the user in cases where userId is absent. Equivalent to a device ID. | | `user_id` | String | | Unique identification for the user in the database. | | `address` | String | | Unique wallet address of the user. | | `event` | String | | Captures the user action that you want to record. | | `context` | Object | ✓ | Contains all additional user information. | | `properties` | Object | | Passes all relevant information associated with the event. | | `original_timestamp` | Timestamp | ✓ | Records the actual time (in UTC) when the event occurred. | | `sent_at` | Timestamp | ✓ | Captures the time (in UTC) when the event was sent from the client to Formo. | | `received_at` | Timestamp | ✓ | Time in UTC when Formo ingests the event. | | `timestamp` | Timestamp | ✓ | Formo calculates this field to account for any client-side clock skew using the formula: timestamp = received\_at - (sent\_at - original\_timestamp). Note that this time is in UTC. | | `message_id` | String | ✓ | Unique identification for the event. | ## Contextual Fields Contextual fields give additional information about a particular event. The following table describes the available contextual fields. | Name | Datatype | Required | Description | | :---------------- | :------- | :------- | :---------------------------------------------------------------------- | | `user_agent` | String | | The user agent of the device that you are tracking. | | `locale` | String | | Captures the language of the device. | | `location` | String | | Geographic location of the user. | | `timezone` | String | | Captures the timezone of the user you are tracking. | | `referrer` | String | | The referrer URL where the user came from. | | `utm_source` | String | | Identifies which site sent the traffic. | | `utm_medium` | String | | Identifies what type of link was used. | | `utm_campaign` | String | | Identifies a specific product promotion or strategic campaign. | | `utm_term` | String | | Identifies search terms. | | `utm_content` | String | | Identifies what specifically was clicked to bring the user to the site. | | `ref` | String | | Referral code or identifier. | | `gclid` | String | | Google Ads click identifier. | | `gad_source` | String | | Google Ads source parameter (newer attribution). | | `fbclid` | String | | Meta (Facebook/Instagram) click identifier. | | `msclkid` | String | | Microsoft Ads (Bing) click identifier. | | `ttclid` | String | | TikTok Ads click identifier. | | `twclid` | String | | Twitter/X Ads click identifier. | | `li_fat_id` | String | | LinkedIn Ads click identifier. | | `rdt_cid` | String | | Reddit Ads click identifier. | | `page_url` | String | | Full URL of the page. | | `page_path` | String | | Path component of the URL. | | `page_title` | String | | Title of the page. | | `library_name` | String | | Name of the SDK used to capture the event. | | `library_version` | String | | Version of the SDK used to capture the event. | | `browser` | String | | Name of the browser (e.g., chrome, firefox, safari). | | `screen_width` | Number | | Width of the device screen in pixels. | | `screen_height` | Number | | Height of the device screen in pixels. | | `screen_density` | Number | | Pixel density of the device screen (devicePixelRatio). | | `viewport_width` | Number | | Width of the browser viewport in pixels. | | `viewport_height` | Number | | Height of the browser viewport in pixels. | ## Timestamps Every API call has four timestamps: `original_timestamp`, `timestamp`, `sent_at`, and `received_at`. They're used for very different purposes. All timestamps are ISO-8601 date strings, and are in the UTC timezone. To see the user's timezone information, check the `timezone` property that's automatically collected by client-side SDKs. ### Timestamp overview | Name | Calculated / Value | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | original\_timestamp | Time on the client device when call was invoked OR The timestamp value manually passed in through server-side libraries. | | sent\_at | Time on client device when call was sent OR sent\_at value manually passed in. | | received\_at | Time on Formo server clock when call was received | | timestamp | Calculated by Formo to correct client-device clock skew using the following formula: `received_at - (sent_at - original_timestamp)` | ### Original Timestamp The `original_timestamp` tells you when call was invoked on the client device or the value of timestamp that you manually passed in. > Note: The `original_timestamp` timestamp is not useful for any analysis since it's not always trustworthy as it can be easily adjusted and affected by clock skew. ### Sent At The `sent_at` timestamp specifies the clock time for the client's device when the network request was made to the Formo API. For libraries and systems that send batched requests, there can be a long gap between a datapoint's timestamp and sent\_at. Combined with `received_at`, Formo uses `sent_at` to correct the `original_timestamp` in situations where a user's device clock cannot be trusted (mobile phones and browsers). The `sent_at` and `received_at` timestamps are assumed to occur at the same time (maximum a few hundred milliseconds), and therefore the difference is the user's device clock skew, which can be applied back to correct the timestamp. > Note: The `sent_at` timestamp is not useful for any analysis since it's tainted by user's clock skew. ### Received At The `received_at` timestamp is added to incoming messages as soon as they hit the API. It's used in combination with `sent_at` to correct clock skew, and also to aid with debugging libraries and systems that deliver events in batches. ### Timestamp The `timestamp` specifies when the data point occurred, corrected for client-device clock skew. This is the timestamp that is passed to downstream destinations and used for historical replays. It is important to use this timestamp for importing historical data to the API. Formo automatically generates `timestamp` and you cannot manually set one directly in the call payload. Formo calculates timestamp as `timestamp = received_at - (sent_at - original_timestamp)`. ## Sample Event Here's an example event with common and contextual fields included: ```json theme={null} { "type": "page", "channel": "web", "version": "0", "project_id": "d5naNbBlqxSBXLuNa6zwc", "session_id": "117b982a451dc22edea6413b8e20958216c0a5b3baaa1d90699c42dbf4e74e33", "anonymous_id": "c2bc0ebe-d852-49d1-9efd-e45744850ae0", "user_id": "a46e6878-1ed5-4a81-9185-83608df2fcb6", "address": "0x8e6ca77a7e044ba836a97beb796c124ca3a6a154", "event": "FooBar", "context": { "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", "locale": "en-US", "location": "ID", "timezone": "Asia/Saigon", "referrer": "https://chatgpt.com/", "ref": "vitalik.eth", "utm_source": "chatgpt.com", "utm_medium": "linkedin", "utm_campaign": "early-access", "utm_term": "", "utm_content": "", "page_url": "https://app.morpho.org/vaults/1", "page_path": "/vaults/1", "page_title": "USDC Vault | Morpho", "library_name": "Formo Web SDK", "library_version": "1.30.1", "browser": "chrome", "screen_width": 1920, "screen_height": 1080, "screen_density": 1, "viewport_width": 1920, "viewport_height": 969 }, "properties": { "name": "USDC Vault", "category": "Vaults", "url": "https://app.morpho.org/vaults/1?utm_source=chatgpt.com", "path": "/vaults/1", "hash": "#deposit", "title": "USDC Vault | Morpho" }, "message_id": "48555101eee2f44ac0f0632fcb7c7c9f6ce0012ae395ae79f8a0d515e4f5e41f", "original_timestamp": "2025-04-03 18:21:00", "sent_at": "2025-04-03 18:21:00", "received_at": "2025-04-03 18:21:00", "timestamp": "2025-04-03 18:21:00" } ``` # Connect event Source: https://docs.formo.so/data/events/connect Reference for the connect event emitted when a user connects their wallet, including the newly connected chain ID and wallet address fields. The `connect` event is emitted whenever the user connects a wallet. ## Properties | Property | Type | Description | | :-------------- | :----- | :----------------------------------------------------------------------------------------- | | `chain_id` | Number | Chain ID of the network the wallet connected to. | | `provider_name` | String | Wallet connector/provider name (present when auto-captured through the wagmi integration). | ## Sample Payload Here’s the payload of a typical call with most common fields removed: ```json theme={null} { "type": "connect", "address": "0x8e6ca77a7e044ba836a97beb796c124ca3a6a154", "properties": { "chain_id": 1 } } ``` # Detect event Source: https://docs.formo.so/data/events/detect Reference for the detect event that identifies a visitor's installed wallet provider, capturing the wallet name and RDNS before a connection is made. The `detect` event lets you identify a visitor's wallet name and rdns. ## Properties | Property | Type | Description | | :-------------- | :----- | :------------------------------------------------------------------- | | `provider_name` | String | Name of the detected wallet provider (e.g., MetaMask). | | `rdns` | String | Reverse-DNS identifier of the wallet provider (e.g., `io.metamask`). | ## Sample Payload Here’s the payload of a typical call with most common fields removed: ```json theme={null} { "type": "detect", "properties": { "provider_name": "MetaMask", "rdns": "io.metamask" } } ``` # Disconnect event Source: https://docs.formo.so/data/events/disconnect Reference for the disconnect event emitted when a user disconnects their wallet, including the chain ID and wallet address payload fields. The `disconnect` event is emitted whenever the user disconnects a wallet. ## Properties | Property | Type | Description | | :--------- | :----- | :---------------------------------------------------- | | `chain_id` | Number | Chain ID of the network the wallet disconnected from. | ## Sample Payload Here’s the payload of a typical call with most common fields removed: ```json theme={null} { "type": "disconnect", "address": "0x8e6ca77a7e044ba836a97beb796c124ca3a6a154", "properties": { "chain_id": 1 } } ``` # Identify event Source: https://docs.formo.so/data/events/identify Reference for the identify event that ties users to their actions and records traits like wallet name and RDNS for cross-session user recognition. The `identify` event lets you tie a user to their actions and record traits about them. It includes a unique User ID, wallet name, and wallet rdns. ## Properties | Property | Type | Description | | :-------------- | :----- | :------------------------------------------------------------------- | | `provider_name` | String | Name of the wallet provider (e.g., MetaMask). | | `rdns` | String | Reverse-DNS identifier of the wallet provider (e.g., `io.metamask`). | ## Sample Payload Here’s the payload of a typical call with most common fields removed: ```json theme={null} { "type": "identify", "user_id": "0c93652b-a366-4c92-ab87-0c0ab4fba5aa", "address": "0x9798d87366bdfc5d70b300abdffc4f9e95369b3d", "properties": { "provider_name": "MetaMask", "rdns": "io.metamask" } } ``` # Events overview Source: https://docs.formo.so/data/events/overview Learn how to send event data to Formo's APIs and the correct format for capturing events with SDKs, including identify, track, connect, and transaction calls. ## Events API The Events API supports the following event types, each capturing key touchpoints in the user journey: | Event type | Description | | :-------------------------------------- | :------------------------------------------------------------- | | [Identify](/data/events/identify) | Identifies a visitor or user | | [Detect](/data/events/detect) | Identifies a user's wallet provider | | [Track](/data/events/track) | Records a custom event with arbitrary data | | [Page](/data/events/page) | Records a page view (web) or screen view (mobile) | | [Connect](/data/events/connect) | Records when a user connects their wallet to your application | | [Disconnect](/data/events/disconnect) | Records when a user disconnects their wallet | | [Chain](/data/events/chain) | Records when a user switches to a different blockchain network | | [Signature](/data/events/signature) | Records signature requests and their statuses | | [Transaction](/data/events/transaction) | Records blockchain transactions and their statuses | # Page event Source: https://docs.formo.so/data/events/page Reference for the page view event that records web and mobile screen visits, with auto-collected browser properties and unified cross-platform tracking. The `page` event records whenever a user views a page on your website or a screen in your mobile app. Both the [web SDK](/sdks/web) and [mobile SDK](/sdks/mobile) emit `page` events with `channel` set to `"web"` or `"mobile"` respectively. On web, page properties are automatically collected from the browser. On mobile, the screen name is mapped to page-equivalent fields (`page_title`, `page_path`, `page_url`) so that web and mobile views are processed through the same analytics pipeline. ## Properties | Property | Type | Description | Web SDK | Mobile SDK | | :--------- | :----- | :-------------------------------------------------------- | :------------------------- | :--------------------- | | `url` | String | Full URL of the page. | `window.location.href` | Not set | | `path` | String | Path component of the URL (without query string or hash). | `window.location.pathname` | Not set | | `hash` | String | Hash fragment of the URL (e.g., `#section`). | `window.location.hash` | Not set | | `query` | String | Query string of the URL (without the leading `?`). | `window.location.search` | Not set | | `name` | String | Name of the page or screen. | Caller-provided | Screen name (required) | | `category` | String | Category of the page or screen. | Caller-provided | Caller-provided | | `title` | String | Title of the page. | Caller-provided | Not set | On web, any non-UTM query-string parameters are also added as individual properties. The SDKs additionally auto-collect the following context fields: | Context field | Web SDK | Mobile SDK | | :---------------------------- | :---------------------------- | :--------------------------------------- | | `context.page_url` | `window.location.href` | `app://{screenName}` | | `context.page_title` | `document.title` | Screen name | | `context.page_path` | Not set (derived in backend) | Not set (derived in backend) | | `context.referrer` | `document.referrer` | From deep link (if set) | | `context.user_agent` | Browser user agent | Device user agent (if available) | | `context.locale` | Browser language | Device locale | | `context.timezone` | Resolved IANA timezone | Resolved IANA timezone | | `context.location` | Country derived from timezone | Country derived from timezone | | `context.library_name` | `"Formo Web SDK"` | `"Formo React Native SDK"` | | `context.library_version` | SDK version | SDK version | | `context.browser` | Detected browser name | Not set | | `context.screen_width` | `window.screen.width` | `Dimensions.get("screen").width` | | `context.screen_height` | `window.screen.height` | `Dimensions.get("screen").height` | | `context.screen_density` | `window.devicePixelRatio` | `Dimensions.get("screen").scale` | | `context.viewport_width` | `window.innerWidth` | Not set | | `context.viewport_height` | `window.innerHeight` | Not set | | `context.os_name` | Not set | `ios` or `android` | | `context.os_version` | Not set | OS version string | | `context.device_model` | Not set | Device model (e.g., `iPhone 14 Pro`) | | `context.device_manufacturer` | Not set | Device manufacturer (e.g., `Apple`) | | `context.device_name` | Not set | Device name | | `context.device_type` | Not set | `mobile` or `tablet` | | `context.app_name` | Not set | From native config or options | | `context.app_version` | Not set | From native config or options | | `context.app_build` | Not set | From native config or options | | `context.app_bundle_id` | Not set | From native config or options | | `context.network_wifi` | Not set | `true` if connected via WiFi | | `context.network_cellular` | Not set | `true` if connected via cellular | | `context.network_carrier` | Not set | Cellular carrier name (when on cellular) | | `channel` | `"web"` | `"mobile"` | ## Sample Payload (Web) ```json theme={null} { "type": "page", "channel": "web", "context": { "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", "locale": "en-US", "timezone": "Asia/Saigon", "location": "ID", "referrer": "https://chatgpt.com/", "utm_source": "chatgpt.com", "utm_medium": "", "utm_campaign": "", "utm_term": "", "utm_content": "", "page_url": "https://formo.so/faucets?utm_source=chatgpt.com", "page_title": "Free testnet faucets", "library_name": "Formo Web SDK", "library_version": "1.30.1", "browser": "chrome", "screen_width": 1920, "screen_height": 1080, "screen_density": 1, "viewport_width": 1920, "viewport_height": 969 }, "properties": { "url": "https://formo.so/faucets?utm_source=chatgpt.com", "path": "/faucets", "hash": "", "query": "utm_source=chatgpt.com", "name": "Faucets", "category": "Docs", "title": "Free testnet faucets" } } ``` ## Sample Payload (Mobile) Screen events from the [mobile SDK](/sdks/mobile) are sent as `page` events with `channel: "mobile"`: ```json theme={null} { "type": "page", "channel": "mobile", "version": "0", "session_id": "117b982a451dc22edea6413b8e20958216c0a5b3baaa1d90699c42dbf4e74e33", "anonymous_id": "c2bc0ebe-d852-49d1-9efd-e45744850ae0", "context": { "library_name": "Formo React Native SDK", "library_version": "1.0.0", "page_title": "Wallet", "page_url": "app://Wallet", "locale": "en-US", "timezone": "America/New_York", "location": "US", "os_name": "ios", "os_version": "17.0", "device_model": "iPhone 14 Pro", "device_manufacturer": "Apple", "device_name": "Yos's iPhone", "device_type": "mobile", "app_name": "MyDeFiApp", "app_version": "2.1.0", "app_build": "42", "app_bundle_id": "com.example.mydefiapp", "network_wifi": true, "network_cellular": false, "screen_width": 393, "screen_height": 852, "screen_density": 3 }, "properties": { "name": "Wallet", "category": "Main" } } ``` # Signature event Source: https://docs.formo.so/data/events/signature Reference for the signature event that tracks wallet message signing, including requested, rejected, and confirmed statuses with message and chain data. The `signature` event is emitted whenever the user signs a message. ## Properties | Property | Type | Description | | :--------- | :----- | :-------------------------------------------------------------------------------------------- | | `status` | String | Signature request status: `requested`, `rejected`, or `confirmed`. | | `chain_id` | Number | Chain ID of the network. | | `message` | String | The message that was requested to be signed (e.g., a stringified EIP-712 typed-data payload). | ## Sample Payload Here’s the payload of a typical call with most common fields removed: ```json theme={null} { "type": "signature", "address": "0x8e6ca77a7e044ba836a97beb796c124ca3a6a154", "properties": { "status": "confirmed", "chain_id": 84532, "message": "{\"domain\":{\"name\":\"Example DApp\",\"version\":\"1\",\"chainId\":84532,\"verifyingContract\":\"0xcccccccccccccccccccccccccccccccccccccccc\"},\"message\":{\"from\":{\"name\":\"Alice\",\"wallet\":\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\"},\"to\":{\"name\":\"Bob\",\"wallet\":\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\"},\"content\":\"zcvzxcvzxvc\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallet\",\"type\":\"address\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person\"},{\"name\":\"content\",\"type\":\"string\"}]}}" } } ``` # Custom events Source: https://docs.formo.so/data/events/track Reference for the custom events used to record custom user actions and in-app behaviour. Record any [custom events](/features/product-analytics/custom-events) in your app, along with properties that describe the action. Custom events can capture a broad range of actions, such as starting a swap or completing a deposit. Additional information about the event can be included in the properties field. For example, for a `Swap Completed` event, you may want to include the token pair, input amount, and output amount. ## Naming events When naming events, Formo recommends establishing a consistent naming convention that uses: * Consistent formatting: Event names are case sensitive. * A consistent syntax: Adopt nouns and past tense verbs like `Swap Completed` and `Deposit Submitted`. A standard of `[Noun] + [Past-Tense Verb]` ensures all your events are consistent. * A consistent actor: Does `Transaction Submitted` mean that the user submitted a transaction or that your app submitted it on their behalf? If all your events are named in a way that reflects the user's perspective, the meaning is clear immediately. This allows everyone including you 6 months from now to instantly understand the meaning of an event. ## Properties Properties are additional information that give more clarity of your users' actions. Every custom event has `type` set to `track` and `event` set to your custom event name, with the event-specific data passed in the `properties` object: ```json theme={null} { "type": "track", "event": "Swap Completed", "properties": { "pair": "ETH/USDC", "token_in": "ETH", "token_out": "USDC" } } ``` Formo has reserved some standard properties listed in the following table and handles them in a special manner. ### Tracking volume, revenue, points You can track `volume`, `revenue`, and `points` in your events. Once tracked, they are shown on the dashboard. Include these optional properties in a custom event to track values associated with an action. | Property | Type | Description | | :--------- | :----- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `volume` | Number | The volume amount as a result of an event. For e.g., a token swap worth \$20.00 would result in a volume of 20.00. Can be positive or negative e.g. send -100 to track outflows. | | `revenue` | Number | The revenue amount as a result of an event. For e.g., a transaction with a protocol fee of \$5.00 would result in a revenue of 5.00. Must be a non-negative number. | | `currency` | String | The currency of the revenue as a result of the event, set in ISO 4217 format. If this is not set, Formo assumes the revenue is in USD. | | `points` | Number | An abstract value such as points or XP associated with an event, to be used by various teams. | Revenue tracking. For example, call `.track()` with the reserved properties alongside any other event properties: ```typescript theme={null} analytics.track('Swap Completed', { pair: 'ETH/USDC', token_in: 'ETH', token_out: 'USDC', amount_in: 1.5, amount_out: 4500, volume: 4500, revenue: 13.5, points: 50 }); ``` ## Sample Payload ```json theme={null} { "type": "track", "event": "Swap Completed", "properties": { "pair": "ETH/USDC", "token_in": "ETH", "token_out": "USDC", "amount_in": 1.5, "amount_out": 4500, "volume": 4500, "revenue": 13.5, "points": 50 } } ``` # Transaction event Source: https://docs.formo.so/data/events/transaction Reference for the transaction event, including status tracking for started, broadcasted, confirmed, reverted, and rejected wallet transactions with chain and hash data. The `transaction` event is emitted whenever the user performs a transaction. ## Properties | Property | Type | Description | | ------------------ | ------ | -------------------------------------------------------------------------------------------------- | | `status` | String | Transaction status: `started`, `broadcasted`, `confirmed`, `reverted`, or `rejected` | | `chain_id` | Number | Chain ID of the network | | `data` | String | Transaction calldata (hex-encoded) | | `to` | String | Recipient address | | `value` | String | Transaction value (hex-encoded) | | `transaction_hash` | String | Transaction hash (available after broadcast) | | `function_name` | String | Decoded function name (if contract interaction) | | `function_args` | Object | Decoded function arguments (if contract interaction) | | `builder_codes` | String | Comma-separated [builder codes](#builder-codes) extracted from the transaction calldata (ERC-8021) | Each entry in `function_args` is also flattened into the event's top-level properties for easier querying. For example, `function_args: { foo: "bar" }` also adds `foo: "bar"` as a property. ## Builder Codes The Formo data platform automatically detects and extracts [ERC-8021](https://www.erc8021.com/) builder codes from transaction calldata. Builder codes are an onchain attribution standard that lets apps identify themselves in transactions. When a transaction includes an ERC-8021 data suffix, Formo parses it and includes the `builder_codes` field in the transaction event. For example, a transaction with the builder code `"uniswap"` would include `"builder_codes": "uniswap"` in the event properties. This works automatically - no additional configuration is needed. If your app appends builder codes to transactions, Formo will detect and attribute them. ## Sample Payload Here's the payload of a typical call with most common fields removed: ```json theme={null} { "type": "transaction", "properties": { "status": "broadcasted", "chain_id": 84532, "data": "0xa4136862000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000086173646661647366000000000000000000000000000000000000000000000000", "to": "0x76BB9C9758F62822Abaa652D49e52Ce85383FC26", "value": "0x1", "transaction_hash": "0x489daee9ded2bfcceb9f9c3edeaf695bd2c3acef0dfb7558e461b2aba59378ba", "function_name": "setText", "function_args": { "text": "asdfadsf" }, "text": "asdfadsf", "builder_codes": "uniswap" } } ``` # Metrics Source: https://docs.formo.so/data/metrics Complete reference of all Formo metrics including visitors, wallets connected, transactions, revenue, retention rates, and acquisition channel breakdowns. ### Live Visitors How many visitors are currently active on your site or app. It counts unique active sessions with a page view in the last 10 minutes. ### Visitors How many unique visitors are interacting with your site or app across the selected period, spanning multiple page views and events. A visitor is identified by a persistent visitor ID (their anonymous ID) and is not stitched to a wallet, so this counts distinct visitors rather than connected wallets. In the overview, the **Visitors** chart shows the daily unique-visitor count (a visitor active on several days adds to each of those days), while the headline number is de-duplicated across the entire selected period, so a visitor active on multiple days is counted only once. Comparable to Google Analytics' "Active users". ### Page Views How many times a page has been viewed across your site or app. ### Wallets Unique wallet addresses active with at least one session during the selected period. We also collect other wallet details, such as the wallet address, wallet type, and wallet profiles. ### Transactions How many transactions have been made across your site or app. ### Volume Total transaction volume (USD) tracked through your [custom events](/features/product-analytics/custom-events#step-3-track-with-volume-revenue-or-points), for example the dollar value of a swap. ### Revenue Total revenue (USD) tracked through your [custom events](/features/product-analytics/custom-events#step-3-track-with-volume-revenue-or-points), for example a protocol fee. ### Sessions A session (also known as a visit) is a set of actions that a user takes on your site. Formo counts unique session IDs. With the web SDK, each visitor's session is counted once per day, so one visitor can have multiple sessions across days. ### Session Duration Session duration measures the observed active time a visitor spends during a single visit (session). ##### How It's Calculated 1. Per-event gaps: The system measures the time between each consecutive event in a session. 2. Active time only: It sums those gaps but ignores any gap longer than 30 minutes (1800 seconds); long inactivity is treated as the visitor leaving, not as time on site. 3. Per-session duration: Adding the counted gaps gives the session's active duration. A session with a single event (e.g. one pageview) has a duration of 0. 4. Averaging: To get the average session duration shown in your reports, the system adds up all individual session durations and divides by the number of sessions. ##### What This Means For You * The reported average session duration gives you a reliable measure of how long people typically engage with your site. * Longer average sessions generally indicate more engaging content. * This metric helps you understand if changes to your site are improving user engagement over time. * Note: The system can only measure activity it can see - when someone views a page or clicks something. If a visitor reads a long article without interacting further, the system can't detect this "passive" time. This is a standard limitation in all web analytics platforms. For most purposes, this calculation method provides an accurate and useful measure of how long visitors are engaging with your content. ### Bounce Rate The bounce rate is the percentage of sessions that bounce, measured over sessions (not unique visitors). Formo counts a "bounce" when a session has a single pageview, no engagement events, and less than 10 seconds of observed activity. Engagement events are wallet connects, transactions, tracked custom events, and smart contract events (decoded logs). ### Channels The acquisition channel that brought a session to your app. Every session is assigned to exactly one of 13 channels when its events are ingested, using a priority-ordered ladder over referrer domain, `utm_source`, `utm_medium`, and 8 ad-platform click IDs (`gclid`, `gad_source`, `fbclid`, `msclkid`, `ttclid`, `twclid`, `li_fat_id`, `rdt_cid`). The channel is stored on the event, so queries read it back without recomputing. | # | Channel | Definition | | -- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Paid Search | Paid signal + referrer is a search engine (Google, Bing, DuckDuckGo, Yahoo, Yandex, Baidu, Brave, Kagi, Naver, ...) | | 2 | Paid Video | Paid signal + referrer is a video platform (YouTube, Vimeo, Twitch, Dailymotion, Loom, Wistia) | | 3 | Paid Social | Paid signal + referrer is a social platform (Meta, X, LinkedIn, Reddit, TikTok, Pinterest, Snapchat, Threads, Discord, Telegram, Farcaster, ...) | | 4 | Email | `utm_medium` ∈ \{`email`, `e-mail`, `e_mail`, `newsletter`}, or referrer is a known email or newsletter domain (Gmail, Proton Mail, Yahoo Mail, Outlook, Substack, Beehiiv, Paragraph) | | 5a | Display (network match) | Referrer domain or `utm_source` matches a known ad-network domain (checked before Referrals, so network-matched traffic is never misclassified as a referral) | | 5b | Referrals | `utm_medium` ∈ \{`affiliate`, `referral`} or non-empty `ref` query parameter | | 6 | Display (medium match) | `utm_medium` ∈ \{`display`, `banner`, `expandable`, `interstitial`} (checked after Referrals) | | 7 | Paid Other | Paid signal that matched none of the above: a paid `utm_medium` or click ID with no recognized source or ad format. A catch-all so paid traffic is never relabelled as organic or Direct. | | 8 | AI | Referrer is an AI assistant domain (ChatGPT, Claude, Gemini, Copilot, Perplexity, DeepSeek, Phind, Poe, Mistral Chat, Meta AI, You.com, Pi, Grok, Qwen, Kimi, Hugging Face, Genspark) | | 9 | Organic Search | `utm_medium=organic` or referrer is a search engine with no paid signal | | 10 | Organic Social | `utm_medium` ∈ \{`social`, `social-network`, `social-media`, `sm`, `social network`, `social media`} or referrer is a social platform with no paid signal | | 11 | Organic Video | Referrer is a video platform with no paid signal | | 12 | Referrers | Any other non-empty referrer not matched by the rules above | | 13 | Direct | No referrer, no UTM, no click ID (typed URL, bookmark, or stripped referrer) | A session has a **paid signal** when any of the following is true: `utm_medium` is one of `cpc`, `cpm`, `cpv`, `cpa`, `ppc`, `paidsearch`, `paidsocial`, `sem`, `retargeting`; `utm_medium` starts with `paid`; or any of the 8 click IDs above is non-empty. Same-domain referrers (e.g. `blog.example.com` referred from `app.example.com`) are stripped before classification so internal navigation rolls up to **Direct** instead of **Referrers**. ### Referrers How many users are referred to your site or app by a particular source such as a search engine, social media platform, etc. Referrers are the statistics for the referring site. The data is extracted from the Referrers (with a r) HTTP header and may not be set by the browser. In these cases it will be listed as unknown. ### UTM parameters How many users come to your site or app from a particular source. We track these UTM codes: * `utm_source` (e.g.: google.com) * `utm_medium` (e.g.: search) * `utm_campaign` (e.g.: summer\_sale) * `utm_content` * `utm_term` To minimize the amount of traffic that falls within the "Direct / None" category, you can add special query parameters (UTMs) to your links. ### Referrals How many users are referred to your site or app by a particular user. We use the `ref` query parameters to track referrals. ### Countries Countries are the statistics for the country of origin of the visitors. ### Devices How many users are using a particular device such as desktop, mobile, tablet, etc. ### Browsers Browser is the statistics of the browser used by the visitor. It is extracted from the User-Agent header. ### OS Shows the operating systems used by your visitors. ### First Seen The date and time when a user or wallet was first observed interacting with your app. ### Last Seen The most recent date and time when a user or wallet was observed interacting with your app. ### User lifecycle The lifecycle stage of each wallet or visitor (New, Returning, Power user, Resurrected, At Risk, or Churned), computed from their activity recency and frequency relative to a reference date. See [User Lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for the exact rules and thresholds for each stage. ### New Users Users who visit your site or app for the first time within the selected time period. ### Returning Users Users who have visited your site or app in previous time periods and return within the current period. ### Resurrected Users Users who were previously active, became inactive for a period of time, and then return after being inactive. ### At Risk Users Previously active users whose activity has slowed but who haven't churned yet: last seen at least 14 days ago, fewer than 5 active days in the last 30 days, and no 30+ day gap, with at least 1 active day in the prior 30 to 60 days. ### Churned Users Users with no activity within the project's churn window. ### Events Events are user-defined custom events. They have a name and optional metadata key/value pairs. When you expand the activity feed, you can view and filter the metadata. Metadata can be anything. For example, you can define a `Swap Completed` event and track the swap pair as the metadata field `pair=ETH/USDC`. ### Top apps The most popular apps used by wallets in your audience. ### Top chains The most popular chains used by wallets in your audience. ### Top tokens The most popular tokens held by wallets in your audience. ### Wallet Profiles Wallet profiles are a collection of onchain and offchain data about a wallet. A profile includes a list of properties such as their address, type, net worth, and more. ### Wallet Address Formo uses the wallet address as a persistent identifier for a visitor where available. ### Linked Addresses Other wallet addresses linked to the same user session. See [Wallet Profiles](/features/wallet-intelligence/wallet-profiles#user-wallet-metadata) for details on how wallets are clustered. ### Wallet Type How many users are using a particular wallet type such as MetaMask, Rainbow Wallet, etc. ### Wallet Net Worth The total net worth of a wallet on the top chains across DeFi positions and token balances. ### Wallet Age The amount of time that has passed since a wallet has been first active onchain. ### Transaction Frequency How often a user or wallet performs transactions within a given time period (e.g., daily, weekly, monthly). ### First Onchain The date and time of the first onchain activity detected for a wallet. ### Last Onchain The most recent date and time of onchain activity detected for a wallet. ### Wallet Labels Labels are assigned to a wallet address based on its past onchain activity and public information offchain. ### Chain Id How many users are using a particular chain such as Ethereum, Polygon, etc. ### Apps How many users are using a particular app such as Uniswap, OpenSea, etc. ### Tokens How many users hold a particular token such as USDC, USDT, etc. ### Feature Usage Rate The percentage of users or wallets who have used a specific feature of your app, helping you measure adoption and engagement for key functionalities. ### Conversion Rate Calculate the conversion rate of key user flows with the [Funnels](/features/product-analytics/funnels) feature. ### Funnels You can follow the visitor journey from a landing page to a conversion with funnel analysis. ### Retention Rate Retention rates: the percentage of customers who continue to use your service or product over a predetermined period (7, 30, 90 days.) ### Churn Rate The percentage of customers who stop using your service or product over a predetermined period. ### Customer Acquisition Cost (CAC) How much you've spent to acquire a customer. ### Average Revenue Per User (ARPU) How much revenue you've made from your site or app. This is a part of [revenue attribution tracking](/features/product-analytics/custom-events#step-3-track-with-volume-revenue-or-points). ### Customer Lifetime Value (CLTV) CLV estimates the total revenue a user generates over their entire engagement with your app. # What we collect Source: https://docs.formo.so/data/what-we-collect Understand exactly what data the Formo SDK collects, how it flows through the system, and how privacy is maintained with no cookies or fingerprinting. ### Data Flow This diagram shows how data flows from the user's browser through the Formo SDK to our infrastructure. ```mermaid theme={null} %%{init: {'themeVariables': {'background': '#ffffff'}}}%% flowchart TD subgraph Browser ["User's Browser"] SDK["Formo SDK"] Q["Event Queue
(batched, deduplicated)"] SDK --> Q end subgraph Formo ["Formo Infrastructure (AWS)"] API["events.formo.so
Ingestion API"] HASH["Session Hashing
hash(salt + domain + ip + ua)"] DISCARD["Raw IP discarded"] STORE["Encrypted Storage
(AES-256 at rest)"] API --> HASH HASH --> DISCARD HASH --> STORE end Q -- "HTTPS POST (TLS 1.2+)
Bearer token auth" --> API style Browser fill:#f0f9ff,stroke:#0D9373 style Formo fill:#f0fdf4,stroke:#0D9373 style DISCARD fill:#fef2f2,stroke:#dc2626 ``` **What the SDK sends:** event type, anonymous session ID, wallet address, chain ID, user agent, browser type, timezone, language, page URL, UTM parameters, screen and viewport dimensions (`screen_width`, `screen_height`, `screen_density`, `viewport_width`, `viewport_height`), referral parameters (`ref`, `referral`, `refcode`), and custom event properties. **What the SDK does NOT send:** IP addresses, third-party cookies, local storage data, device fingerprints, or social profiles. **What happens server-side:** The server receives the IP address via standard HTTP headers (this is true for any HTTP request to any server, including GA4 and Umami). Formo uses the IP address only to compute a daily-rotating session hash for session counting, then discards it. The raw IP address is never stored in logs or databases. Separately, the network edge resolves the IP to a country code (see [Country](#country)) and passes only that code to Formo; the IP itself never reaches our servers for this purpose. ### What We Do NOT Collect | Data type | Collected? | | :---------------------------------- | :--------------------------------------- | | IP addresses | **No** - discarded after session hashing | | Device fingerprints (Canvas, WebGL) | **No** | | Third-party cookies | **No** | | Email addresses | **No** | | Phone numbers | **No** | | Social profiles (Twitter, Discord) | **No** | | Passwords or credentials | **No** | | Local storage or IndexedDB contents | **No** | | Cross-site tracking identifiers | **No** | *** ### NO IP Addresses > We do NOT store IP addresses. Every HTTP request to any server (including GA4, Umami, and Plausible) includes the sender's IP address as part of the protocol. Our server uses the IP address **only** to compute the daily session hash above, then discards it. The raw IP address is never written to logs, databases, or anywhere on disk. ### NO Fingerprinting > We do NOT use device or browser fingerprinting. We identify visitors with a first-party anonymous ID (a random identifier stored in a first-party cookie) rather than by fingerprinting the device or browser. The overview **Visitors** metric counts unique first-party anonymous IDs. For sessions, we additionally derive a daily-rotating identifier on the server from standard HTTP headers: ``` hash(daily_salt + website_domain + ip_address + user_agent) ``` This session hash is a **one-way function** - it cannot be reversed to recover the original IP address or user-agent, and the salt rotates every 24 hours. ### NO Third-party Cookies > We do NOT read nor set any third-party cookies. We care about the privacy of your visitors. Cookies are something that can track visitors across multiple websites and domains. *We do not store, use, retrieve, nor extract third-party cookies from visitor's devices.* We do use first-party cookies to support cross-subdomain tracking. This is necessary to see when visitors go from your site (formo.so) to your app (app.formo.so). ### No PII > We do NOT collect personally identifiable information. Wallet addresses are **pseudo-anonymous identifiers** - similar to session IDs. They are not inherently linked to a person's real-world identity. However, we recognize that under certain privacy frameworks (including GDPR), wallet addresses that *can* be linked to an individual may be considered personal data. **How Formo handles this:** * The SDK collects wallet addresses as analytics identifiers, not for personal identification * We do **not** collect social profiles (Twitter, Discord, email) through the SDK * If the Formo dashboard displays ENS names or social handles, this data is resolved from **public data sources** (e.g. ENS registry, Farcaster) - not collected by the SDK from your users * We do not correlate wallet addresses with off-chain personal data, through probabilistic matching or any other means. Public blockchain data (wallet addresses, ENS names, onchain social profiles) is already visible to anyone via block explorers like Etherscan. Formo does not create new data linkages, it surfaces data that is publicly available. ### User Agent > We collect and store the user agent and browser type. The SDK sends the full user-agent string (`navigator.userAgent`) and a browser type (e.g. `chrome`, `firefox`, `safari`). Both are stored. User agents are standard browser identifiers that websites use to understand which browsers and devices visitors are using. They are not unique enough to identify individuals on their own. ### Screen Dimensions > We collect and store screen and viewport dimensions. We collect device screen and browser viewport dimensions to understand how visitors view your site: * `screen_width` - Width of the device screen in pixels * `screen_height` - Height of the device screen in pixels * `screen_density` - Pixel density of the device screen (devicePixelRatio) * `viewport_width` - Width of the browser viewport in pixels * `viewport_height` - Height of the browser viewport in pixels ### Timezone > We collect and store the timezone of each visitor. ### Country > We collect and store country of each visitor. We resolve visitor country server-side at the network edge, the same approach used by analytics tools like Google Analytics, Plausible, and OpenPanel. The edge performs an IP lookup and passes only the resolved ISO 3166-1 alpha-2 country code (for example, `US`, `GB`, `DE`) to Formo. The raw IP address never reaches our servers and is never stored. Country is the only geographic granularity we collect: we do not derive or store region or city. Where the edge cannot resolve a country, the value falls back to `unknown`. Previously, country was inferred from the browser timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) on the client side. Edge-based geolocation is more accurate because a single timezone can span multiple countries (for example, the UTC+7 band covers both Thailand and Vietnam). For events sent from a server-side SDK (`server`, `api`, or `import` channels), the location provided by the SDK is preserved, since the request IP belongs to your backend rather than the end user. ### Language > We collect and store language of each visitor. Devices are set to a certain language. We collect the language of the device being used by a visitor. ### URL > We collect and store URLs. We collect the URL to track page visits and referrers. ### Referrer > We collect and store referrers. Referrers answer the question "Where did this visitor come from?". Browsers send the URL of the previous website as a referrer. We also check UTM-parameters. You can see a list of your site's referrers in your dashboard. ### UTM > We collect and store UTM parameters. UTM parameters such as `utm_source`, `utm_medium`, and `utm_campaign`, are a way to track the source of a visitor. They are added to a URL to track where a visitor came from. We track these UTM codes: * `utm_source` (e.g.: google.com) * `utm_medium` (e.g.: search) * `utm_campaign` (e.g.: summer\_sale) * `utm_content` (e.g.: summer\_sale) * `utm_term` (e.g.: summer\_sale) ### Ad click IDs > We collect and store ad-platform click IDs. Ad platforms append a click identifier to your landing page URL when a user clicks an ad. We capture these parameters to attribute traffic and users to the acquiring ad network (see [Ads](/features/product-analytics/ads)): * `gclid` and `gad_source` (Google Ads) * `fbclid` (Meta Ads) * `msclkid` (Microsoft Ads) * `ttclid` (TikTok Ads) * `twclid` (X Ads) * `li_fat_id` (LinkedIn Ads) * `rdt_cid` (Reddit Ads) ### Referral > We collect and store referral parameters. Referral parameters such as `referral` and `ref` are a way to track who referred a visitor. They are added to a URL to track where a visitor came from. We track these UTM codes and assign it to each user: * `ref` * `referral` * `refcode` * `af` * `referrer` ### Wallet Provider > We collect and store the crypto wallet type of visitors. We collect the wallet type (EIP6963's `rdns` identifier) to identify the wallet type of the visitor. ### Wallet Address > We collect and store wallet addresses. We collect the wallet address to identify a visitor. ### Chain Id > We collect and store chain ids. We collect the chain id to identify the connected chain of the visitor. ### Wallet Connected > We collect and store wallet connected status. We collect the wallet connected status to identify if a visitor has connected their wallet. ### Wallet Metadata > We collect and store signature and transaction metadata. We collect signature and transaction metadata such as statuses (confirmed, reverted, rejected, etc.) and transaction hashes. ### Form Data > Form data is determined by you, the Customer. If you use [Token Gated Forms](/features/token-gated-forms/form-builder), any data submitted by end users through your forms is collected and stored. The content of this data is determined entirely by the form fields you create. You are responsible for ensuring that sensitive data is not collected through forms without appropriate legal basis and explicit consent. ## FAQ Formo resolves the visitor's country server-side at the network edge, which performs an IP lookup and passes only the resolved ISO 3166-1 alpha-2 country code to our servers. The raw IP address never reaches Formo and is never stored. We collect country only: never region or city. (Earlier versions inferred country from the browser timezone on the client side; edge geolocation replaced this because a single timezone can span multiple countries.) Formo uses first-party cookies for anonymous visitor identification and cross-subdomain tracking (e.g., an `anonymous-id` cookie and session cookies). These are first-party cookies scoped to your domain only. Formo does **not** set any third-party cookies or cross-site tracking cookies. Because Formo does not use third-party cookies, most jurisdictions do not require a cookie consent banner, but consult your legal counsel for your specific case. # Activity feed Source: https://docs.formo.so/features/product-analytics/activity View a real-time event stream of everything users do on your app, from page views to onchain transactions. The Activity page shows a real-time view into what users are doing on your app. You can also track [custom events](/features/product-analytics/custom-events) and [contract events](/features/product-analytics/contract-events). Product Analytics Activity Every event includes full properties and is captured without sampling. ## Filter by event properties Filter the activity feed by event properties to focus on specific user actions or behaviors. Product Analytics Activity Use filters to narrow events by any property to investigate a specific user journey or troubleshoot an issue. *** ## How to use the activity feed This section covers using the Activity Feed for investigation and user research. ### Step 1: Open the Activity Feed Open [app.formo.so](https://app.formo.so). You'll see a chronological stream of all events on your app. ### Step 2: Understand event types Each event has a structured schema and type based on the [event specs](/data/events/overview): | Event Type | What it captures | | ------------- | ------------------------- | | `page` | Page view with URL path | | `connect` | Wallet connection | | `disconnect` | Wallet disconnection | | `chain` | Chain switches | | `signature` | Message signing | | `transaction` | Onchain transaction | | `track` | Custom events | | `identify` | User identification | | `detect` | Wallet provider detection | | `decoded_log` | Smart contract events | Click any event to expand and see all properties. Product Analytics Event Properties ### Step 3: Filter to find specific events Use filters to narrow down the feed: **Common filter combinations:** | Goal | Filters | | ---------------------------------- | ----------------------------------------- | | Connects from Twitter | type = connect, referrer contains twitter | | Visitors from a marketing campaign | utm\_source contains newsletter | | Events from mobile devices | device = mobile | | Events on a specific page | page = /swap | | High-volume wallets | volume > 1000 | Activity Filters ### Step 4: Investigate a user's journey To see everything a specific user did: See [Wallet Profiles](/features/wallet-intelligence/wallet-profiles) for more details. Wallet Profile Activity Feed Or search the Activity Feed directly: ### Step 5: Investigate issues When users report problems, use the Activity Feed to investigate: **Investigating a failed transaction:** **Investigating a drop-off:** See [Funnels](/features/product-analytics/funnels) for more details. **Investigating a flow issue:** See [Flows](/features/product-analytics/flows) for more details. ### Common investigation patterns **"Why are users bouncing from our swap page?"** **"Which transactions are failing?"** **"What do our best users do differently?"** **"Is our new feature being used?"** # Ad Attribution Source: https://docs.formo.so/features/product-analytics/ads Measure which ads acquire visitors, wallets, and conversions. Formo attributes traffic from Google, Meta, X, TikTok, LinkedIn, Reddit, and Microsoft ads automatically using click IDs. Formo attributes traffic and users to the ad network that acquired them. When someone clicks your ad, the platform appends a click ID to your landing page URL; Formo captures it automatically and every dashboard surface can measure ad performance. ## Supported ad platforms | Platform | Click ID parameter | | ------------- | ----------------------------------------------------------------------------- | | Google Ads | `gclid`, `gad_source` | | Meta Ads | `fbclid` | | Microsoft Ads | `msclkid` | | TikTok Ads | `ttclid` | | X Ads | `twclid` | | LinkedIn Ads | `li_fat_id` | | Reddit Ads | `rdt_cid` | | Other Paid | any paid `utm_medium` (`cpc`, `cpm`, `ppc`, `paid*`, etc.) without a click ID | There is nothing to configure. Keep auto-tagging enabled on your ad platform (it is the default on all of them) and the click IDs arrive on your landing URLs. We recommend adding UTM parameters as well to give you campaign-level detail on top of the network-level attribution; see the [UTM guide](https://docs.formo.so/guides/onchain-attribution#utm-parameter-basics). ## How it works * **Sessions** attribute to the session's first paid touch. An organic landing followed by an ad click in the same session still counts for the ad network. * **Users** carry first-touch and last-touch ad attribution across their whole history, so you can analyze acquisition (first touch) or conversion influence (last touch). * Click IDs also feed [channel classification](/data/attribution): a click from a known ad platform classifies as Paid Search or Paid Social even when the referrer is missing (common with in-app browsers). * Ads is one level more granular than [Channels](/data/attribution): the Paid Search and Paid Social channels group networks by ad format, while Ads shows the individual network. ## How to measure ad performance * **Overview → Acquisition card → Ads tab**: paid sessions and visitors by network. Click a row to jump to the Users page filtered to that network. * **Overview chart breakdown**: break Visitors, Sessions, Page views, Wallets, or Transactions down by Ads (Acquisition group in the breakdown picker). * **Users page**: filter by **Ads** (network) or **Ad Click ID** (raw token) in first-touch, last-touch, or any-touch mode. The users table has an **Ad** column, and each profile shows **Ad** and **Ad Click ID** rows with first and last touch. * **Activity page**: filter the event feed and chart by Ads or Ad Click ID. * **Funnels**: break conversion funnels down by Ads to compare conversion rates per network, or restrict a funnel to a saved segment with an Ads filter. * **Retention**: segment retention cohorts by Ads. * **API**: `/v0/top_sources?metric_column=paid_source` for the breakdown, and `/v0/profiles` returns and filters the user-level attribution columns. See the [API reference](/api). # Ask AI Source: https://docs.formo.so/features/product-analytics/ai Ask questions about your analytics data in natural language and get AI-generated SQL queries, charts, and growth insights without writing code. **Ask AI** is an AI assistant that explores your analytics data, generates SQL queries, and builds charts from natural language questions. Ask AI ## How it works The Ask AI feature provides an intelligent analytics assistant that can: * **Generate queries**: Ask questions in natural language and get SQL queries automatically generated * **Explore patterns**: Discover trends and patterns in your data that might not be immediately obvious * **Surface anomalies**: Identify unusual behavior or outliers in your metrics * **Recommend actions**: Get recommendations for what to do next based on your data * **Build charts**: Create [custom charts](/features/product-analytics/charts) and save them to your dashboard * **Text to SQL**: Write and fix SQL code based on your instructions ## Access Ask AI Use Ask AI from anywhere on the dashboard. Ask it any question about your data, or ask it to draw custom charts for your dashboard. ## Getting started 1. Click **Ask AI** from anywhere in the dashboard 2. Type your question in natural language, such as: * "What are my top performing acquisition channels this month?" * "Show me users who dropped off in the signup funnel" * "Which wallets have the highest transaction volume?" * "Are there any unusual patterns in my recent activity?" 3. Review the generated query and results 4. Save useful charts to your dashboard ## Use cases ### Data exploration Ask AI can help you explore your data without needing to know SQL or complex analytics queries. Simply describe what you want to understand, and the AI will generate the appropriate analysis. ### Pattern recognition The AI can identify trends, seasonality, and patterns in your user behavior, transaction data, and conversion metrics that might be difficult to spot manually. ### Anomaly detection Get alerts about unusual spikes, drops, or changes in your key metrics, along with potential explanations for what might be causing them. ### Actionable insights Ask AI provides context and recommended next actions based on what it finds, not just raw data. ## Best practices * **Be specific**: The more specific your questions, the better the AI can help you * **Provide context**: Mention time periods, [user segments](/features/wallet-intelligence/segments), or specific metrics you're interested in * **Follow up**: Ask follow-up questions to explore interesting findings further * **Validate insights**: Always review the AI's findings and validate important insights with your domain knowledge * **Specify time ranges**: Use "in the last 7 days" instead of "recently" * **Name metrics clearly**: Use "daily active wallets" instead of "users" * **Include thresholds**: Use "net worth > \$10,000" instead of "wealthy users" * **Ask for comparisons**: Use "Compare X to Y" instead of "Show me X" ## Sample questions For a full list of example questions and prompts, see the [How to use Ask AI](/guides/ask-ai#example-questions) guide. ## Memory Memory is a set of durable facts that Ask AI recalls across every chat, so you don't have to re-explain your product each time. Use it to store product context, metric definitions, naming conventions, and preferences that shape how the AI queries your data and answers questions. Ask AI memory settings Good facts to add include: * **Product context**: "Formo is an analytics and attribution platform for DeFi apps." * **Metric definitions**: "Our activation event is `swap_completed`." * **Funnels and naming**: "Our core funnel is: Signup → Project Activated → Upgrade." * **Preferences**: default time ranges, the segments you care about, or how you want results formatted. Ask AI applies these facts automatically, so questions can stay short and still return results that match how your team defines things. ### Manage your memory 1. Go to **Workspace settings → Memory** 2. Type a fact in the **Add a fact** field and click **Add** 3. Remove a fact anytime with the **×** next to it The usage bar shows how much of your available memory you've used. Keep facts short and specific: one clear statement per entry works best. # Alerts Source: https://docs.formo.so/features/product-analytics/alerts Set up real-time alerts for high-value users, high-value transactions, and key user events, delivered via webhook or Slack. Alerts Get notified in real time when high-value whales and important user actions happen: * Key user events (connect wallet, transactions, conversions, drop off) * When whales and high-value users visit your app In the project settings page, you can create an alert that will notify you via: * Webhooks (including Slack via incoming webhooks) *** ## How to set up your first alert Get real-time notifications when high-value users interact with your app. This guide walks you through creating alerts for whale detection and key events. ### Step 1: Navigate to Alerts 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Settings** in the left navigation (gear icon) 4. Select the **Alerts** tab ### Step 2: Create a new alert 1. Click **Create Alert** 2. Configure your alert: | Setting | Description | | ---------------- | ----------------------------------------------------------- | | **Name** | Descriptive name (e.g., "Whale Alert") | | **Trigger Type** | Event or User (see below) | | **Conditions** | Filter conditions (optional) | | **Notification** | Webhook (generic HTTP or Slack via an incoming-webhook URL) | ### Step 3: Choose a trigger type Formo supports two trigger types: **Event alerts**, which fire on individual matching events, and **User alerts**, which fire when a user profile matches your filters. Triggers when a new event matches your filters. Formo checks for matching events every 5 minutes; each matching event since the last check is sent to your webhook. | Event Type | When it fires | | --------------- | --------------------------- | | **connect** | User connects their wallet | | **transaction** | User submits a transaction | | **page** | User visits a specific page | | **custom** | Your custom tracked events | You can add conditions to filter on the event's top-level fields: `type`, `event`, `address`, `user_id`, `anonymous_id`, `location`, `device`, `browser`, `os`, and `referrer`. Conditions support exact match, contains, starts with, ends with, and not-equals (e.g., `browser = Chrome`, `referrer contains twitter`). They can't filter on values nested in `properties` (like `chain_id` or a transaction's `status`) or use numeric comparisons (`>`, `<`); use a User alert if you need to filter on a number. Triggers when a user profile matches your filters. Formo checks for matching users every 5 minutes and sends a notification for each new user that meets your filters. Each matching user is **notified once per 24 hours**: if the same user remains active and continues to match, they won't be re-sent until the dedup window expires. Available user filters: | Filter | Description | | ------------------- | ------------------------------------------- | | **net\_worth\_usd** | Wallet net worth (e.g., > \$100,000) | | **volume** | Trading volume | | **revenue** | Revenue generated | | **points** | Loyalty points | | **location** | Country code | | **device** | Device type (desktop, mobile) | | **browser** | Browser name | | **os** | Operating system | | **referrer / UTM** | Acquisition source filters | | **apps** | Specific onchain app usage | | **tokens** | Token holdings | | **chains** | Chain activity | | **labels** | Wallet labels (e.g., Coinbase verified) | | **lifecycle** | User lifecycle stage (New, Returning, etc.) | | **socials** | Social profiles (e.g., Farcaster) | `net_worth_usd`, `volume`, `revenue`, `points`, **apps**, **tokens**, and **chains** support numeric comparisons (`>`, `>=`, `<`, `<=`, `=`) against a usage threshold (e.g. chain activity > 10 transactions); the rest match on equality. ### Step 4: Configure notifications 1. Select **Webhook** as the notification type 2. Enter your webhook URL (generic HTTPS endpoint or Slack incoming webhook) 3. Formo sends a POST request with batched data **Webhook security and signatures:** * Optional **secret**: In the alert configuration, you can provide a signing secret used to verify requests on your server. * For **generic webhooks** (non-Slack URLs), Formo uses your secret to compute an HMAC-SHA256 signature over the string `{timestamp}.{body}`, where: * `timestamp` is the Unix timestamp (seconds) used for the request * `body` is the exact JSON payload string sent in the request **HTTP headers sent:** | Header | Description | | --------------------- | --------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Formo-Alerts/1.0` | | `X-Formo-Alert-Id` | The alert ID in Formo | | `X-Webhook-Signature` | HMAC-SHA256 signature (generic webhooks with a secret only) | | `X-Webhook-Timestamp` | Unix timestamp used in the signature (generic webhooks with a secret only) | | `X-Webhook-Event` | `alert.event.triggered` or `alert.user.triggered` (generic webhooks with a secret only) | 1. Create a [Slack incoming webhook](https://api.slack.com/messaging/webhooks) 2. Use the Slack webhook URL as your Formo webhook URL 3. Formo auto-detects `hooks.slack.com` URLs and formats messages as Slack Block Kit 4. Messages post to your configured Slack channel with a formatted alert summary ### Step 5: Test your alert 1. Save your alert configuration 2. Trigger a test event: * Visit your app in a browser * Connect a wallet * Perform the action that matches your alert 3. Check your Slack channel or webhook endpoint for the notification *** ## Alert payloads ### Event alerts When an event alert is triggered, Formo batches all matching events into a single webhook call. #### Webhook payload The webhook payload uses the `alert.event.triggered` event type and contains raw analytics events: **Top-level properties:** | Property | Type | Description | | --------- | ------ | ------------------------------------------------------- | | `id` | string | Unique event ID (e.g., `evt_3f7f9c4e-...`) | | `type` | string | Always `alert.event.triggered` | | `created` | number | Unix timestamp (seconds) when the webhook was created | | `data` | array | Array of raw analytics events that matched your filters | **Each event in `data`:** | Property | Type | Description | | ----------------- | ------ | ------------------------------------------------------------ | | `project_id` | string | Your Formo project ID | | `session_id` | string | User session ID | | `channel` | string | Event channel (e.g., `web`, `import`) | | `type` | string | Event type (e.g., `connect`, `transaction`, `page`, `track`) | | `anonymous_id` | string | Anonymous user ID | | `user_id` | string | Identified user ID (if available) | | `address` | string | Wallet address (if available) | | `event` | string | Event name | | `context` | object | Context data (IP, user agent) | | `properties` | object | Event-specific properties | | `version` | string | SDK version | | `timestamp` | string | ISO 8601 timestamp | | `message_id` | string | Unique message ID | | `origin` | string | Origin domain | | `locale` | string | User locale | | `location` | string | Country code | | `timezone` | string | User timezone | | `page_path` | string | Page path | | `page_title` | string | Page title | | `page_url` | string | Full page URL | | `page_query` | string | URL query string | | `page_hash` | string | URL hash | | `library_name` | string | SDK library name | | `library_version` | string | SDK library version | | `referrer_url` | string | Full referrer URL | | `referrer` | string | Referrer domain | | `ref` | string | Ref parameter | | `utm_source` | string | UTM source | | `utm_medium` | string | UTM medium | | `utm_campaign` | string | UTM campaign | | `utm_term` | string | UTM term | | `utm_content` | string | UTM content | | `user_agent` | string | Raw user agent string | | `device` | string | Device type (e.g., `desktop`, `mobile`) | | `browser` | string | Browser name | | `os` | string | Operating system | **Sample payload:** ```json theme={null} { "id": "evt_3f7f9c4e-8b2c-4d1f-9a4b-4a2e9c1d2f01", "type": "alert.event.triggered", "created": 1739097600, "data": [ { "project_id": "proj_123", "session_id": "sess_456", "channel": "web", "type": "connect", "anonymous_id": "4b48c7b6-3d61-409f-b695-1d9452954d6b", "user_id": "", "address": "0xF04bC8FdFC8b1c03Fa77885574Ae6Ea041E26bdc", "event": "Connected wallet", "context": { "ip": "203.0.113.10", "user_agent": "Mozilla/5.0 ..." }, "properties": { "chain_id": 84532, "net_worth": 250000 }, "version": "1", "timestamp": "2026-02-09T06:42:16.519Z", "message_id": "msg_01J8ABCDEF1234567890", "origin": "app.formo.so", "locale": "en-US", "location": "VN", "timezone": "Asia/Saigon", "page_path": "/pricing", "page_title": "Pricing", "page_url": "https://app.formo.so/pricing", "page_query": "", "page_hash": "", "library_name": "@formo/sdk-js", "library_version": "1.0.0", "referrer_url": "https://google.com/", "referrer": "google.com", "ref": "", "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "whale-acquisition", "utm_term": "", "utm_content": "", "user_agent": "Mozilla/5.0 (...)", "device": "desktop", "browser": "Chrome", "os": "MacOS" } ] } ``` #### Slack payload For Slack incoming webhooks (`hooks.slack.com`), Formo formats each event as an individual Slack Block Kit card message. Each message includes: * A **header** with the alert name * A **user** link (clickable link to Formo profile, if available) * **Event properties** (key-value pairs from the event) * **Metadata fields**: Country, Device, Browser, OS, Referrer, Referral, UTM Source, UTM Medium, UTM Campaign, UTM Term, UTM Content - only shown when non-empty **Sample Slack Block Kit payload (one event = one message):** ```json theme={null} { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": ":bell: Whale Alert", "emoji": true } }, { "type": "section", "text": { "type": "mrkdwn", "text": "" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*chain_id:* 84532\n*net_worth:* 250000" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Country:* Vietnam\n*Device:* Desktop\n*Browser:* Chrome\n*OS:* MacOS\n*Referrer:* google.com\n*UTM Source:* google\n*UTM Medium:* cpc\n*UTM Campaign:* whale-acquisition" } } ] } ``` *** ### User alerts When a user alert is triggered, Formo sends matching user profiles to your webhook. #### Webhook payload The webhook payload uses the `alert.user.triggered` event type: **Top-level properties:** | Property | Type | Description | | --------- | ------ | ----------------------------------------------------- | | `id` | string | Unique event ID (e.g., `evt_3f7f9c4e-...`) | | `type` | string | Always `alert.user.triggered` | | `created` | number | Unix timestamp (seconds) when the webhook was created | | `data` | array | Array of user profiles that matched your filters | **Each user in `data`** contains the full wallet profile. All fields from the profile are included; key properties: **Wallet identity:** | Property | Type | Description | | --------------- | -------------- | ---------------------------------------- | | `address` | string | Wallet address | | `net_worth_usd` | number | Wallet net worth in USD | | `tx_count` | number | Total transaction count | | `first_onchain` | string | First on-chain transaction timestamp | | `last_onchain` | string | Last on-chain transaction timestamp | | `updated_at` | string | When the wallet profile was last updated | | `ens` | string | ENS name | | `farcaster` | string | Farcaster username | | `lens` | string | Lens handle | | `basenames` | string | Base name | | `linea` | string | Linea name | | `avatar` | string \| null | ENS/Farcaster avatar URL | | `display_name` | string | Display name | | `description` | string | Profile description | | `profile_url` | string | Link to user profile in Formo dashboard | **Socials** (included when available): `discord`, `telegram`, `website`, `github`, `twitter`, `linkedin`, `email`, `instagram`, `facebook`, `tiktok`, `youtube`, `reddit`, `linea` **Project-level engagement:** | Property | Type | Description | | ---------------- | --------- | --------------------------------------------------------------------------------------- | | `first_seen` | string | When the user was first seen (UTC) | | `last_seen` | string | When the user was last active (UTC) | | `num_sessions` | number | Total unique sessions | | `revenue` | number | Total revenue | | `volume` | number | Total transaction volume | | `points` | number | Total loyalty points | | `lifecycle` | string | Lifecycle stage (`New`, `Returning`, `Power user`, `Resurrected`, `At Risk`, `Churned`) | | `activity_dates` | string\[] | Array of dates the user was active | | `location` | string | Country code | | `device` | string | Device type | | `browser` | string | Browser name | | `os` | string | Operating system | **Attribution (first-touch and last-touch):** | Property | Type | Description | | -------------------- | ------ | ----------------------------- | | `first_referrer` | string | First-touch referrer domain | | `first_referrer_url` | string | First-touch full referrer URL | | `first_ref` | string | First-touch referral code | | `first_utm_source` | string | First-touch UTM source | | `first_utm_medium` | string | First-touch UTM medium | | `first_utm_campaign` | string | First-touch UTM campaign | | `first_utm_term` | string | First-touch UTM term | | `first_utm_content` | string | First-touch UTM content | | `last_referrer` | string | Last-touch referrer domain | | `last_referrer_url` | string | Last-touch full referrer URL | | `last_ref` | string | Last-touch referral code | | `last_utm_source` | string | Last-touch UTM source | | `last_utm_medium` | string | Last-touch UTM medium | | `last_utm_campaign` | string | Last-touch UTM campaign | | `last_utm_term` | string | Last-touch UTM term | | `last_utm_content` | string | Last-touch UTM content | **Last activity:** | Property | Type | Description | | ----------------- | ------ | -------------------------------------------------- | | `last_type` | string | Last event type (e.g., `connect`, `page`, `track`) | | `last_event` | string | Last event name | | `last_properties` | string | JSON-encoded properties of the last event | **Sample payload:** ```json theme={null} { "id": "evt_a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "type": "alert.user.triggered", "created": 1739097600, "data": [ { "address": "0xF04bC8FdFC8b1c03Fa77885574Ae6Ea041E26bdc", "net_worth_usd": 250000, "ens": "whale.eth", "farcaster": "", "lens": "", "basenames": "", "linea": "", "discord": "", "telegram": "", "website": "", "github": "", "twitter": "whale_trader", "linkedin": "", "email": "", "instagram": "", "facebook": "", "tiktok": "", "youtube": "", "reddit": "", "avatar": "https://euc.li/whale.eth", "display_name": "Whale", "description": "", "updated_at": "2026-02-09 06:40:00", "tx_count": 142, "first_onchain": "2022-03-15 12:00:00", "last_onchain": "2026-02-09 06:40:00", "profile_url": "https://app.formo.so/teams/team_1/projects/proj_123/users/0xF04bC8FdFC8b1c03Fa77885574Ae6Ea041E26bdc", "first_seen": "2025-11-15 08:30:00", "last_seen": "2026-02-09 06:42:16", "num_sessions": 47, "revenue": 5200.50, "volume": 89000.00, "points": 1500, "lifecycle": "Returning", "activity_dates": ["2026-02-09", "2026-02-08", "2026-02-05"], "location": "VN", "device": "desktop", "browser": "Chrome", "os": "MacOS", "first_referrer": "google.com", "first_referrer_url": "https://google.com/search?q=formo", "first_ref": "", "first_utm_source": "google", "first_utm_medium": "cpc", "first_utm_campaign": "whale-acquisition", "first_utm_term": "", "first_utm_content": "", "last_referrer": "twitter.com", "last_referrer_url": "https://twitter.com/formo", "last_ref": "", "last_utm_source": "twitter", "last_utm_medium": "social", "last_utm_campaign": "", "last_utm_term": "", "last_utm_content": "", "last_type": "connect", "last_event": "Connected wallet", "last_properties": "{\"chain_id\":8453}" } ] } ``` #### Slack payload For Slack incoming webhooks, Formo formats user profiles as a rich card layout: * A **header** with the alert name * Per user: a clickable address header with avatar image (if available), a **field grid** with key profile data, and a **View Profile** button * Up to **25 users** per batch, with an overflow summary if more were found **Default fields displayed:** | Field | Source | Conditional | | ---------- | ---------------------------------------------- | ------------------------------- | | Lifecycle | `lifecycle` (e.g., New, Returning, Power user) | Yes - only shown when non-empty | | Net Worth | `net_worth_usd` (formatted as `$1,234`) | No - always shown | | First Seen | `first_seen` (formatted date) | No - always shown | | Location | `location` | Yes - only shown when non-empty | | Browser | `browser` | Yes - only shown when non-empty | | Device | `device` | Yes - only shown when non-empty | | OS | `os` | Yes - only shown when non-empty | | Referrer | `first_referrer` or `last_referrer` | Yes - only shown when non-empty | | Referral | `first_ref` or `last_ref` | Yes - only shown when non-empty | | UTM | `first_utm_*` or `last_utm_*` (comma-joined) | Yes - only shown when non-empty | **Sample Slack Block Kit payload:** ```json theme={null} { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": ":bell: Whale Alert", "emoji": true } }, { "type": "section", "text": { "type": "mrkdwn", "text": "**" }, "accessory": { "type": "image", "image_url": "https://euc.li/whale.eth", "alt_text": "0xF04bC8FdFC8b1c03Fa77885574Ae6Ea041E26bdc" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Lifecycle:* Returning" }, { "type": "mrkdwn", "text": "*Net Worth:* $250,000" }, { "type": "mrkdwn", "text": "*First Seen:* Nov 15, 2025" }, { "type": "mrkdwn", "text": "*Location:* VN" }, { "type": "mrkdwn", "text": "*Browser:* Chrome" }, { "type": "mrkdwn", "text": "*Device:* desktop" }, { "type": "mrkdwn", "text": "*OS:* MacOS" }, { "type": "mrkdwn", "text": "*Referrer:* google.com" }, { "type": "mrkdwn", "text": "*UTM:* google, cpc, whale-acquisition" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "emoji": true, "text": "View Profile" }, "style": "primary", "url": "https://app.formo.so/teams/team_1/projects/proj_123/users/0xF04bC8FdFC8b1c03Fa77885574Ae6Ea041E26bdc", "value": "0xF04bC8FdFC8b1c03Fa77885574Ae6Ea041E26bdc" } ] } ] } ``` *** ## Examples ### Whale detection alert Notify when high-value users visit: | Setting | Value | | ------------ | ------------------------ | | Name | Whale Alert | | Trigger type | Users | | Condition | net\_worth\_usd > 100000 | | Notification | Slack webhook | Every time a wallet with over \$100k net worth is active, you'll get a Slack message with their profile. ### Failed transaction alert Track when users encounter issues: | Setting | Value | | ------------ | ------------------ | | Name | Failed TX Alert | | Trigger type | Events | | Condition | type = transaction | | Notification | Webhook | `status` lives in the event's `properties` object, not a filterable top-level field, so this condition sends every transaction event. Filter for `properties.status = "failed"` on your webhook receiver to isolate failures. ### Webhook integration examples **Send to Slack:** ```bash theme={null} # Your Slack webhook URL https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX ``` **Send to Zapier:** ```bash theme={null} # Your Zapier webhook URL https://hooks.zapier.com/hooks/catch/123456/abcdef/ ``` Then use Zapier to route alerts to any destination: Notion, Airtable, Discord, SMS, etc. *** # Charts Source: https://docs.formo.so/features/product-analytics/charts Build custom dashboards with line, bar, pie, and funnel charts to visualize your data. Custom dashboards You can query, transform, and export your data: * Create custom charts and reports with SQL. * Use [Ask AI](/features/product-analytics/ai) to build charts and explore your data with natural language. * Query your data from Claude, Cursor, or other AI tools via [MCP](/mcp/overview). * Connect external [BI tools](/data/bi) directly to Formo. ## Features ### Autocomplete The query editor supports autocomplete for SQL including: * Table names * Table columns * ClickHouse SQL keywords and functions Custom dashboard ### Text to SQL The query editor comes with tools to ask AI to write and fix SQL code for you. Custom dashboard Select your query in the SQL editor to get AI-assisted query generation and debugging. ### Ask AI Use [Ask AI](/features/product-analytics/ai) to build charts and explore your data with natural language. Custom dashboard Ask AI can: * Answer questions about your data * Generate SQL queries * Build custom charts and boards * Surface patterns in your data ### Export CSV Export the results of any query into a CSV file for use elsewhere in your data stack. ### Dune Integration Charts can query on-chain data from [Dune](/integrations/dune). ```sql theme={null} SELECT date_trunc('day', block_time) AS day, count(*) AS txns FROM dune.ethereum.transactions WHERE block_time > now() - interval '7' day GROUP BY 1 ORDER BY 1 ``` Autocomplete switches to Dune (Trino) functions and tables while you're writing a `dune` query. Dune charts require a Dune API key set in **Project Settings → Integrations**. See [Dune integration](/integrations/dune) for setup instructions. ## Chart types Here is the current list of supported chart types on Formo: * Funnel * Flow * Retention * Table * Number * Bar * Stacked Bar * Line * Area * Pie *** ## How to build a custom dashboard Create a custom dashboard with charts to track your key metrics. This guide walks you through creating charts using both SQL and the AI assistant. ### Step 1: Create a board 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Dashboards** in the left navigation 4. Click **Add board** to create a new dashboard 5. Click **Add Chart** to add your first visualization ### Step 2: Create a chart with Ask AI (Recommended) The easiest way to create charts is using natural language: 1. Click **Ask AI** in the sidebar 2. Describe what you want to see: * "Show me daily active users over the last 30 days" * "What are my top 10 referrers by transaction count?" * "Chart wallet connects by country this month" 3. AI generates the SQL query and chart 4. Click **Add to Dashboard** Ask AI **Example prompts:** | Prompt | What you get | | --------------------------------------------- | ----------------------------- | | "Daily unique wallets last 7 days" | Line chart of wallet connects | | "Top pages by views" | Bar chart of page rankings | | "Conversion funnel from visit to transaction" | Funnel visualization | | "Week-over-week retention" | Retention cohort table | ### Step 3: Create a chart with SQL For more control, write SQL directly: 1. Click **Add Chart** > **Blank chart** 2. Write your query using Formo's schema 3. Click **Run** to preview results 4. Choose your visualization type 5. Click **Save Chart** **Example: Daily active wallets** ```sql theme={null} SELECT toDate(timestamp) AS date, countDistinct(address) AS unique_wallets FROM events WHERE type = 'connect' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY date ORDER BY date ``` SQL Editor Use autocomplete (`Ctrl+Space`) to see available tables and columns. See the [Data Catalog](/data/catalog) for full documentation. ### Step 4: Choose a visualization After running your query, select the chart type that best represents your data: | Chart Type | Best for | | --------------- | ------------------------------------ | | **Line** | Trends over time | | **Area** | Cumulative trends over time | | **Bar** | Comparing categories | | **Stacked Bar** | Comparing categories across segments | | **Pie** | Showing proportions | | **Number** | Single KPI metric | | **Table** | Detailed data exploration | | **Funnel** | Conversion analysis | | **Flow** | User path exploration | | **Retention** | Cohort retention | ### Step 5: Organize your board Arrange charts into a dashboard layout: 1. Open your board from **Dashboards** in the sidebar 2. Open the board actions menu and select **Edit layout** 3. Use the up and down arrows on each chart to reorder it 4. Click **Save layout** when you're done Number charts are grouped automatically into their own row at the top of the board; other chart types follow in the order you set. ### Step 6: Share your dashboard Share insights with your team: * **Export** - Download individual charts as CSV * **Share link** - Generate a view-only dashboard link * **BI tools** - Connect Metabase, Tableau, or other tools via [direct database access](/data/bi) ### Example: Weekly metrics dashboard Here's a starter dashboard with common metrics: | Chart | Query description | Type | | ----------------- | ----------------------------- | ------ | | WAU | Unique wallets last 7 days | Number | | Daily trend | Visitors per day last 30 days | Line | | Top referrers | Referrers ranked by visitors | Bar | | Conversion rate | Visitors who transacted | Number | | Country breakdown | Users by country | Pie | Use Ask AI to generate each chart, then arrange them on your dashboard. # Contract events Source: https://docs.formo.so/features/product-analytics/contract-events Add contracts to ingest and decode onchain events like swaps, transfers, and mints in real time across supported EVM chains. Add your contract in the project settings page to start ingesting contract events. Product Analytics Contract Events Once ingested, you will see contract events on the [Activity Feed](/features/product-analytics/activity) and [Wallet Profile](/features/wallet-intelligence/wallet-profiles) in real time. Contract events can be used as steps when creating [Funnels](/features/product-analytics/funnels). *** ## How to track smart contract events Monitor your smart contract activity in real time. This guide walks you through adding contracts, selecting events, and using contract data in analytics. ### Step 1: Navigate to Project Settings page 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Settings** in the left navigation 4. Select the **Contracts** tab ### Step 2: Add a contract and select events to track 1. Click **Add Contract** 2. Fill in the contract details: | Field | Description | Example | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | **Chain** | The blockchain network | Ethereum, Base, Arbitrum | | **Contract Address** | Your contract's address | `0x1234...abcd` | | **Name** | Friendly name for reference. Auto-filled from the verified contract source when available; edit if you want a different label. | "Swap Router" | | **ABI** | Required to decode events. Formo automatically fetches it once the address and chain are set (Etherscan, then Sourcify as a fallback); paste it manually if auto-detection fails. | | 3. Switch on the **Ingest contract events** toggle to reveal the list of available events from the ABI 4. Toggle on the events you want to track 5. Click **Add contract** Adding a Contract and selecting Contract Events For verified contracts, Formo auto-detects the ABI. For unverified contracts, you can paste the ABI manually. You can also come back and change your event selection later by reopening the contract from the list. ### Step 3: Deploy the pipeline Selecting events and saving the contract doesn't start ingestion by itself; the events pipeline needs to be deployed. 1. In the Contracts settings page, click **Deploy** (or **Redeploy** if a pipeline is already live) 2. Track progress via the pipeline status badge: **Draft**, **Live**, **Paused**, **Inactive**, or **Error** See the [contract events guide](/guides/contract-events#pipeline-management) for details on managing pipeline state. ### Step 4: View events in Activity Feed Once the pipeline is live, contract events appear in your Activity Feed: 1. Go to **Activity** in the left navigation 2. Filter by the **Contract event** event type to see contract events 3. Click any event to see decoded parameters including transaction hash, block number, function name and function parameters ### Step 5: Use contract events in funnels Build funnels that include onchain actions: 1. Go to **Funnels** and click **Create Funnel** 2. Add steps mixing offchain and onchain events: * Step 1: `page` view (visit homepage) * Step 2: `connect` (connect wallet) * Step 3: `transaction` (successful tx) * Step 4: `SwapCompleted` (contract event) This gives you true end-to-end conversion tracking from first visit offchain to conversions onchain. ### Example: DEX swap tracking Track user journey from landing page to completed swap: | Step | Event Type | Description | | ---- | -------------------- | -------------------- | | 1 | `page` | User lands on /swap | | 2 | `connect` | User connects wallet | | 3 | Contract: `Approval` | User approves token | | 4 | Contract: `Swap` | Swap executed | # Custom events Source: https://docs.formo.so/features/product-analytics/custom-events Define and track custom events like swaps, deposits, and quests with structured properties using the Formo SDK Track API. ## Overview The Formo SDK offers an easy-to-use event collection library that allows you to track custom events in your crypto app. See the [Web SDK](/sdks/web#track-events) docs to get started. *** ## How to track custom events While Formo autocaptures page views, wallet connects, and transactions, custom events let you track specific actions that matter to your app. ### When to use custom events Track actions that aren't captured automatically: | Action | Why track it | | ------------------- | ------------------------- | | Swap submissions | Measure swap conversion | | Deposit completions | Track liquidity flows | | Feature usage | Understand adoption | | Errors | Debug user issues | | Key conversions | Measure business outcomes | ### Step 1: Import the Formo SDK ```tsx theme={null} import { useFormo } from '@formo/analytics'; function YourComponent() { const analytics = useFormo(); // Now you can track events } ``` The `formo` object is available globally after the snippet loads: ```javascript theme={null} // Track an event window.formo.track('Swap Completed', { pair: 'ETH/USDC' }); ``` ### Step 2: Track a custom event Use the `track` function with an event name and optional properties: ```typescript theme={null} analytics.track('Swap Completed', { pool_id: 'ETH/USDC', amount: 1000, slippage: 0.5 }); ``` **Event parameters:** * **Event name** (required): Descriptive name for the action * **Properties** (optional): Key-value pairs with additional context ### Step 3: Track with volume, revenue, or points For events with monetary value, use the reserved `volume`, `revenue`, and `points` properties: ```typescript theme={null} analytics.track('Swap Completed', { pair: 'ETH/USDC', volume: 5000, // volume of the swap; can be positive or negative revenue: 25, // revenue earned (e.g., fees); must be non-negative points: 100 // loyalty/reward points }); ``` See [Tracking volume, revenue, points](/data/events/track#tracking-volume-revenue-points) for the full field reference, including `currency` (defaults to USD). These values power revenue attribution, volume tracking, and points/rewards leaderboards in your dashboard. ### Step 4: View custom events After tracking events: 1. Go to **Activity** in the Formo Dashboard 2. Filter by your custom event name 3. See all event occurrences Custom events also appear in: * [**Wallet Profiles**](/features/wallet-intelligence/wallet-profiles): user's activity history * [**Funnels**](/features/product-analytics/funnels): as conversion steps * [**Charts**](/features/product-analytics/charts): query with SQL ## Examples ### Tracking a swap flow Track multiple events throughout a user flow: ```typescript theme={null} // User clicks swap button analytics.track('Swap Started', { pair: 'ETH/USDC', token_in: '0x...', token_out: '0x...', amount_in: 1, amount_out_estimate: 3000 }); // User approves token (if needed) analytics.track('Token Approved', { token: '0x...', amount: 1, spender: '0x...' }); // Swap completes successfully analytics.track('Swap Completed', { pair: 'ETH/USDC', token_in: '0x...', token_out: '0x...', amount_in: 1, amount_out: 2998, volume: 2998, slippage: 0.07 }); // Or if swap fails analytics.track('Swap Failed', { pair: 'ETH/USDC', token_in: '0x...', token_out: '0x...', error: 'Insufficient liquidity', error_code: 'INSUFF_LIQ' }); ``` ### Tracking DeFi feature adoption Measure which features users engage with: ```typescript theme={null} // User opens a liquidity pool analytics.track('Pool Opened', { pool_id: 'ETH/USDC', pool_address: '0x...', source: 'sidebar' }); // User completes a key action in the pool analytics.track('Liquidity Added', { pool_id: 'ETH/USDC', pool_address: '0x...', token_in: 'ETH', token_out: 'USDC' }); ``` # Explorer Source: https://docs.formo.so/features/product-analytics/explore Run custom SQL queries against your analytics data warehouse to build reports, export results, and answer questions beyond dashboards. Explorer SQL Editor ## When to use the Explorer Use the Explorer when you need: * **Ad-hoc analysis** for specific questions * **Custom calculations** that aren't available in standard dashboards * **Complex joins** across multiple data types * **Testing queries** for custom charts and dashboards * **Data validation** to verify metrics ## How to use the explorer ### Step 1: Open the Explorer 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Explorer** in the left navigation ### Step 2: Write your query Enter your SQL query in the editor. Formo uses ClickHouse SQL syntax. **Example: Daily active wallets** ```sql theme={null} SELECT toDate(timestamp) AS date, countDistinct(address) AS daily_active_wallets FROM events WHERE type = 'connect' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY date ORDER BY date ``` ### Step 3: Run and visualize 1. Click **Run** to execute your query 2. View results in the table below 3. Optionally, switch to chart view to visualize the data ### Step 4: Export or save as a chart * **Export as CSV**: Click **Export** to download your query results as a CSV file for offline analysis, reporting, or importing into other tools. * **Save as a custom chart**: Turn any query into a reusable visualization on your [Charts](/features/product-analytics/charts) page: choose from line, bar, pie, table, and more. ## Available tables See the [Data Catalog](/data/catalog#tables) for the full list of tables, column definitions, query patterns, and example queries. ## Best practices * Use `LIMIT` to control result size. Default is 100 rows; maximum is 1,000,000 rows * Use date filters to improve query performance * See the [Event Spec](/data/events/overview) for available event properties * Use **Ask AI** to help write and fix queries ## Saving queries After writing a useful query, save it as a chart on your custom dashboard: 1. On the [Charts](/features/product-analytics/charts) page, select an existing board or create a new one 2. Add a new chart by clicking **Add Chart** 3. Paste your query into the SQL editor and run it 4. Choose a visualization type (line, bar, table, etc.) Your query becomes a reusable chart on your dashboard that you can share with your team. # Flows Source: https://docs.formo.so/features/product-analytics/flows Visualize user navigation paths with Sankey diagrams to identify the most common flows leading to or from any event, and find drop-off points. User flow analysis (the Sankey diagram) identifies the most frequent paths taken by users to or from any event. Flow Chart You can learn the following from paths: * What did users do immediately after signing up? * Where are users getting confused or stuck? * Which parts of your app are people actually using? * Why aren't users discovering new features? * Where are new users landing on your website? Understand how your users sequentially perform events in your product and analyze drop-offs or unsuccessful behavior. *** ## How to analyze user paths User flows show you the actual paths users take through your app, not just the paths you designed. This guide walks you through creating and analyzing flow charts. ### What is a Sankey diagram? A Sankey diagram visualizes user journeys as flowing paths. The width of each path represents the number of users taking that route. Wider paths = more common behavior. Unlike funnels (which track a specific sequence), flows reveal **all paths** users take, including unexpected ones. ### Step 1: Create a Flow chart Flows are a chart type you add to a dashboard (board), not a separate nav page. 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Open **Dashboards** in the left navigation, then open or create a board 4. Click **Add Chart** and choose the **Flow** chart type ### Step 2: Choose your starting point Flows can start from any event. Select what you want to analyze: | Starting point | What you'll learn | | ------------------ | --------------------------------- | | **First visit** | Where users go after landing | | **Wallet connect** | What users do after connecting | | **Specific page** | Paths from a key page | | **Contract event** | Behavior after an onchain action | | **Custom event** | Behavior after a conversion point | ### Step 3: Configure the flow Flows are configured as a positional step list rather than a direction toggle. Each row's role is determined by its position: * **Start**: The first row is your starting event (e.g., `connect`) * **Anchor**: Middle rows are the events users pass through next * **End**: The last row is where the flow ends 1. Add rows for each step you want to trace, in order 2. Set **Steps count** (2 to 5; default 3), the number of steps shown between each pair of anchor events 3. Set **Nodes per step** (2 to 8; default 5), the number of distinct events shown per step before the rest are grouped into "Others" 4. Optionally set a **Conversion window** (value + unit), the time allowed for the next steps to happen after the starting event 5. Optionally add **Exclude events** to drop specific events from the path so transitions collapse around them 6. Click **Save Chart** Flows always trace forward from the starting event; there is no backward-tracing option. ### Step 4: Read the flow chart The Sankey diagram shows: * **Nodes**: Events (pages, actions, conversions) * **Paths**: User journeys between events * **Width**: Number of users (wider = more common) * **Drop-off**: Users who left the flow Flow Chart ### Step 5: Identify patterns Look for these insights: **Common paths:** * Wide paths show your most frequent user journeys * Do they match your intended UX flow? **Unexpected paths:** * Narrow paths to unexpected destinations * Users skipping steps or taking detours **Dead ends:** * Where do users leave the flow? * High drop-off at certain pages = friction points ### Example: Post-connect analysis Analyze what users do after connecting their wallet: \| Starting event | `connect` | \| Steps | 4 | **Typical findings:** * 60% go to `/swap` (expected) * 20% go to `/portfolio` (checking balances) * 10% disconnect immediately (issue with UX?) * 10% navigate to `/docs` (need help?) ### Example: Path to conversion See what users do leading up to a conversion event by setting your conversion event as the last (End) step in the list, with earlier steps left open to discover common precursor actions: \| Starting event | A page view or wallet event that typically precedes conversion | \| End event | Your conversion event (e.g., `Swap`) | \| Steps | 3 | **Questions to answer:** * What pages do converters visit first? * Do they explore multiple features before converting? * Is there a common path to conversion? ### Use flows with funnels Flows and funnels complement each other: | Tool | Best for | | ----------- | ----------------------------------------------- | | **Funnels** | Measuring specific conversion paths | | **Flows** | Discovering all paths (expected and unexpected) | **Workflow:** 1. Use Flows to discover common paths 2. Create Funnels to measure conversion on those paths 3. Use Flows again to investigate drop-off points # Funnels Source: https://docs.formo.so/features/product-analytics/funnels Build multi-step conversion funnels that track user journeys from page view to wallet connect to onchain transaction, with drop-off rates at each step. Funnels A funnel tracks how visitors move through a sequence of steps, from an initial action to a conversion event. * Track page views, [contract events](/features/product-analytics/contract-events), and [custom events](/features/product-analytics/custom-events) as funnel steps. * **Conversion rate**: the percentage of visitors who completed the funnel. * **Drop-off rate**: the percentage of visitors lost between each step. * Filter funnels by campaign, traffic source, device, and more. ## Conversion Windows Specify a conversion window for your funnels to control the time period during which funnel steps must be completed. The default conversion window is 2 days, meaning all steps must be completed within that time. Longer windows will include more event completions. ## Step Order Control whether funnel steps must be completed in sequence or in any order. This setting changes how Formo counts conversions and can significantly affect your reported numbers. ### Sequential (Closed Funnel) Step B must happen after Step A, but any number of events can happen between A and B. This is the default and most common mode. Users must progress through your defined steps in order, but they can perform other actions in between. For example, in a funnel of **Page View → Wallet Connect → Swap**, a user who visits the page, browses other pages, connects their wallet, checks token prices, and then swaps would still count as converted. **Seeing lower counts than expected in the sequential funnel?** Users likely: * Skipped steps in your defined funnel * Completed steps in a different order * Had multiple wallets/sessions where they completed different parts of the funnel Consider making the funnel less strict by removing some intermediate steps, or use an open funnel. ### Any Order (Open Funnel) Counts users who performed each event, regardless of sequence or timing. In this mode, users must complete all defined steps, but the order does not matter and the conversion window does not apply. Steps are matched across your full selected date range with no time constraint between them. The first step in your funnel could be the last action the user takes. This is useful when your funnel steps represent a set of actions rather than a linear journey. **When to use each mode:** | Mode | Best for | Example | | -------------- | ---------------------------------------- | ----------------------------------------- | | **Sequential** | Linear user journeys where order matters | Page view → Wallet connect → Swap | | **Any order** | Feature adoption or non-linear flows | Deposit, Borrow, and Swap in any sequence | Start with **Sequential** (the default). Switch to **Any order** only if your funnel represents actions that genuinely happen in variable order: for example, multi-step DeFi flows where users may borrow before swapping, or complete approvals in any sequence. ## Funnel Breakdowns Break down funnel steps by key dimensions to understand how different user segments convert: * **Device**: Compare mobile vs desktop conversion performance * **Country**: Understand geographic differences in conversion rates * **Browser**: Compare conversion rates across browsers * **OS**: Analyze conversion rates across operating systems * **Referrer**: See which traffic sources drive the best conversions * **Referral**: Compare conversion by referral code * **UTM Source, Medium, Campaign, Content, Term**: Measure campaign performance across UTM parameters * **Builder Codes**: Break down conversion by builder code ## Conversion Insights [Insights](/features/product-analytics/insights) surfaces which events or user properties are most strongly associated with conversion. We prioritize two key metrics to help you identify meaningful drivers: * **Lift**: Measures the impact of an event. A lift > 1 means users who perform the event are more likely to convert. * **Odds Ratio**: Measures the strength of the association. Useful for verifying that an observed lift is not just due to random chance, especially for rare events. **2×2 Contingency Table** All insights are derived from a standard binary exposure vs. conversion table: | | Converted | Did Not Convert | Total | | ----------------- | --------- | --------------- | ----- | | **Did Event** | a | b | a+b | | **Did NOT Event** | c | d | c+d | | **Total** | a+c | b+d | N | **Key Metrics** 1. **Lift** $$ Lift = \frac{a / (a+b)}{(a+c) / N} $$ * *Interpretation*: Lift > 1 indicates a positive effect. 2. **Odds Ratio** $$ \text{Odds Ratio} = \frac{a \times d}{b \times c} $$ * *Interpretation*: An Odds Ratio > 1 confirms a positive correlation. **Noise Reduction** To prevent false positives, we automatically filter out events with: * Low volume (fewer than 10 users). * Negligible impact (less than 0.2% to 1.5% absolute difference depending on baseline conversion). *** ## How to create your first funnel Build a conversion funnel to track how users progress from first visit to onchain action. This guide walks you through creating, analyzing, and optimizing funnels. ### What is a funnel? Think of a funnel like a kitchen funnel: wide at the top, narrow at the bottom. Users enter at the top (e.g., visiting your site) and progress through steps until some complete the final action (e.g., making a transaction). At each step, some users drop off. Funnels help you: * **Measure conversion rates** between steps * **Identify bottlenecks** where users drop off * **Compare performance** across segments (referrers, devices, countries) ### Step 1: Create a Funnel chart Funnels are a chart type you add to a dashboard (board), not a separate nav page. 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Open **Dashboards** in the left navigation, then open or create a board 4. Click **Add Chart** and choose the **Funnel** chart type ### Step 2: Add funnel steps Define the user journey you want to track. Click **Add Step** for each action: | Step | Event Type | Example | | ---- | -------------- | ------------------------ | | 1 | Page view | Visit `/swap` | | 2 | Wallet connect | User connects wallet | | 3 | Transaction | User submits transaction | By default, steps must happen in order (Sequential mode). Formo tracks users who complete step 1, then step 2, then step 3 in sequence. You can change this to **Any order** in the [Step Order](#step-order) settings. ### Step 3: Configure each step For each step, specify: **Page view steps:** * Event type: `page` * Filter: `path = /swap` (or any URL path) **Wallet connection steps:** * Event type: `connect` * Optional: filter by chain **Transaction steps:** * Event type: `transaction` * Optional: filter by contract address or status **Contract event steps:** * Event type: Your contract event (e.g., `Swap`, `Transfer`) * Requires [contract events](/features/product-analytics/contract-events) to be configured ### Step 4: Set the conversion window Choose how long users have to complete all steps by entering a numeric value and selecting a unit (Hour, Day, or Week). The default is 2 days. Users must complete all steps within this window to count as converted. Note that the conversion window only applies to Sequential (Closed) funnels; [Any Order (Open) funnels](#any-order-open-funnel) have no time constraint between steps. ### Step 5: Analyze your funnel After creating your funnel, you'll see: 1. **Conversion rate** - Percentage who completed all steps 2. **Drop-off by step** - Where users abandon the journey 3. **Absolute numbers** - User count at each step Funnel Analysis ### Step 6: Add a breakdown Select a dimension from **Breakdown by** to compare conversion across segments: | Breakdown | What you learn | | -------------- | ---------------------------------- | | **Referrer** | Which traffic sources convert best | | **Country** | Geographic conversion differences | | **Device** | Mobile vs desktop performance | | **UTM Source** | Campaign performance | ### Example: DEX swap funnel Track users from landing to completed swap: | Step | Event | Typical conversion | | ---- | -------------- | ------------------ | | 1 | Visit `/swap` | 100% (baseline) | | 2 | Connect wallet | 40-60% | | 3 | Approve token | 70-80% | | 4 | Complete swap | 85-95% | A big drop between steps 1 and 2 points to friction in the wallet connection step. ### Using Conversion Insights After running your funnel, go to the [Insights](/features/product-analytics/insights) page to see which user behaviors correlate with conversion: * **High lift events**: Users who did X are 2x more likely to convert * **Negative indicators**: Users who did Y are 50% less likely to convert Use these insights to prioritize which behaviors to encourage and which friction points to remove. # Insights Source: https://docs.formo.so/features/product-analytics/insights Receive AI-powered insights that surface acquisition quality, revenue trends, and churn signals from your analytics data. Insights runs AI-powered cohort analysis on your data, surfacing findings across acquisition, revenue, and churn. Insights page showing acquisition quality, revenue, and churn prediction ## Wins, Issues, and Opportunities In addition to cohort analysis, the Insights page provides a summary organized into three sections: * 🏆 **Wins** - Metrics that improved recently, with deltas and baselines * ⚠️ **Issues** - Performance drops or problems that need attention * 📈 **Opportunities** - Untapped segments or behaviors with estimated upside ## Cohort Analysis Insights runs a cohort analysis that answers three key questions about your users: ### Acquisition Quality **Which channels bring in the most valuable users?** Acquisition Quality ranks your traffic sources by the visitors, wallets, and transactions they bring in, so you can quickly see which channels drive real activity rather than just traffic volume. Each channel shows: | Column | Description | | ---------------- | ------------------------------------------------------------------- | | **Channel** | The traffic source (e.g., discord.com, google.com, t.co) | | **Visitors** | Number of visitors from this channel | | **Wallets** | Number of wallets connected from this channel | | **Transactions** | Number of onchain transactions from users acquired via this channel | Use this to shift acquisition spend toward channels that produce real onchain activity, not just visits. ### Revenue Insights **Is revenue, volume, or points growing week over week?** Revenue Insights tracks whichever value metric your project actually records (revenue, transaction volume, or points) over the last 7 days versus the prior 7, using whichever has the strongest signal if more than one is tracked. The chart displays: * **Weeks** on the x-axis * **Total value for that week** (revenue, volume, or points) on the y-axis * **Trend analysis** highlighting spikes and declines ### Churn Prediction **What behaviors signal a user is about to churn?** Churn Prediction surfaces early warning signals: specific user behaviors that precede churn. Each signal includes a risk level and a description of what the data shows. | Column | Description | | --------------- | ----------------------------------------- | | **Behavior** | The action or pattern that precedes churn | | **Risk Level** | High, Medium, or Low severity | | **Description** | What the data shows about this signal | For example: * **High risk**: "Users who trigger an error event have a 92% churn rate within the first 72 hours" * **Medium risk**: "Users who disconnect before completing onboarding rarely return" ## How to use Insights 1. Navigate to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Insights** in the left navigation (✨ sparkle icon) 4. Review the latest analysis and take action Insights are generated the first time you open the page each day, then cached for the rest of that day. Reopening the page later that same day shows the cached report; your next visit on a new day generates a fresh one. ## Combining Insights with other features | Insight | Follow-up action | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Low-quality acquisition channel** | Create a [segment](/features/wallet-intelligence/segments) of users from that channel to investigate | | **Revenue decline week over week** | Use [retention analysis](/features/product-analytics/retention) to understand drop-off patterns | | **High churn signal detected** | Set up an [alert](/features/product-analytics/alerts) to notify you when users exhibit that behavior | | **Behavioral gap between power and casual users** | Explore the gap using [Ask AI](/features/product-analytics/ai) or the [Explorer](/features/product-analytics/explore) | # Key metrics Source: https://docs.formo.so/features/product-analytics/key-metrics Track key growth metrics like visitors, wallets, transactions, sessions, page views, volume, and revenue to understand your product performance. These are the core metrics on your project's Overview page in the Formo dashboard: how many people show up, how many connect a wallet, and how much they transact, spend, and earn you. ## Visitors Unique visitors interacting with your site or app over the selected period. See [Visitors](/data/metrics#visitors) for exactly how this is counted. ## Page views How many times a page has been viewed across your site or app. See [Pages](#pages) below for a breakdown by URL. ## Wallets Unique wallet addresses that connected during the selected period. See [Wallets](/data/metrics#wallets) for details. ## Transactions How many onchain transactions were made across your site or app. ## Sessions A session groups the actions a visitor takes in one visit. See [Sessions](/data/metrics#sessions) for how Formo counts them. ## Session duration See the average session duration of visitors and users on your app. Understand how long users spend engaging with your product. ## Bounce rate Track your bounce rate to understand how many visitors leave after viewing only one page. A high bounce rate may indicate issues with your landing page or user experience. ## Volume Total transaction volume (USD) tracked through your [custom events](/features/product-analytics/custom-events#step-3-track-with-volume-revenue-or-points), like the dollar value of a swap. ## Revenue Total revenue (USD) tracked through your [custom events](/features/product-analytics/custom-events#step-3-track-with-volume-revenue-or-points), like a protocol fee. Revenue tracking ## Breakdowns Break down visitors, page views, wallets, transactions, sessions, volume, or revenue by acquisition channel, referrer, referral, UTM parameter, device, browser, OS, or country, plus onchain builder codes for wallets, transactions, volume, and revenue. Session duration and bounce rate are not breakdown-eligible. Click any item in a breakdown to drill down into the [users](/features/wallet-intelligence/overview) behind it. Product Analytics Referrer Breakdown ## Pages See the top pages, entry pages, exit pages, and origins that your users are visiting. See which pages are driving the most traffic and conversions. Product Analytics Pages ### Entry, exit, and origin pages * **Entry pages** are the first pages visitors land on when they come to your website. Think of them as digital front doors where your user's journey begins. * **Exit pages** are the last pages users view before leaving your site. They represent the end of a visitor's session, whether it's after completing a goal or bouncing before engaging further. * **Origins** groups traffic by the full page origin (protocol and domain), useful if your app spans multiple subdomains or environments. Understanding these gives you a clear window into how people interact with your site and where you might be losing them. ## Channels Formo automatically classifies every session into one of 13 acquisition channels using a priority-ordered ladder over the referrer domain, `utm_medium`, and 8 ad-platform click IDs (`gclid`, `gad_source`, `fbclid`, `msclkid`, `ttclid`, `twclid`, `li_fat_id`, `rdt_cid`). Classification happens at ingestion time. See [Channels](/data/metrics#channels) for the full priority list and detection rules. Product Analytics Referrers ## Referrers See what's moving the needle with a breakdown of your metrics by referrer. Easily understand where your onchain users come from and how they find you. See [Referrers](/data/metrics#referrers) for details. Getting the specific referrer URL (not just the domain) requires the right `Referrer-Policy` header on your site. See [Referrer URL tracking](/sdks/web#referrer-url-tracking) for how to configure it. ## Builder codes For wallets, transactions, volume, and revenue, Formo defaults to breaking down by [builder codes](/data/events/transaction#builder-codes), an onchain attribution standard (ERC-8021) that lets apps identify themselves directly in transaction calldata. ## UTM The Formo SDK automatically adds the following UTM parameters present on the page to events fired from that page load: * utm\_source * utm\_campaign * utm\_medium * utm\_term * utm\_content Break down any metric by UTM source, medium, campaign, content, or term. See [UTM parameters](/data/metrics#utm-parameters) for details, and use Formo's [UTM Generator](https://formo.so/utm-generator) to generate your marketing links. ## Referrals See how many users arrive through a referral link, tracked via the `ref` query parameter (or `utm_medium=affiliate`/`referral`). See [Referrals](/data/metrics#referrals) for details. ## Countries See where your users are coming from by country. Product Analytics Countries ## Devices Product Analytics Devices ## Browsers See the breakdown of browsers your users are on, in the same view as [Devices](#devices), just switch tabs. ## OS See the breakdown of operating systems your users are on, in the same view as [Devices](#devices), just switch tabs. ## Wallet See the top wallet providers (MetaMask, Coinbase Wallet, Phantom, etc.) your users connect with. See [Wallet Type](/data/metrics#wallet-type) for details. Product Analytics Wallets ## Chains See the top chains your wallets and transactions are active on. Formo supports Ethereum and all major EVM chains, plus Solana. See [Chains](/chains/overview) for the full list and feature availability per chain. *** For a complete walkthrough of analyzing traffic sources, setting up UTM and referral tracking, comparing attribution models, and optimizing acquisition spend, see [How to Set Up Attribution](/guides/onchain-attribution). # Live view Source: https://docs.formo.so/features/product-analytics/live-view See a realtime activity feed of your current visitors on a rotating globe. Perfect for launch day monitoring or a mission control dashboard. Live View ## When to use Live View Live View is great for: * **Launch day monitoring** - Watch users discover your app in real time * **Campaign monitoring** - See traffic spikes as campaigns go live * **Demo dashboards** - Show real-time activity to stakeholders * **Incident response** - Spot unusual patterns or errors quickly ## How to use Live View ### Step 1: Open Live View 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Overview** in the left navigation 4. Click the pulsing live visitor count (e.g. "3 live visitors") next to the page title to open Live View. It only appears while at least one visitor is active on your site. Open Live view from the Current Visitors button ### Step 2: Monitor activity Events stream in real time on an interactive globe: * **Avatars** on the globe mark where active visitors are, grouped by country when several visitors share a location * The **live feed** panel (bottom left) lists recent events as they happen * Click on any user or activity in the live feed to see where they are on the map. Click again to open their [wallet profile](/features/wallet-intelligence/wallet-profiles) ### Step 3: Use for presentations 1. Open Live View in a browser tab 2. Expand to fullscreen mode 3. Display on a team monitor or during demos # Overview Source: https://docs.formo.so/features/product-analytics/overview Track the full user journey from acquisition to activation and retention with unified analytics designed for crypto apps. Formo captures the user journey from page visits offchain to transactions onchain, unifying product and marketing analytics for crypto apps. Product Analytics Overview ## Features * 🎯 **Key metrics.** Track visitors, pageviews, wallets, transactions, and revenue with complete channel and campaign attribution. * 🏃‍♀️‍➡️ **Activity feed.** Real-time event stream for understanding user behavior across web, mobile, and onchain sources. * 🌍 **Live view.** View live users and activity on an interactive, real-time global map. * ⚡️ **Custom events.** Track key touchpoints and feature adoption on your crypto app. * 🌐 **Contract events.** Track smart contract events without running your own indexer. * 📈 **Charts.** Create custom charts and reports and share them with your team. * 🔍 **Explorer.** Run custom SQL queries on your analytics data. * 📊 **Funnels.** Follow your users from first visit to conversion with multi-step funnels. * 🧭 **Flows.** Analyze user paths and flows to see what users do next. * 📅 **Retention.** Measure which user cohorts retain and churn over time. * 🔔 **Alerts.** Get notified of high-value users and important events in real time. * 💡 **Insights.** Get AI-generated product insights on acquisition, revenue, and churn, refreshed daily. * ✨ **Ask AI.** Use natural language to ask any questions about your data. ## FAQ Product Analytics tracks user behavior and funnels from acquisition to activation. [Wallet Intelligence](/features/wallet-intelligence/overview) focuses on identity: wallet profiles, user lifecycle, and segmentation. Yes. Cross-subdomain tracking is enabled by default (`crossSubdomainCookies: true`), so visitor identity is shared across all subdomains of your root domain by default. See the [Web SDK documentation](/sdks/web#cross-subdomain-tracking) for details. Formo supports Ethereum and all major EVM chains, plus Solana. See the [Chains](/chains/overview) page for the full list and feature availability per chain. Formo generates an anonymous visitor ID (UUID) stored in a first-party cookie on your domain. This ID is not linked to any personal information: no IP addresses or device fingerprints are used. A server-side daily-rotating hash is also used for session deduplication. See [what we collect](/data/what-we-collect) for details. Yes. Use the [Explorer](/features/product-analytics/explore) to run SQL queries directly against your analytics data. You can also use the [Query API](/api/query) or connect [BI tools](/data/bi) like Metabase or Grafana. # Retention Source: https://docs.formo.so/features/product-analytics/retention Measure user retention with cohort analysis to identify which user groups return over time, track stickiness, and spot churn patterns in your onchain app. Measure the stickiness of your product with different cohorts of users. Understanding user retention and engagement allows you to identify which user cohorts are more likely to take specific actions within a given time period. You can select which event to use to calculate week-on-week user retention. This gives product teams better insight into retention rates for specific features and user flows. For example, measure retention based on: * Transaction completions * Feature usage * Wallet connections * [Custom events](/features/product-analytics/custom-events) specific to your app *** ## How to analyze user retention Retention analysis shows how many users come back to your app over time. This guide walks you through reading retention charts and improving user stickiness. ### What is a cohort retention chart? A cohort is a group of users who started using your app in the same time period (e.g., users who first connected in Week 1). Retention tracks what percentage of each cohort returns in subsequent weeks. | Week 0 | Week 1 | Week 2 | Week 3 | Week 4 | | ------ | ------ | ------ | ------ | ------ | | 100% | 40% | 25% | 20% | 18% | This example shows: of users who started in Week 0, 40% returned in Week 1, 25% in Week 2, etc. ### Step 1: Create a Retention chart Retention is a chart type you add to a dashboard (board), not a separate nav page. 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Open **Dashboards** in the left navigation, then open or create a board 4. Click **Add Chart** and choose the **Retention** chart type ### Step 2: Choose the entry and retention events Configure how cohorts are formed and what counts as "coming back": | Setting | What it does | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Entry event** | Defines the cohort: users are grouped by the week they first performed this event (defaults to wallet connect; "Any event" groups everyone) | | **Retention event** | The action that counts as returning in a later week (e.g. `connect`, `transaction`, `page`, or a custom event; "Any event" counts any activity) | | **Retention type** | **Rolling** (default): a user counts toward week N if active in week N or any later week, giving a smooth curve. **Recurring**: counts only if active in exactly week N, so the curve can dip and recover | For crypto apps, `transaction` retention is usually the most meaningful metric because it shows who's actually using your app. You can also narrow the cohort with the **Segment** filter: device, browser, OS, country, volume, revenue, points, referrer, referrer URL, referral, builder codes, or UTM parameters. Separately, a **cohort label filter** restricts the cohort to wallets that carried a given label as of their entry week. Alternatively, switch **Retention by** to **User label** to retain on a wallet label value (e.g. `open_interest > 10000`) instead of an event. ### Step 3: Read the retention matrix The retention chart displays: * **Rows**: Cohorts, grouped by start week, plus a pinned "Mean retention" row (the unweighted average across all visible cohorts) * **Columns**: Weeks after the cohort's start (Week 0 through however many weeks have fully elapsed) * **Cells**: Percentage of the cohort still active in that week Retention Chart **Color coding:** cells shade from light to dark green as the retention percentage increases (darkest at 100%). Week 0 is always 100% by definition. Weeks whose observation window hasn't fully elapsed yet are shown blank or dashed rather than colored. ### Step 4: Identify patterns **Healthy retention curve:** * Sharp drop in Week 1 (normal) * Gradual stabilization by Week 3-4 * Flat line after stabilization (loyal users) **Concerning patterns:** * Continuous decline without stabilization * Large drop-offs in later weeks * Significant variance between cohorts ### Step 5: Compare cohorts Look for cohorts with better or worse retention: **Questions to ask:** * Did a product change improve retention for newer cohorts? * Do users from certain campaigns retain better? * Is there seasonal variation in retention? ### Improving retention Based on your retention analysis: **If Week 1 drop-off is too high:** * Improve onboarding experience * Send follow-up notifications * Add incentives for early engagement **If long-term retention is declining:** * Add new features or content * Implement re-engagement campaigns * Analyze churned users for common patterns **If certain cohorts retain better:** * Identify what made them different * Replicate successful acquisition channels * Apply learnings to current users ### Using retention with other features | Feature | How to combine | | ------------------- | --------------------------------------------------------------------------- | | **Segments** | Create segments of retained vs. churned users | | **Funnels** | Measure conversion for retained users | | **Alerts** | Notify when an at-risk or churned user re-engages (lifecycle = Resurrected) | | **Wallet Profiles** | Investigate high-retention users | # Form builder Source: https://docs.formo.so/features/token-gated-forms/form-builder Build and customize token-gated forms, waitlists, and surveys for your web3 community with drag-and-drop fields and wallet-connected responses. Token Gated Form Builder Launch token-gated forms, waitlists, and surveys for your community. * 🔑 **Token gating.** Gate access by token ownership, NFT collections, or other onchain credentials. * ✅ **Verified socials.** Verify Twitter accounts, Discord usernames, and more. * 🎨 **Custom branding.** Customize your backgrounds, colors, and logo to match your brand. * 🎨 **Template library.** Choose from a variety of form templates or build your own. * 🌐 **World ID** proof-of-personhood verification. Token Gated Form Builder Token Gated Form Builder *** ## How to create your first token-gated form Build and launch a token-gated form in under 10 minutes. This guide walks you through creating a form, adding token requirements, customizing branding, and viewing responses. ### Step 1: Create a new form 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Click **Forms** in the left navigation 3. Click **Create Form** 4. Choose a template or start from scratch: * **Waitlist** - Collect emails and wallet addresses * **Survey** - Gather feedback from your community * **Application** - Accept applications for grants, allowlists, etc. * **Blank** - Start with an empty form Templates come with pre-built questions you can customize. Starting from a template saves time. ### Step 2: Add form fields The form builder uses a drag-and-drop interface. Add fields by clicking the **+** button or dragging from the sidebar. **Available field types:** | Category | Fields | | ---------------- | ------------------------------------------------------------------------------------------- | | **Basic** | Short Text, Long Text, Email, Link, Heading, Paragraph, Image | | **Advanced** | Single Choice, Dropdown, Checkboxes, File, Rating, Scale | | **Verification** | Connect Wallet (EVM), Connect Wallet (Solana), Connect Discord, Connect X, Connect Telegram | A Connect Wallet field is required if you want to gate the form by token or NFT ownership. Connect Discord, Connect X, and Connect Telegram fields verify and capture the respective account. **Rating** shows a five-star widget. **Scale** shows a numeric scale with a configurable start, end, and step (0 to 10 by default), ideal for importance questions ("rate this from 1 to 5") and NPS-style surveys. ### Step 3: Enable token gating (optional) To restrict form access based on token ownership: 1. Click **Settings** in the form builder toolbar 2. Toggle **Token Gating** to enable 3. Click **Add Requirement** 4. Configure your requirement: * Select **ERC20 Token** (or **Native Token** for ETH, SOL, and other gas tokens) * Choose the chain (Ethereum, Base, Arbitrum, etc.) * Paste the token contract address (not needed for native tokens) * Set the minimum amount * Select **NFT** as the condition * Choose the chain * Paste the NFT contract address * Set the minimum quantity * Select **Contract Read** as the condition * Choose the chain * Paste the contract address and ABI * Select a read function (e.g., `balanceOf`, `stakedAmount`) * Set the comparison operator and expected value * See [Contract Read guide](/features/token-gated-forms/token-gating#contract-read) * Add **Connect World ID** as a requirement * Users must verify with World ID before submitting * See [World ID integration](/features/token-gated-forms/world-id) Wallet requirements support both EVM chains and Solana (NFT, SPL Token, Native Token). You can also gate by Human Passport score, or require a verified (blue-check) X account, Discord, or Telegram connection. See [Token gating](/features/token-gated-forms/token-gating) for the full list of requirement types. Add multiple requirements and choose whether responders must meet all of them or at least N of them. ### Step 4: Customize branding Make your form match your brand: 1. Click **Design** in the form builder toolbar 2. Customize: * **Logo** - Upload your project logo * **Background** - Set a color or upload an image * **Colors** - Match your brand colors * **Font** - Choose from available fonts ### Step 5: Publish and share 1. Click **Publish** in the form builder toolbar 2. Copy your form URL (e.g., `app.formo.so/your-form-id`), download a QR code, or use the embed and social share options 3. Share the link with your community Share form ### Step 6: View responses As responses come in, view them in the dashboard: 1. Go to **Forms** > select your form 2. Click the **Responses** tab 3. You'll see each submission with: * Wallet address * Form answers * Token verification status * Submission timestamp Form responses ### Example: NFT holder feedback form Here's a practical example of a token-gated survey: | Setting | Value | | ----------------- | ------------------------------------------------------------- | | Template | Survey | | Token requirement | NFT: Your collection address | | Minimum holdings | 1 NFT | | Questions | "How did you hear about us?", "What features would you like?" | | Branding | Your logo, brand colors | Only users holding at least 1 NFT from your collection can submit the form. ## FAQ Token gating supports EVM-compatible chains (Ethereum, Base, Arbitrum, Optimism, Polygon, and more) as well as Solana. See the full list of [supported chains](/chains/overview). Yes. You can configure multiple token gate conditions and choose whether responders must meet all of them or at least N of them. See [token gating](/features/token-gated-forms/token-gating#combining-requirements) for details. You can require a connected Discord or Telegram account, or a verified (blue-check) X account. Combine these with World ID or Human Passport score to prevent sybil submissions. See [Token gating](/features/token-gated-forms/token-gating) for the full list of requirement types. # Token gating Source: https://docs.formo.so/features/token-gated-forms/token-gating Restrict form access by requiring responders to hold specific ERC-20 tokens, NFTs, or SPL tokens, be on an uploaded allowlist, or complete identity verification before submitting responses. Token Gated Form Builder Formo supports token gating, giving you full control over who can access your forms. ## Quickstart Token Gated Form Builder Sign in to [app.formo.so](https://app.formo.so) to create your form. Go to your form's *settings* page to enable token gating. Click *Add Requirement* to add one or more gating requirements. Publish your form and share the link with your users. Your form will verify that responders fulfill your requirements before continuing. Formo currently supports [35 chains](/chains/overview) for token gating. Token Gated Form Builder Choose from different types of token gating requirements: Token Gated Form Builder ## Native Token Require responders to hold a minimum balance of a chain's native token (e.g., ETH, MATIC, BNB). ### Setup 1. In your form settings, click **Add Requirement** and select **Native Token** 2. Choose the **chain** (Ethereum, Base, Polygon, etc.) 3. Set the **minimum balance** required ### Example Require at least 0.1 ETH on Ethereum Mainnet: | Field | Value | | --------------- | ------------ | | Type | Native Token | | Chain | Ethereum | | Minimum balance | 0.1 | ## ERC-20 Token Require responders to hold a minimum amount of a specific ERC-20 token. ### Setup 1. In your form settings, click **Add Requirement** and select **ERC-20** 2. Choose the **chain** where the token is deployed 3. Paste the **token contract address** 4. Set the **minimum balance** required ### Example Require at least 1,000 USDC on Base: | Field | Value | | ---------------- | -------------------------------------------- | | Type | ERC-20 | | Chain | Base | | Contract address | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | | Minimum balance | 1000 | Formo automatically detects the token name, symbol, and decimals from the contract address. ## NFT Require responders to hold one or more NFTs from a specific collection (ERC-721 or ERC-1155). ### Setup 1. In your form settings, click **Add Requirement** and select **NFT** 2. Choose the **chain** where the NFT collection is deployed 3. Paste the **NFT contract address** 4. Set the **minimum quantity** required ### Example Require at least 1 NFT from a collection on Ethereum: | Field | Value | | ---------------- | --------------------------- | | Type | NFT | | Chain | Ethereum | | Contract address | Your NFT collection address | | Minimum quantity | 1 | ## Contract Read Gate form access based on any smart contract's read function. This is useful for verifying staking balances, governance power, protocol participation, or any other onchain state that can be queried from a contract. ### How it works Contract Read calls a read-only function on a smart contract and compares the result against a value you specify. The responder's wallet address can be passed as a function argument using the `{{address}}` placeholder. **Example:** Require that the responder has staked at least 100 WCT tokens by reading the `stakedBalance(address)` function. ### Setup In your form settings, click **Add Requirement** and select **Contract Read**. Choose the chain where your contract is deployed (Ethereum, Base, Arbitrum, etc.) Paste the smart contract address (e.g., `0x1234...abcd`). Paste the contract ABI (JSON format). For verified contracts, you can copy the ABI from Etherscan. Choose a read-only function from the ABI. Only `view` and `pure` functions are available. Enter the function arguments. Use `{{address}}` as a placeholder for the responder's wallet address. Choose a comparison operator and the expected value: | Operator | Meaning | | -------- | --------------------- | | `>` | Greater than | | `>=` | Greater than or equal | | `<` | Less than | | `<=` | Less than or equal | | `==` | Equal to | | `!=` | Not equal to | Publish your form. Formo will verify each responder's wallet against the contract before allowing submission. ### Example: Staking requirement Gate access to users who have staked at least 100 tokens: | Field | Value | | ---------------- | ----------------------------------------------------- | | Chain | Ethereum | | Contract address | Your staking contract | | Function | `stakedBalance(address)` | | Arguments | `{{address}}` | | Operator | `>=` | | Value | `100000000000000000000` (100 tokens with 18 decimals) | Values are compared as raw integers. For ERC-20 tokens with 18 decimals, 100 tokens = `100000000000000000000` (100 \* 10^18). ### Example: Governance power Require a minimum voting power to access a governance feedback form: | Field | Value | | ---------------- | -------------------------------------- | | Chain | Base | | Contract address | Your governance token | | Function | `getVotes(address)` | | Arguments | `{{address}}` | | Operator | `>=` | | Value | `1000000000000000000000` (1000 tokens) | ### Supported functions Contract Read supports any `view` or `pure` function that: * Returns a single scalar value (number, boolean, address, or string) * Takes scalar input parameters (no arrays or structs) Common use cases include `balanceOf`, `stakedBalance`, `getVotes`, `isWhitelisted`, `hasRole`, and any custom getter function on your contract. ## Allowlist Restrict a form to a fixed list of wallet addresses you upload as a CSV. Useful for private betas, airdrop claims, and any form where the audience is a list you already hold rather than an onchain condition. Available on both **Connect Wallet (EVM)** and **Connect Wallet (Solana)**. ### How it works Your browser turns the CSV into a **Bloom filter**, a compact bit array that answers "is this address on the list?". The original allowlist is never stored anywhere. Respondents connect and sign with their wallet to prove they own the address. Formo verifies that proven address is in the allowlist. ### Setup On your form, go to Settings > Access. Turn on **Connect Wallet (EVM)** or **Connect Wallet (Solana)**. For each Connect Wallet, click **Add requirement** and select **Allowlist (CSV)**. Click **Upload CSV** and choose your file. Formo shows how many unique addresses it found. Give the requirement a name (e.g. *Wallet is on the allowlist*) and save, then publish your form. ## Solana Alongside EVM chains, you can gate forms on Solana wallets. Enable **Connect Wallet (Solana)** in your form settings, then add one or more conditions: | Condition | Requires | | ------------------- | --------------------------------------------------------- | | **Native Token** | A minimum SOL balance | | **SPL Token** | A minimum balance of an SPL token, by mint address | | **NFT** | An NFT from a specific collection | | **Allowlist (CSV)** | Membership of a list you upload (see [above](#allowlist)) | Responders connect a Solana wallet (Phantom or Solflare) and sign a message to prove ownership before their balances are checked. ### Setup 1. In your form settings, enable **Connect Wallet (Solana)** 2. Click **Add requirement** and choose a condition 3. For SPL Token, paste the **token mint address** and set the **minimum balance** 4. For NFT, paste the **collection address** and set the **minimum quantity** EVM and Solana requirements can be combined on one form. Pair them with **Should meet some** if responders may hold assets on either ecosystem. ## World ID Require responders to verify with World ID's proof-of-personhood check before submitting. See [World ID integration](/features/token-gated-forms/world-id) for setup. Optionally enable **Require unique World ID** to block the same verified human from submitting more than once. ## Human Passport Require a minimum [Human Passport](https://docs.passport.xyz/) Unique Humanity Score to filter out bots and sybil accounts. ### Setup 1. In your form settings, click **Add Requirement** and select **Human Passport** 2. Set the **minimum Unique Humanity Score** (0-100; defaults to 20) Only one Human Passport requirement is allowed per form. ### Example | Field | Value | | ------------- | -------------- | | Type | Human Passport | | Minimum score | 20 | ## X Verified Require responders to have a verified X (formerly Twitter) account, indicated by the blue checkmark. ### Setup In your form settings, click **Add Requirement**, select **Connect X**, then **X Verified**. No further configuration is required. ## Discord Require responders to connect a Discord account before submitting. ### Setup In your form settings, click **Add Requirement** and select **Connect Discord**. No further configuration is required. ## Telegram Require responders to connect a Telegram account before submitting. ### Setup In your form settings, click **Add Requirement** and select **Connect Telegram**. No further configuration is required. ## Combining requirements Under **Access gating** in your form settings, choose how many requirements a responder must meet: * **Should meet all** (default): responders must satisfy every requirement you've added * **Should meet some**: responders must satisfy at least N of your requirements, where you set N ## Require unique wallet addresses Under a **Connect Wallet** requirement, enable **Require unique wallet addresses** to block the same wallet from submitting more than once. Use it for airdrops, claims, and one-vote-per-wallet forms. The equivalent toggle for World ID is **Require unique World ID**, which blocks the same verified human rather than the same wallet. # Webhooks Source: https://docs.formo.so/features/token-gated-forms/webhooks Send each new form response to other tools as it arrives: Zapier, Make, n8n, or Slack. Webhooks let you react to form responses in real time. Add a webhook to a form and Formo will `POST` every new submission to the URL you choose, the moment it arrives. * **Integrations.** Point it at Zapier, Make, n8n, or your own backend to push responses into a CRM, spreadsheet, or internal tool. * **Slack out of the box.** Paste a Slack incoming-webhook URL and responses show up as a formatted message in your channel, with no extra setup. * **Signed and secure.** Optionally sign every payload so your server can verify it genuinely came from Formo. ## Setup Open your form in the builder and go to **Settings → Webhooks**. Click **Add webhook** and paste your endpoint URL. Give it an optional name so it's easy to recognize later. A signing secret is generated for you. Copy it now; it's stored encrypted and never shown again. Use it to verify incoming requests (see [Verifying signatures](#verifying-signatures)). Click **Test** to send a sample response to your endpoint and confirm it's wired up, then toggle the webhook on. A form can have up to 10 webhooks, and you can disable any of them with its toggle. ### Slack To post responses into a Slack channel, create a [Slack incoming webhook](https://api.slack.com/messaging/webhooks) and paste its URL (it looks like `https://hooks.slack.com/services/...`). Formo detects Slack automatically and sends a formatted Block Kit message. No signing secret is needed; keep the URL private, since anyone who has it can post to your channel. ## Sample Payload Non-Slack endpoints receive a JSON envelope: ```json theme={null} { "id": "evt_9f8c...", "type": "form.response.created", "created": 1767225600, "data": { "form_id": "aBcD1234", "form_title": "Beta signup", "response_id": "6f1e...", "submitted_at": "2026-01-01T00:00:00.000Z", "answers": [ { "id": "q_name", "label": "Full name", "value": "Ada" }, { "id": "q_langs", "label": "Languages", "value": ["ts", "go"] } ], "fields": { "q_name": "Ada", "q_langs": ["ts", "go"] } } } ``` `answers` keeps the order and human labels the respondent saw; `fields` is a flat `id` to `value` map that's easy to bind in automation tools. Field ids are stable across label edits, so key on `id`, not `label`. ### Headers | Header | When | Meaning | | --------------------- | ----------- | ------------------------------------------------- | | `X-Formo-Event` | always | Event type (`form.response.created`) | | `X-Formo-Webhook-Id` | always | Which configured webhook fired | | `X-Formo-Test` | test sends | `true`, a "Send test" rather than a real response | | `X-Webhook-Timestamp` | when signed | Unix seconds, part of the signed material | | `X-Webhook-Signature` | when signed | `HMAC-SHA256` over `{timestamp}.{body}`, hex | ## Security When a signing secret is set, Formo signs each request so your server can prove it genuinely came from Formo and wasn't tampered with. A valid signature can only be produced with the secret (authenticity), it covers the exact body (integrity), and it includes a timestamp your server can use to reject old requests (replay protection). Secrets are encrypted at rest and never returned by the API. Only public `http(s)` endpoints are allowed; URLs that resolve to private or internal addresses are rejected. The signature is different on every request because it covers both the timestamp and the body, which carries a fresh event `id`. Don't compare it to a stored value: recompute it per request from that request's timestamp, body, and your secret, then compare to the `X-Webhook-Signature` header. ### Verifying signatures ```ts Node.js (Express) theme={null} import crypto from 'crypto'; app.post('/formo-webhook', express.raw({ type: 'application/json' }), (req, res) => { const secret = process.env.FORMO_WEBHOOK_SECRET; const timestamp = req.header('X-Webhook-Timestamp'); const signature = req.header('X-Webhook-Signature'); const rawBody = req.body.toString('utf8'); // Reject stale replays (timestamp is inside the signed material). if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.status(401).end(); } const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); // Constant-time compare. A plain === leaks the signature byte by byte. const a = Buffer.from(signature ?? '', 'hex'); const b = Buffer.from(expected, 'hex'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).end(); } const payload = JSON.parse(rawBody); // safe to trust now // handle the submission res.status(200).end(); }); ``` ```python Python theme={null} import hmac, hashlib, time def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool: if not timestamp or abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature or "") ``` Verify against the raw request body, before parsing JSON. Re-serializing changes whitespace and key order, and the signature won't match. When testing on [webhook.site](https://webhook.site), the `X-Webhook-Signature` and `X-Webhook-Timestamp` headers appear in the request's Headers section. ## Delivery Delivery happens after the response is saved and never blocks the submitter, so a slow or dead endpoint can't fail or delay a submission. Failed deliveries are retried a few times on transient errors (network issues, timeouts, `5xx`). Deduplicate on `data.response_id` if a retry could double-process on your side. # World ID Source: https://docs.formo.so/features/token-gated-forms/world-id Add World ID verification to your Formo forms to ensure submissions come from real, unique humans. Prevent bots and duplicate entries with proof of personhood. Token Gated Forms World ID World ID lets form respondents anonymously verify they're a unique human, preventing bots and duplicate submissions. Enable it for your forms on the Settings page: Token Gated Forms World ID Toggle on **Connect World ID**. # Audience insights Source: https://docs.formo.so/features/wallet-intelligence/audience-insights Analyze your audience with breakdowns of lifecycle, sessions, transactions, net worth, and lifetime volume, revenue, and points, plus filters by apps, tokens, and chains. Audience Insights Track and analyze visitors and users in real time, with automatic labeling and profile data. Get insights into your users' activity and other properties: * A breakdown of users by lifecycle stage * Sessions and transactions per wallet * A breakdown of users by net worth * Lifetime volume, revenue, and points * Wallet labels Each user is automatically labeled and grouped into segments such as 'New' or 'Returning'. Understand the top wallets, devices, OS, and net worth of your users. Select a user to view their Wallet Profile. ## Filters Apply filters to understand how different segments of users interact with your app at different times. ### Filter by Lifecycle Filter users by their [lifecycle stage](/features/wallet-intelligence/wallet-profiles#user-lifecycle) to create segments: New, Power User, Resurrected, At Risk, Returning, Churned. ### Filter by Apps, Tokens, Chains Apply filters such as "users who use app X and Y" for apps, tokens, and chains, combining multiple filters into one segment. ### Filter by First-Touch and Last-Touch Attribution Filter users by referrer, referral, and UTM properties using first-touch and last-touch attribution. Understand which channels first introduced users to your app versus which channels drove the final conversion. ### Filter by App and Token Amounts Filter users' apps and tokens by specific amounts. For example, "Show me users of Ethena with more than \$1000 balance." *** ## How to analyze your user base Audience Insights gives you a bird's-eye view of who your users are. This section covers exploring your audience composition and building segments from it. ### Step 1: Open Audience Insights 1. Go to the [Formo Dashboard](https://app.formo.so) 2. Select your project 3. Click **Users** in the left navigation The **Users** page includes an Audience Insights section (lifecycle, sessions per wallet, transactions per wallet, net worth, and lifetime volume/revenue/points breakdowns) above the users table, showing aggregate metrics about your entire user base. ### Step 2: Explore the overview charts The dashboard shows key breakdowns of your audience: | Chart | What it shows | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Lifecycle** | New, Returning, Power user, Resurrected, At Risk, Churned breakdown | | **Sessions per Wallet** | Distribution of session counts across your users | | **Transactions per Wallet** | Distribution of transaction counts across your users | | **Net Worth Distribution** | Users grouped by wallet value ($0-$250, $250-$10K, $10K-$50K, $50K-$100K, $100K-$500K, $500K-$1M, $1M-$5M, $5M-$10M, $10M-$25M, $25M-$50M, $50M-$100M, \$100M+) | | **Lifetime Volume** | Distribution of total transaction volume across your users | | **Lifetime Revenue** | Distribution of total revenue across your users | | **Lifetime Points** | Distribution of total points across your users | Audience Insights ### Step 3: Apply filters to segment Click **Filter** to narrow down to specific user groups: **Example: Find your most valuable DeFi users** | Filter | Value | | --------- | ---------- | | Net Worth | > \$10,000 | | Apps | Uniswap | | Lifecycle | Power user | **Example: Find users from a specific campaign** | Filter | Value | | ------------ | ------------- | | UTM Source | twitter | | UTM Campaign | launch\_promo | **Example: Find whales who use competitor apps** | Filter | Value | | --------- | --------------- | | Net Worth | > \$100,000 | | Apps | Competitor Name | ### Step 4: Analyze filtered results After applying filters, the charts update to show only matching users: * **Compare segments**: How do Twitter users differ from Discord users? * **Find opportunities**: Which tokens do your whales hold that you don't support yet? * **Identify patterns**: What apps do your power users have in common? ### Step 5: Save as a segment Turn useful filter combinations into reusable segments: 1. Apply your filters 2. Click **Save Segment** 3. Name your segment (e.g., "High-Value DeFi Users") 4. Reapply it anytime from the **Segment** dropdown next to the filter bar; see [Segments](/features/wallet-intelligence/segments) ### Common analysis workflows **Understand your best users:** 1. Filter by Lifecycle = "Power user" 2. Filter by Apps or Tokens to see what they hold and use 3. Identify common characteristics to target in acquisition **Evaluate campaign quality:** 1. Filter by UTM Source, Referrer, or Referral to your campaign 2. Compare net worth distribution to overall average 3. Check if campaign users have higher/lower value **Find expansion opportunities:** 1. Filter by Chains to see which networks your users are active on 2. Identify chains you don't fully support yet 3. Prioritize based on user demand **Competitive analysis:** 1. Filter by Apps to select competitor apps 2. See how many of your users also use competitors 3. Understand what else these users are doing # Wallet intelligence overview Source: https://docs.formo.so/features/wallet-intelligence/overview Explore wallet intelligence features including wallet profiles, audience insights, user segmentation, labels, and scoring for onchain user targeting. Users ## Features Wallet intelligence helps you **understand and target users with crypto-native segmentation based on unified offchain and onchain data**: * 🔍 **Wallet search.** Look up any wallet address to generate a full profile on demand. * 🕵️‍♀️ **Wallet profiles.** Turn wallet addresses into user profiles. * 👥 **Audience insights.** See your users' lifecycle, apps, tokens, chains, revenue, and retention. * 🤩 **User segments.** Target segments based on wallet properties and in-app activity. * 🏷️ **Wallet labels.** Further enrich wallet profiles with wallet labels. Profile data is available for querying and export in the [Profiles API](/api/overview#profiles-api). *** ## How to explore your users This guide walks you through viewing wallet profiles, understanding your audience, and creating your first user segment. ### Step 1: View the Users page 1. Open the [Formo Dashboard](https://app.formo.so) 2. Select your project from the sidebar 3. Click **Users** in the left navigation You'll see a table of all users who have connected a wallet to your app, along with key metrics like net worth, lifecycle stage, and last seen. Users list ### Step 2: Explore a wallet profile Click on any wallet address to open its full profile. The wallet profile shows: | Section | What you'll see | | -------------- | ---------------------------------------------------------- | | **Activity** | Real-time feed of actions on your app with UTM attribution | | **Apps** | DeFi applications the wallet uses across chains | | **Tokens** | Token balances and holdings across chains | | **Properties** | Custom properties and labels. | Wallet Profile ### Step 3: Understand lifecycle stages Formo automatically categorizes users by lifecycle stage based on their activity: | Stage | What it means | | --------------- | ---------------------------- | | **New** | Recent acquisition | | **Returning** | Engaged user | | **Power user** | Highly engaged | | **Resurrected** | Re-engaged after going quiet | | **At Risk** | Still active but going quiet | | **Churned** | Needs re-engagement | Use the lifecycle filter on the Users page to focus on specific user groups. See [User Lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for the exact rules and thresholds behind each stage. ### Step 4: Create your first segment Segments let you group users by shared characteristics. Here's how to create one: 1. Go to **Users** in the sidebar 2. Apply filter conditions. For example: * Lifecycle = "Power user" * Net worth > \$10,000 * Label = "Coinbase Verified" 3. Click **Save Segment** 4. Name your segment (e.g., "High-Value Verified Users") 5. Click **Save** Saved segments are managed inline on the Users page via the Segments dropdown. Segment builder ### Example: Find whales who churned Here's a practical segment to identify high-value users at risk: | Filter | Value | | --------- | ------------- | | Lifecycle | Churned | | Net worth | > \$100,000 | | Last seen | > 30 days ago | Export this segment as CSV to run targeted re-engagement campaigns on social channels. ## FAQ Formo aggregates onchain data across major EVM chains and Solana to build wallet profiles. This includes token holdings, DeFi positions, wallet age, transaction frequency, and net worth. Data is refreshed periodically to keep profiles current. Lifecycle stages are calculated from a wallet's activity recency and frequency on your app, relative to a reference date that defaults to today. Default thresholds can be customized in Settings → Lifecycle. See [User Lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for the exact rules. Yes. You can export any [segment](/features/wallet-intelligence/segments) as a CSV file for use in targeted re-engagement campaigns. You can also query profiles programmatically via the [Profiles API](/api/profiles/get). Yes. Formo automatically groups wallets that share a session or a common user ID into clusters. Each wallet's profile lists its **Linked Addresses**, and the **Clusters** tab on the Users page shows every multi-wallet group with its member wallets. You can also use [formo.identify()](/data/events/identify) to associate wallets with a common user ID. # Wallet search Source: https://docs.formo.so/features/wallet-intelligence/search Look up any wallet address to generate a full profile on demand, even for wallets that haven't visited your app. Look up any wallet by address to generate a full profile on demand, with net worth, DeFi positions, labels, and social identities, even for wallets that haven't visited your app. Wallet search ## How it works 1. Enter a wallet address (or ENS name) in the search input 2. Formo validates the address and checks for an existing profile 3. If no fresh profile exists, Formo generates one on demand by aggregating onchain data across all supported chains 4. The profile is ready in seconds; click **View Profile** to see the full [wallet profile](/features/wallet-intelligence/wallet-profiles) Profiles are considered fresh for 30 days. Searching for a wallet that already has a recent profile returns it instantly. ## How to import wallets Import a CSV file of wallet addresses to profile multiple wallets at once. Click the **Import** button next to the search input. Drag and drop a CSV file or click to browse. The file should contain one wallet address per row. Formo validates each address, deduplicates entries, and checks against your plan's MAU limits before processing. Imported wallets are added to your project's user list and profiled automatically. # User segmentation Source: https://docs.formo.so/features/wallet-intelligence/segments Create audience segments by grouping users based on wallet properties, onchain behavior, demographics, and lifecycle stage for targeted campaigns. Audience Segmentation Segments are groups of users that share a certain set of properties or who perform a similar sequence of events. Formo lets you define segments, view the list of users that comprise them, compare them in your analysis, and share them with your team. ## Creating a segment 1. Go to **Users** in the sidebar 2. Apply filters to define your target audience (e.g., wallet labels, net worth, lifecycle stage) 3. Click **Save Segment** to save your filtered view 4. Give your segment a name and click **Save** Your segment is now saved and can be reused across the dashboard. Target exactly the right people based on any number or combination of conditions: * US Users: Users with the 'Coinbase Verified Country' label set to the US * Verified humans: Users with a 'Human Passport Unique Humanity Score' above 50 * Power users with high balances: Users with the 'Power user' lifecycle label who have net worth above \$1M * Dropped-Off Users: Users who did not come back the following week You can use wallet labels, offchain, and onchain properties to create your segments. ## Lifecycle Segments Each user's lifecycle stage is automatically calculated based on their activity. Filter users by lifecycle (New, Power User, Resurrected, At Risk, Returning, Churned) to create segments and audiences. See [User Lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for a breakdown of the different lifecycle stages. ## Behavioural Segments Create behavioural segments: groups of users who performed any action (in-app events, [custom events](/features/product-analytics/custom-events), [contract events](/features/product-analytics/contract-events)) N or more times in a given time period. Behavioural segments work with other wallet properties such as net worth, apps, tokens, device, and attribution data to create granular segments. For example: | Segment | Use Case | | :------------------------------------------------------------------------------------------------------------------- | :--------------------- | | U.S. users from a specific UTM campaign who visited /swap at least 1 time in the last 90 days | Surface key prospects | | Users who connected their wallet and made a transaction more than 3 times in the last 30 days | Identify power users | | DeFi users who use Ethena with >\$10,000 net worth, who started but rejected a transaction 1 time in the last 7 days | Activate at-risk users | Behavioural segments support multiple events with AND/NOT logic. For example, you can create a segment for churned wallets as users who performed "connect wallet" in the last 7 days (>=1) AND did NOT perform "connect wallet" between 14 and 7 days ago (=0). # Wallet labels Source: https://docs.formo.so/features/wallet-intelligence/wallet-labels Understand user interests and behavior with automatically generated wallet labels based on onchain activity, reputation, and user properties. Wallet Labels **Wallet Labels** annotate blockchain addresses with attributes like verification status, lifecycle stage, and reputation scores. Use labels to build [segments](/features/wallet-intelligence/segments) that target users matching your Ideal Customer Profile (ICP). ## List of Labels Each tracked user on Formo is assigned labels based on their onchain activity and other data. ### User lifecycle labels Every wallet is assigned a lifecycle stage based on its recency and active days: **New**, **Returning**, **Power user**, **Resurrected**, **At Risk**, or **Churned**. See [User lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for the exact rules and thresholds. ### Attestations | Label | Description | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **OFAC Sanctioned** | OFAC-sanction status, sourced from Chainalysis's onchain sanctions oracle | | **Human Passport Unique Humanity Score** | [Human Passport score (0-100)](https://docs.passport.xyz/) | | **Human Passport Aggregate Model Score** | [Aggregate unique humanity score for EVM wallets (0-100, or -1 if there's insufficient transaction history)](https://docs.passport.xyz/building-with-passport/models/available-models#aggregate-unique-humanity-model) | | **Coinbase Verified Account** | [Coinbase verified account attestations](https://www.coinbase.com/developer-platform/products/verifications) | | **Coinbase Verified Country** | Coinbase verified country attestations with country code extraction | | **Coinbase One** | Coinbase One subscription attestations | | **Binance Verified** | Holds a [Binance Account Bound Token (BABT)](https://www.binance.com/en/babt), attesting the wallet completed Binance KYC | ### Sybil Wallets that appear on published airdrop sybil / farmer lists. These are one-time historical snapshots from past airdrop investigations, so a wallet is labeled only if it was named in the original list. | Label | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Sybil (LayerZero)** | Named in the [LayerZero airdrop sybil list](https://github.com/psrvere/layerzero-sybil-checker) as a suspected airdrop farmer | | **Sybil (Hop)** | Listed as a sybil attacker in the [Hop Protocol airdrop](https://github.com/hop-protocol/hop-airdrop/blob/master/src/data/eliminatedSybilAttackers.csv) | | **Sybil (Optimism)** | Removed from the [Optimism airdrop](https://community.optimism.io/citizens-house/airdrops/airdrop-1) for sybil activity | ### Others | Label | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Merkl Campaigns** | Tracks participation in [Merkl](https://merkl.xyz) incentive campaigns. Formo automatically detects campaign IDs from the Merkl API and stores them as a label on the wallet profile. You can filter users by campaign ID to measure the quality of users acquired through specific Merkl campaigns. | | **ERC-8004 AI Agent** | Automatically detects wallets registered as AI agents via the [ERC-8004](https://erc8004.org) standard. Formo queries onchain registries across 11 chains to identify agent wallets (agent discovery runs via subgraph on 5 of them; the rest are used for RPC and reputation lookups once an agent is found) and surfaces the agent name, ID, and reputation score on the wallet profile. | *** ## How to use labels for targeting This section covers using labels to identify and target specific user types. ### How labels work Formo automatically analyzes each wallet's onchain activity and assigns relevant labels. Labels update as users' behavior changes. You can also add your own custom labels to a wallet for information Formo can't detect on its own. **Label sources:** * **Lifecycle** - Engagement stage derived from each wallet's recency and active days * **Attestations** - Verified credentials from Coinbase, Binance, Human Passport, and OFAC sanction lists * **Sybil lists** - Membership in published airdrop sybil / farmer lists (LayerZero, Hop, Optimism) * **Campaigns** - Participation in incentive campaigns such as Merkl * **Onchain registries** - Standards such as ERC-8004 that identify AI agent wallets * **Custom** - Labels you add manually to a wallet's profile ### Step 1: View labels on a user profile 1. Go to **Users** and click any wallet address 2. Open the **Properties** tab 3. See all labels assigned to this user in the **User Labels** section Auto-generated labels (lifecycle, attestations, campaigns, registries) are read-only. Click **+ Add Label** to add, edit, or remove a custom label. To filter users by label, use **Add Filter** (see Step 2 below). ### Step 2: Filter users by label Use labels to find specific user types: 1. Go to **Users** 2. Click **Add Filter** 3. Select **Label** as the filter type 4. Choose the label(s) you want **Example filters:** | Goal | Label Filter | | -------------------------- | ----------------------------------- | | Find power users | Lifecycle = "Power user" | | Find verified humans | Human Passport Score > 50 | | Exclude sanctioned wallets | Label != "OFAC Sanctioned" | | Find Coinbase users | Label = "Coinbase Verified Account" | ### Step 3: Combine labels with other filters Combine labels with other filters to narrow further: **High-value DeFi users:** | Filter | Value | | --------- | --------------------------------------- | | Apps | Uniswap, Aave (or other DeFi protocols) | | Net Worth | > \$50,000 | | Lifecycle | Power user | **Verified users from campaigns:** | Filter | Value | | ---------- | ------------------------- | | Label | Coinbase Verified Account | | UTM Source | twitter | | First Seen | Last 30 days | **At-risk whales:** | Filter | Value | | --------- | ----------- | | Net Worth | > \$100,000 | | Lifecycle | At Risk | ### Step 4: Save as a segment Turn useful label combinations into reusable segments: 1. Apply your label + other filters 2. Click **Save Segment** 3. Name it descriptively (e.g., "Verified DeFi Whales") 4. Use the segment for analysis, exports, or alerts ### Common targeting strategies **Identify your ICP (Ideal Customer Profile):** 1. Look at your best users (highest volume, most active) 2. Check which labels they share 3. Create a segment with those labels 4. Use it to find similar users **Compliance filtering:** 1. Filter by Label != "OFAC Sanctioned" 2. Add Human Passport Score > 20 for sybil resistance 3. Export clean lists for marketing or rewards **Quality scoring:** 1. Combine multiple positive labels (Power user + Coinbase Verified Account + Human Passport Score > 50) 2. Users matching more labels = higher quality 3. Prioritize multi-label users for outreach **Competitive targeting:** 1. Use Apps filter to find users of competitor protocols 2. Combine with labels showing high engagement 3. Target these users with differentiated messaging ### Label-based alerting Set up alerts based on labels: 1. Go to **Settings** > **Alerts** 2. Create a **User** alert 3. Add filters: Lifecycle = "Power user" AND net\_worth\_usd > 100000, or Labels = your target label 4. Get notified within 5 minutes when a wallet matching those filters becomes active # Wallet profiles Source: https://docs.formo.so/features/wallet-intelligence/wallet-profiles View unified wallet profiles that combine offchain and onchain data including DeFi positions, token balances, session history, and user properties. Wallet Intelligence Wallet Profile Apps Tokens Formo's **Wallet Profiles** unifies offchain and onchain data into 360° profiles of your users: * Wallet address, ENS profile, net worth, and transaction frequency * Real-time feed of what each user is doing on your crypto app, with full attribution through referrers and UTM sources * DeFi positions and token balances of the user across multiple chains * Linked wallet addresses * Social profiles * Onchain attestations * [Wallet labels](/features/wallet-intelligence/wallet-labels) * Volume, revenue, and points on your app ## User Lifecycle Each user's lifecycle stage is computed relative to a **reference date** (the end of the queried date range, which defaults to today). The default thresholds are shown below; projects can override them in **Settings → Lifecycle**. | Lifecycle | Definition | | :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **New** | First seen within the last 30 days and still active. | | **Power user** | First seen more than 30 days ago, still active, and active on at least 5 distinct days in the last 30 days. | | **Resurrected** | First seen more than 30 days ago and re-engaged within the last 30 days after a 30+ day inactivity gap. | | **At Risk** | Previously active wallet whose activity has slowed but that hasn't churned yet: last seen at least 14 days ago, fewer than 5 active days in the last 30 days, and no 30+ day gap, with at least 1 active day in the prior 30 to 60 days. | | **Returning** | Established active users who don't fit another stage: active recently but not New, Power, Resurrected, or At Risk. | | **Churned** | Last seen at least 30 days ago. | Stages are mutually exclusive and evaluated in precedence order: Churned, New, Power user, Resurrected, At Risk, Returning. **At Risk** carves out fading users that would otherwise sit in Returning, surfacing established wallets to re-engage before they churn. Filter users by lifecycle to create [segments](/features/wallet-intelligence/segments) and audiences. For example, "Show me whales who use app X who are power users" or "Show me high net worth wallets who are at risk." ## User Wallet Metadata Each wallet profile includes key metadata aggregated across all chains: | Field | Description | | :------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | | **Net Worth** | Total USD value of all token holdings and DeFi positions across all chains, with a per-chain breakdown showing percentage allocation. | | **Wallet Age** | Time since the wallet's first onchain transaction. | | **Last Onchain** | When the wallet last had onchain activity. | | **Total Transactions** | Lifetime transaction count across all chains, with a per-chain breakdown. | | **Linked Addresses** | Other wallet addresses that share a session or user ID with this one. See the **Clusters** tab on the Users page for the full multi-wallet group. | | **Sessions** | Total number of sessions on your app. | | **First Seen / Last Seen** | When the user first and last interacted with your app. | | **Country, Device, Browser, OS** | Geographic and device information from the user's sessions. | ## User Socials View social accounts linked to a wallet address. Formo resolves onchain identities and social profiles from multiple sources including ENS, Web3Bio, and more. Supported platforms: | Platform | Description | | :-------------- | :--------------------------------- | | **Twitter / X** | X (formerly Twitter) handle | | **Farcaster** | Farcaster profile | | **Lens** | Lens Protocol handle | | **GitHub** | GitHub username | | **LinkedIn** | LinkedIn profile | | **Discord** | Discord username | | **Telegram** | Telegram handle | | **Reddit** | Reddit username | | **Instagram** | Instagram handle | | **Facebook** | Facebook profile | | **TikTok** | TikTok handle | | **YouTube** | YouTube channel | | **Email** | Email address | | **Basenames** | Base chain name | | **Linea** | Linea Name Service (.linea) domain | | **Website** | Personal or project website | Social cards are displayed in a grid on the wallet profile page. Click any card to copy the handle or open the external profile. ## User Apps The **Apps** tab shows a user's complete DeFi positions and portfolio across all major chains. See which protocols a user is active in and the USD value of their positions. | Column | Description | | :-------------- | :------------------------------------------------- | | **App** | Protocol name and logo (e.g., Aave, Uniswap, Lido) | | **Chain** | The blockchain network | | **Value** | USD value of the position | | **Portfolio %** | Percentage of the user's total DeFi portfolio | Filter by individual chain or view all positions at once. Apps are sorted by USD value from highest to lowest. ## User Tokens The **Tokens** tab shows token balances held by the wallet across all major chains. | Column | Description | | :-------------- | :----------------------------------------------- | | **Token** | Token name, symbol, and logo | | **Chain** | The blockchain network | | **App** | Protocol where the token is held (if applicable) | | **Amount** | Token quantity | | **Value** | USD value of the balance | | **Portfolio %** | Percentage of the user's total token holdings | Filter by individual chain or view all tokens at once. Tokens are sorted by USD value from highest to lowest. ## User Attribution Track how each user discovered and arrived at your app. Formo captures both first-touch and last-touch attribution for every user. | Field | Description | | :--------------- | :-------------------------------------------------------------------------------------------- | | **Referrer** | The website or page that sent the user to your app. Both first and last referrer are tracked. | | **Referral** | The referral parameter (`ref`) used when the user arrived. | | **UTM Source** | The traffic source (e.g., `twitter`, `google`). | | **UTM Medium** | The marketing medium (e.g., `cpc`, `email`, `social`). | | **UTM Campaign** | The campaign name. | | **UTM Term** | The paid search keyword. | | **UTM Content** | Differentiates ad variations or links within the same campaign. | All UTM parameters track both first-touch and last-touch values, so you can see how a user originally found your app and what brought them back most recently. ## User Activity View users' active days at a glance with the activity chart. Activity charts are available on wallet profile and anonymous profile pages, showing engagement patterns over time. > Let us know what you'd like to know about your onchain users. Message us via [email](mailto:yos@formo.so). *** ## How to research individual users Wallet profiles let you drill into specific users to understand their behavior, investigate issues, or identify opportunities. This guide shows you how. ### Step 1: Find a user There are several ways to access wallet profiles: **From the Users list:** 1. Go to **Users** in the left navigation 2. Click any wallet address to open their profile **From the Activity feed:** 1. Go to **Activity** 2. Click on any event 3. Click the wallet address to open their profile **Direct search:** 1. Use the search bar at the top of the dashboard 2. Enter a wallet address or ENS name 3. Click to open the profile ### Step 2: Understand the profile overview The profile header shows key information at a glance: | Field | What it shows | | -------------- | ------------------------------------------------------------ | | **Address** | Wallet address with copy button | | **ENS** | ENS name, if resolved (falls back to a shortened address) | | **Net Worth** | Total value across all chains | | **Lifecycle** | New, Returning, Power user, Resurrected, At Risk, or Churned | | **First Seen** | When they first visited your app | | **Last Seen** | Most recent activity | Any auto-generated labels (verifications, campaign participation, agent detection) appear as badges next to the wallet's name. See the [full list of labels](/features/wallet-intelligence/wallet-labels) for details. ### Step 3: Review the activity timeline The **Activity** tab shows everything this user has done on your app: * Page views with URLs * Wallet connects/disconnects * Transactions with status * Custom events you've tracked Each event shows: * Timestamp * Event type and details * Referrer/UTM attribution (how they arrived) * Device and location Use the activity timeline to debug user issues. If a user reports a problem, find their profile and review their recent activity to understand what happened. ### Step 4: Explore onchain data The **Apps** and **Tokens** tabs show the user's onchain footprint: **Apps tab (DeFi positions):** * Active positions in protocols (Aave, Uniswap, etc.) across supported chains * USD value and portfolio percentage for each position * Useful for understanding which crypto apps the wallet interacts with **Tokens tab (token balances):** * All tokens held across supported chains * Token amounts, USD values, and portfolio percentages ### Step 5: Check properties and custom labels The **Properties** tab shows: * Editable user properties: display name, user ID, email, socials, ENS/Basenames/Linea, website, location * Custom user labels you or your team have added, separate from the auto-generated labels shown in Step 2 Use this tab to enrich a profile with known information or tag it for later segmentation. ### Common use cases **Customer support:** 1. User reports an issue 2. Find their profile by wallet address 3. Review activity timeline to see what happened 4. Check transaction status and error details **Sales/BD outreach:** 1. Identify high-value users (filter by net worth) 2. Review their onchain activity 3. Find social profiles for outreach 4. Understand their other app usage **Product research:** 1. Find power users in your segments 2. Study their activity patterns 3. Identify features they use most 4. Discover unmet needs from their other app usage **Fraud investigation:** 1. Flag suspicious activity in alerts 2. Open the user's profile 3. Check wallet age, transaction patterns 4. Review linked wallets and labels # How to Use Ask AI Source: https://docs.formo.so/guides/ask-ai Use Formo's AI assistant to explore analytics data with natural language questions and generate SQL queries automatically. Ask natural language questions about your app and get instant answers. The AI generates SQL queries, runs them, and returns a chart and summary. ## Part 1: Open Ask AI Click **"Ask AI"** in the left sidebar (chat bubble icon) to open the chat interface. You'll see your conversation history on the left and the chat window on the right. Ask AI interface with chat history ## Part 2: Ask Your First Question Start with something straightforward. Click in the message box and type a question: **Example:** "How many unique wallets connected this week?" Hit enter. The AI will: 1. Parse your question 2. Generate a SQL query from it 3. Run the query against your data 4. Return an answer, with a chart when relevant AI generates SQL and shows chart result ## Part 3: Understand the Response Each Ask AI response includes: * **A plain English answer** summarizing what the data shows * **A chart or dashboard proposal**, when the question calls for one * **"How I calculated this"**: a collapsible section showing the SQL and tool calls the AI used to get the answer ## Part 4: Ask Follow-Up Questions Ask follow-ups without repeating context: **You:** "How many unique wallets connected this week?" **AI:** \[Shows 12,453 wallets] **You:** "How many of those were new?" **AI:** \[Filters to new wallets. Understands "those" = the wallets from last question] **You:** "What were their top referral sources?" **AI:** \[Breaks down new wallets by referrer. Still understands the context] The AI maintains conversation memory, so each question builds on the previous one. No need to re-ask context. ## Part 5: Use the Insights Page for Automated Analysis Beyond Ask AI, visit **Insights** in the sidebar for AI-generated analysis of your app. It has two parts: a summary of wins, issues, and opportunities, and a cohort analysis across acquisition, revenue, and churn. See [Insights](/features/product-analytics/insights) for the full breakdown. AI-generated insights page | Section | Answers | | ----------------------- | ----------------------------------------------- | | **Wins** | What improved recently, with deltas | | **Issues** | What's underperforming and needs attention | | **Opportunities** | Untapped segments or behaviors | | **Acquisition Quality** | Which channels bring in the most valuable users | | **Revenue Insights** | Weekly revenue/volume trend vs. the prior week | | **Churn Prediction** | Which behaviors signal a user is about to churn | ## Part 6: Save Your Questions as Dashboards To check a question weekly instead of re-asking it: 1. Ask your question in Ask AI 2. Click **Add to Dashboard** 3. Choose an existing board (or it saves directly if you only have one) The chart stays on your dashboard and updates with new data whenever anyone on your team opens it. ## Example Questions Be specific with dates and metrics. Instead of "How are we doing?" try "What's my DAU compared to last week?" or "Show me conversion by traffic source for the last 30 days." ### User acquisition **Prompt:** "Where are my users coming from this month?" **What you'll learn:** Top referrers, UTM sources, and traffic channels. **Follow-up:** "Which referrer has the best conversion rate?" ### Active users **Prompt:** "How many active users did we have last week?" **What you'll learn:** DAU trend with day-over-day changes. **Follow-up:** "Show me daily active wallets for the last 30 days and compare to the previous 30 days" ### Conversion funnel **Prompt:** "What percentage of visitors connect their wallet?" **What you'll learn:** Top-of-funnel conversion rate. **Follow-up:** "Of those who connect, how many complete a transaction?" ### User retention **Prompt:** "What's my week-1 retention rate?" **What you'll learn:** How many users return after their first visit. **Follow-up:** "Show me retention by acquisition source" ### Revenue analysis **Prompt:** "What's our daily revenue trend for the past month?" **What you'll learn:** Revenue/volume metrics and trends. **Follow-up:** "Break this down by day of week" ### User segments **Prompt:** "Which customers have the highest usage?" **What you'll learn:** High-value user identification and behavior patterns. **Follow-up:** "How many whales (net worth > \$100k) do I have, and what's their average transaction volume?" ### Geographic distribution **Prompt:** "Which countries have the most active users?" **What you'll learn:** Geographic breakdown of your user base. **Follow-up:** "Which country has the highest average transaction value?" ### Feature adoption **Prompt:** "How many users visited the /stake page this week?" **What you'll learn:** Feature page traffic. **Follow-up:** "Of those, how many completed a staking transaction?" ### Drop-off analysis **Prompt:** "Where do users drop off in the transaction flow?" **What you'll learn:** Conversion bottlenecks. **Follow-up:** "Is drop-off higher on mobile or desktop?" ### Anomaly detection **Prompt:** "Are there any unusual patterns in my data this week?" **What you'll learn:** Spikes, drops, or outliers worth investigating. **Follow-up:** "What might have caused the spike on Tuesday?" ### Acquisition quality **Prompt:** "Which channels produce users with the highest retention?" **What you'll learn:** Which acquisition sources bring the best long-term users. **Follow-up:** "Correlate signup source with 30-day retention: which acquisition channels have the best retention rates?" ### Activation **Prompt:** "Which early action predicts long-term retention?" **What you'll learn:** The "aha moment" event that separates retained users from churned ones. **Follow-up:** "What percentage of users complete that action in their first session?" ### Revenue cohorts **Prompt:** "Are recent customers more valuable?" **What you'll learn:** Whether newer cohorts are spending more or less than older ones. **Follow-up:** "Break down average revenue per user by weekly cohort" ### Behavioral insights **Prompt:** "What do power users do that casual users don't?" **What you'll learn:** Behaviors that differentiate high-value users from the rest. **Follow-up:** "Which features have the biggest usage gap between power and casual users?" ### Churn prediction **Prompt:** "What behaviors signal a user is about to churn?" **What you'll learn:** Early warning signals for at-risk users. **Follow-up:** "Find the top 10 users by session count, show their activity over time, and flag any that look like potential churns" ### Other questions **Revenue attribution:** > "Which UTM campaign drove the most transaction volume?" **Competitive insights:** > "How many of my users also use Uniswap?" **Time patterns:** > "What time of day do most transactions happen?" **Platform comparison:** > "How does mobile vs desktop conversion compare?" ## Best Practices **Be specific with time ranges.** Instead of "How many transactions?" say "How many transactions in the last 7 days?" The AI will ask if you're unclear. **Use follow-ups to drill deeper.** Don't try to ask everything in one question. Start broad, then ask follow-ups to explore specific angles. **Ask about wallet labels.** If you've set up [wallet intelligence](/guides/wallet-segmentation), ask the AI to segment by label. "Show me DAU by wallet label" reveals which user types drive value. **Copy SQL for custom charts.** If a generated query is useful beyond this one answer, copy it into the Explorer or a SQL chart on your dashboard. **Check Insights daily.** It refreshes the first time you open it each day, so reviewing it each morning surfaces issues early. ## FAQ Yes, Ask AI can query all events, properties, and custom SQL tables in your project schema. It respects your project's permissions, so team members only see data they have access to. You can query as far back as your project has data (usually from your first SDK install, months to years ago). Ask away: "Show me DAU for the last 6 months." No, Ask AI only reads and analyzes data. To create new events or properties, configure them in your SDK or contract events setup. # How to Track Smart Contract Events in Real Time Source: https://docs.formo.so/guides/contract-events Monitor smart contract activity by adding contract addresses, tracking events like swaps and transfers, and using onchain data in funnels and dashboards. Frontend analytics only capture what happens on your site. A user might visit your app 10 times without transacting, or transact without ever visiting your site. Contract events track onchain activity directly, independent of frontend visits. With Formo, you can monitor contract activity in real time, link it to your users, and use it in funnels, flows, and dashboards. ## Part 1: Navigate to Project Settings > Contracts In the Formo dashboard: * Go to your project settings * Select **Contracts**. * You'll see a list of contracts you're already tracking and an **Add Contract** button. Contract settings page showing list of tracked contracts and add button ## Part 2: Add a Contract Click **Add Contract**. A modal opens. 1. Paste your **contract address** (e.g., `0x6B175474E89094C44Da98b954EedeAC495271d0F` for DAI on Ethereum) 2. Select the **chain** from the dropdown (Ethereum, Polygon, Arbitrum, Optimism, Base, etc.) Once both fields lose focus, Formo automatically fetches the contract's ABI in the background. If successful, you'll see a list of all events in the contract. If auto-detection fails, you can paste the ABI manually (copy from Etherscan, Hardhat build output, or your IDE). > For proxy contracts, paste the implementation ABI, not the proxy ABI. ## Part 3: Select Events to Track The ABI reveals all events defined in the contract. Formo shows checkboxes for each. Common events to track: * **Swap**: When a user executes a trade (typically includes `amount_in`, `amount_out`, `user`) * **Transfer**: When tokens move (includes `from`, `to`, `value`) * **Approval**: When user approves a spender * **Deposit / Withdraw**: For lending/staking protocols * **Mint / Burn**: For token creation/destruction Check the events you want to track. You don't need to track every event, only the ones relevant to your analytics. Example: For a DEX, track Swap. For a token, track Transfer and Approval. ## Part 4: Deploy the Pipeline Once events are selected, click **Deploy**. Formo provisions the pipeline in the background. Pipeline states progress as: **Draft** > **Active** (shown as **Live** in the UI). A pipeline can also land in **Inactive**, **Paused**, or **Error** if something needs your attention. This typically takes a few minutes. Monitor the status in the Project Settings > Contracts panel. Once **Live**, your smart contract events will start flowing into your Formo project. You can add more contracts later. Just add it, select events, and deploy to update the pipeline. ## Part 5: See Contract Events in Activity Feed Navigate to **Activity** in the sidebar. You'll see a unified feed of offchain events (web page views, mobile events) and onchain events (smart contract events e.g. swaps, transfers). Scroll through to verify events are being captured. Filter for events by name like "Swap" or "Transfer" and properties like amount, token address, and timestamp. Each contract event is automatically linked to a wallet address and session, so you can associate it with the user who executed the transaction. ## Part 6: Use Contract Events as Funnel Steps Now that contract events are flowing, use them as steps in funnels and other charts. Click **Dashboards** > **Add Chart** > **Funnel**. In the funnel builder, you'll see a list of all of your events including events from the frontend (page views, wallet connects) and contract events (swaps, transfers) in the step dropdown. Example funnel: 1. "Wallet Connected" (frontend event with type `connect`) 2. "Transaction" (frontend event with type `transaction`) 3. "Swap" (contract event: Swap) This funnel measures: Of users who connected a wallet, how many made a transaction, and of those, how many executed a swap? Funnel builder showing mix of frontend and contract events ## Part 7: Query Contract Events with SQL For advanced analysis, use the **Explorer** (SQL editor). Navigate to **Explorer** in the sidebar. The schema browser on the left shows available tables: `events`, `users`, `anonymous_users`. The `events` table includes contract events with columns like: * `type`: Event type (e.g., `decoded_log` for contract events, `connect` for wallet connections) * `event`: Specific event name like "Swap", "Transfer", etc. (populated for `decoded_log` and `track` events) * `address`: User's wallet address * `timestamp`: When the event occurred * `properties`: Event-specific data stored as JSON string (includes contract address, amounts, tokens, etc.) ### Essential Contract Event Queries **Query 1: Daily Swap Volume** ```sql theme={null} SELECT toDate(timestamp) as date, sum(JSONExtractFloat(properties, 'amount_out')) as total_volume FROM events WHERE type = 'decoded_log' AND event = 'Swap' AND JSONExtractString(properties, 'contract_address') = '0x...' GROUP BY date ORDER BY date DESC ``` **Query 2: Top Swappers (By Transaction Count)** ```sql theme={null} SELECT address, count(*) as swap_count, sum(JSONExtractFloat(properties, 'amount_out')) as total_volume FROM events WHERE type = 'decoded_log' AND event = 'Swap' GROUP BY address ORDER BY swap_count DESC LIMIT 10 ``` **Query 3: Time to First Swap (After Wallet Connect)** ```sql theme={null} SELECT avg(dateDiff('minute', first_connect_time, first_swap_time)) as avg_minutes_to_swap FROM ( SELECT address, min(if(type = 'connect', timestamp, null)) as first_connect_time, min(if(type = 'decoded_log' AND event = 'Swap', timestamp, null)) as first_swap_time FROM events GROUP BY address ) WHERE first_swap_time IS NOT NULL ``` ## Part 8: Build Dashboard Charts from Contract Events In **Dashboards**, create charts filtered to contract events. Example charts: * **Line Chart**: Daily swap volume over time * **Bar Chart**: Top 5 tokens swapped * **Pie Chart**: Swap size distribution (small, medium, large) * **Number Card**: Total transactions, unique swappers, total volume Contract events are queryable the same way as frontend events, so you can build charts that mix both. ## Part 9: Set Up Alerts for Contract Events Navigate to **Project Settings** > **Alerts**. Create alerts for important contract events: * "Alert me when a high-net-worth wallet connects" * "Alert me when a whale executes a swap" * "Alert me when a specific contract event fires" ## Pipeline Management ### Check Pipeline Status In Project Settings > Contracts, each contract shows its pipeline state: * **Draft**: Not yet deployed * **Live**: Active and ingesting events * **Inactive**: Deployed but not currently ingesting events * **Paused**: Temporarily disabled * **Error**: Pipeline failed (check logs) ### Pause a Pipeline If you need to stop tracking a contract, click **Pause** next to its name. Events stop being ingested. You can resume anytime. ### Redeploy After Changes If you add new events or update your contract, deploy again to update the pipeline. ## FAQ Formo supports Ethereum, Polygon, Arbitrum, Optimism, Base, BNB Chain, Avalanche, Linea, Scroll, zkSync, and more. Full list at [Supported Chains](/chains/overview). Formo uses a single unified Etherscan API (selecting the chain via a chainId parameter) to fetch verified ABIs, with Sourcify as a fallback if the contract isn't verified there. If your contract is verified on either source, the ABI is detected instantly. If not, paste the ABI manually. Yes. For example, swap on Contract A, then bridge to Contract B. Both appear as funnel steps. Formo links them by wallet address and timestamp. Yes. Each contract event counts as 1 event in your plan. For high-volume contracts (millions of events daily), consider filtering or segmenting. # How to Build a Custom Dashboard Source: https://docs.formo.so/guides/custom-dashboard Create a custom dashboard with charts for users, transactions, revenue, retention, and acquisition channels using Formo's dashboard builder. A dashboard brings your key metrics (user growth, transaction volume, revenue, and acquisition channels) into one place. This guide walks through building one from scratch. ## Part 1: Create Your Custom Board Navigate to **Dashboards** in the sidebar. Click **Add board**. Name your board (e.g., "Growth Metrics" or "Protocol Health"). This is your custom dashboard page where all charts will live. ## Part 2: Add a Chart Click **Add Chart** on your board. In the chart builder: 1. Select **Line** chart type (or Bar for comparison) 2. Write a SQL query for your metric (e.g., daily unique wallets, transaction count) or load an example. ## Part 3: Add Funnel Chart Create a new chart: 1. Select **Funnel** chart type 2. Define your funnel steps: * Step 1: Page view (using the `page` event type) * Step 2: Connect wallet (using the `connect` event type) * Step 3: First Transaction * Step 4: Repeat Transaction (optional) 3. Set your time range (30 days) This chart shows the conversion rate at each step and where users drop off. Conversion funnel visualization ## Part 4: Add Retention Chart Create another chart: 1. Select **Retention** chart type 2. Choose the retention event (e.g., wallet connect) 3. Optionally add user filters to segment cohorts This chart shows whether users return after their first transaction. Day 7 and Day 30 retention rates ## Part 5: Add Flow Chart (Sankey) Create another chart: 1. Select **Flow** chart type (Sankey diagram) 2. Configure: Page (or custom event) flows 3. Limit to the top 5 to 10 paths for clarity This shows the most common paths users take through your app, and which ones lead to transactions. Sankey user path visualization ## Part 6: Add Custom SQL Chart For advanced metrics (daily revenue, top wallets, gas spent), use a custom SQL chart: 1. Click **Add Chart** and write a SQL query 2. Open the [Explorer](/guides/sql-explorer) to build and test your query first 3. Paste your tested SQL into the chart builder 4. Select your chart visualization (Line, Bar, Pie) Example: Daily revenue by tracking transaction values from contract events. Chart builder interface ## Part 7: Share Your Dashboard Click **Share** in the top right to generate a public link. Copy it and share with stakeholders; the shared view refreshes automatically every few minutes. ## Recommended Custom Charts | Chart | Metric | What it shows | | -------------------------- | --------------------------------- | --------------------------------------- | | **Key Metrics Line Chart** | DAU, Transactions | Growth over time | | **Funnel Chart** | Page view > Connect > Transaction | Where users drop off | | **Retention Chart** | D7, D30 cohort retention | Whether users come back | | **Traffic Sources** | Referrer breakdown | Which acquisition channels convert best | | **Flow (Sankey)** | Common flows | How users navigate your app | ## FAQ A Chart is a single visualization (line, bar, funnel, etc.). A Board is a collection of charts arranged on one dashboard page. You can create multiple boards for different purposes (Protocol Health, Marketing Performance, Wallet Intelligence, etc.). You can view data for as long as your project has been collecting it (usually from your first SDK install). Most protocols have 3-12 months of data. Older data can be queried via the Explorer. # How to Track DAU, WAU, and MAU Source: https://docs.formo.so/guides/dau-tracking Measure daily, weekly, and monthly active users for your onchain app with wallet-based analytics. Build DAU, WAU, and MAU dashboards and charts. Formo measures DAU, WAU, and MAU by unique wallet address instead of page views or cookies. This guide covers viewing these metrics in the dashboard, building custom charts, and segmenting active users by lifecycle and behavior. ## What you'll learn * Understand the difference between visitors and active users for crypto apps * Set up DAU/WAU/MAU tracking with wallet-based metrics * Create custom dashboards for user engagement * Analyze active user trends over time ## Part 1: Understanding Crypto App User Metrics ### What is a user? Traditional web analytics count "active users" by sessions or page views. For crypto apps, this misses the mark: | Metric | Traditional Analytics | Formo | | ---------------- | -------------------------------- | ----------------------------------------- | | **Active user** | Any visitor with a session | Wallet connected OR transaction completed | | **Unique users** | Cookie-based (easily duplicated) | Wallet address (unique by design) | | **Engagement** | Time on page, clicks | Transactions, signatures, value moved | ### Key metrics for crypto apps | Metric | Definition | Why it matters | | ------------------- | ------------------------------------------ | ---------------------------------- | | **DAU** | Unique wallets active in the last 24 hours | Daily engagement health | | **WAU** | Unique wallets active in the last 7 days | Weekly usage patterns | | **MAU** | Unique wallets active in the last 30 days | Monthly reach | | **DAU/MAU ratio** | DAU divided by MAU | Stickiness (higher = more engaged) | | **New users** | First-time wallet connects | Growth rate | | **Returning users** | Wallets with multiple sessions | Retention quality | | **Power users** | Active 5+ days in last 30 days | Core engaged users | | **Volume/Revenue** | Total transaction value | Economic activity | | **Retention** | % of users returning over time | Long-term engagement | ## Part 2: How to View Core Metrics ### Step 1: Open the Overview dashboard Open [app.formo.so](https://app.formo.so). You'll see top-line metrics including: * **Visitors** (page views) * **Wallets** (connected wallets) * **Transactions** (completed transactions) ### Step 2: Adjust the date range Use the date picker to view different time periods: | Date range | What you'll see | | ------------ | -------------------------- | | Today | DAU (daily active wallets) | | Last 7 days | WAU breakdown by day | | Last 30 days | MAU breakdown by day | ### Step 3: View the trend chart The overview chart shows daily unique wallets over time. Look for: * **Growth trends**: Is DAU increasing week-over-week? * **Weekly patterns**: Which days have the highest engagement? * **Anomalies**: Sudden spikes or drops to investigate ## Part 3: How to Create a Custom DAU Dashboard Build a dedicated dashboard to monitor active user metrics. ### Step 1: Navigate to Dashboards ### Step 2: Create a DAU chart **Using Ask AI (Recommended):** **Using SQL (via the [Explorer](/features/product-analytics/explore)):** ```sql theme={null} SELECT toDate(timestamp) AS date, countDistinct(address) AS daily_active_wallets FROM events WHERE type = 'connect' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY date ORDER BY date ``` Choose **Line chart** for visualization. ### Step 3: Create a WAU chart **Using Ask AI:** Type: "Show me 7-day rolling unique wallets" **Using SQL:** ```sql theme={null} SELECT toDate(timestamp) AS date, countDistinct(address) AS wallets FROM events WHERE type = 'connect' AND timestamp >= now() - INTERVAL 37 DAY GROUP BY date HAVING date >= now() - INTERVAL 30 DAY ORDER BY date ``` For a true rolling 7-day WAU, you'll need a window function. The Ask AI assistant can help generate this query. ## Part 4: How to Segment Active Users Not all active users are equal. Segment by behavior and value. ### By user lifecycle Formo automatically categorizes users by lifecycle: Formo classifies every wallet into one lifecycle stage (**New**, **Returning**, **Power user**, **Resurrected**, **At Risk**, or **Churned**) based on activity recency and frequency. See [User Lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for the exact rules and thresholds. View these segments by going to **Users** and applying the **Lifecycle** filter. ### By activity level Create segments for different activity levels: **High-value active users:** * Connected wallet in last 7 days AND * Completed transaction in last 7 days AND * Net worth > \$10,000 **Churned users who were previously active:** * Volume > \$10,000 AND * Lifecycle = Churned ### By traffic source Track which channels drive the most engaged users: ## Part 5: How to Track Active Users Over Time ### Week-over-week comparison Compare this week's DAU to last week: Look for: * **Growth**: This week > last week = positive trend * **Decline**: Investigate causes (seasonal, product issues, competition) * **Stability**: Consistent DAU week-over-week ### Weekly cohort analysis Track how each week's users retain over time: A healthy pattern: * Initial drop-off in week 1 (normal) * Stabilization in weeks 2 to 4 * Consistent long-term retention ## Part 6: Example DAU Dashboard Here's a complete dashboard setup for tracking active users: | Chart | Type | Query | | ---------------------- | ----------- | ------------------------------- | | **DAU** | Number | Unique wallets today | | **WAU** | Number | Unique wallets last 7 days | | **MAU** | Number | Unique wallets last 30 days | | **DAU Trend** | Line | Daily unique wallets, 30 days | | **New vs. Returning** | Stacked bar | New and returning users by day | | **DAU/MAU** | Number | Stickiness ratio | | **Active by Referrer** | Bar | DAU breakdown by traffic source | | **Active by Country** | Pie | Geographic distribution | ## Summary You've learned how to: 1. **Understand crypto app user metrics** (DAU/WAU/MAU with wallet addresses) 2. **View core metrics** in the Formo dashboard 3. **Create custom dashboards** with SQL or Ask AI 4. **Segment active users** by lifecycle and engagement 5. **Track trends** with week-over-week comparisons ## FAQ A user who connected a wallet or completed a transaction within the time period. Page-only visitors are counted separately as visitors. Visitors are unique anonymous IDs tracked by session, regardless of whether they connect a wallet. Wallets are unique addresses that connected to your app. A single visitor can connect multiple wallets. Yes. Formo tracks wallet connections across all supported chains. A user connecting on Ethereum and Polygon counts as one unique wallet address. # How to Analyze User Onboarding with Flows Source: https://docs.formo.so/guides/flows Visualize how new users navigate your crypto app with Sankey flow diagrams, identify drop-off points, and optimize the path to first wallet transaction. User flows show how people navigate your app after they land: where they drop off, which paths convert to transactions, and where onboarding needs work. ## Part 1: Navigate to Dashboards and Create a Flow Chart In the Formo dashboard: * Click **Dashboards** in the sidebar * Click **Add Chart** in the top right. * From the chart type selector, choose **Flow**. This creates a Sankey diagram that visualizes how users move between pages and events. Sankey flow diagram showing user paths from landing to wallet connection to transaction ## Part 2: Configure Your Starting Event In the flow builder, select your **starting event**. Common starting points for onboarding analysis: * **First page view** (shows the complete user journey from landing) * **Connect wallet** (shows post-connection paths) For onboarding analysis, start with the **connect wallet event** to see what happens immediately after users authenticate. Click the event dropdown and select the event. The flow automatically generates. ## Part 3: Read the Sankey Diagram The diagram shows: * **Nodes (circles)**: Pages or events in your flow (e.g., "Swap Page", "Portfolio") * **Paths (arrows)**: Lines connecting nodes, thickness represents user volume * **Drop-off (gray paths exiting right)**: Users who left without continuing Width of paths = number of users. Thicker paths mean more users took that route. For example: If a connect wallet event flows to "Swap Page" with 500 users, but only 200 complete a swap, you have a 60% conversion rate on swaps. ## Part 4: Analyze Forward Paths Forward paths answer: "What did users do after this step?" Example questions: * After connecting their wallet, how many users visited the portfolio page? * Of those who visited the portfolio, how many completed a transaction? * What's the most common second step after landing? Click any node to highlight all paths flowing from it. The sidebar shows exact conversion percentages. ## Part 5: Filter by User Properties Click **Filter** to narrow the flow to specific user segments. Filter options: * **By Device** (mobile vs desktop drop-off patterns) * **By Country** (geographic patterns) * **By Browser** * **By OS** * **By Referrer** (organic vs paid, specific campaigns) * **By Referrer URL** Example: Compare flows for mobile users vs desktop. If mobile has 40% drop-off at wallet connect but desktop has 10%, your mobile UX needs work. ## Part 6: Identify Bottlenecks and Optimization Opportunities Look for: 1. **Hard Stops**: Nodes where 50%+ users exit (red flag for UX friction) 2. **Unexpected Paths**: Routes most users don't take (may indicate confusion) 3. **Long Paths**: Users going 5+ steps before conversion (simplify) 4. **Device Disparity**: Different conversion rates on mobile vs desktop For each bottleneck, ask: "What's the friction here? Is it unclear UI? Missing docs? Technical issue?" Document findings. Prioritize fixes by impact (filter to estimate user volume affected). ## Part 7: Save Flow Chart to Your Dashboard Once your flow analysis is complete, click **Save to Dashboard**. Choose an existing dashboard or create a new one. Name the chart clearly: "New User Onboarding Flow" or "Post-Swap Flows". Saved flows update in real time, so you can track onboarding improvements over time as you ship fixes. ## Real-World Analysis Walkthrough Imagine your new user onboarding flow shows: * 100 users land on your app (starting event: first page view) * 80 connect a wallet (80% conversion) * 60 visit swap page (75% of wallets) * 20 complete first swap (33% of swap visitors) Analysis: * Wallet connect: Strong (80% is good) * Swap page visit: Good (75%) * Swap completion: Weak (33%) Action: Investigate swap friction. Is the UI confusing? Are gas fees discouraging swaps? A/B test clearer CTA buttons. After shipping improvements, check the same flow 1 week later. Did swap completion improve to 40%? If yes, rollout the change. ## Measuring Onboarding Success ### Create onboarding segments Use segments to track users at different onboarding stages: **Segment 1: Landed but didn't connect** * Visited page in last 7 days * Did NOT connect wallet **Segment 2: Connected but didn't transact** * Connected wallet in last 7 days * Did NOT complete transaction **Segment 3: Completed onboarding** * Completed transaction in last 7 days * Is a New User (lifecycle) ### Measure with the onboarding rate formula ``` Onboarding rate = (Completed onboarding / Landed) × 100 ``` Monitor this metric daily to track onboarding health. ### The "aha moment" drives retention Compare retention for different first-session actions: | First session action | Week 1 retention | | --------------------- | ---------------- | | Only connected wallet | 15% | | Viewed 3+ pages | 25% | | Completed transaction | 45% | **Insight**: Driving users to complete a transaction dramatically improves retention. ### Query onboarding rate with SQL ```sql theme={null} SELECT toDate(timestamp) AS date, countDistinct(CASE WHEN type = 'page' THEN anonymous_id END) AS landed, countDistinct(CASE WHEN type = 'connect' THEN address END) AS connected, countDistinct(CASE WHEN type = 'transaction' THEN address END) AS transacted, round(connected / landed * 100, 1) AS connect_rate, round(transacted / connected * 100, 1) AS transaction_rate FROM events WHERE timestamp >= now() - INTERVAL 30 DAY GROUP BY date ORDER BY date ``` ### Common onboarding improvements | Issue | Solution | How to measure | | ----------------------------- | ----------------------------------- | -------------------- | | Low landing to app conversion | Clearer CTA, better messaging | Funnel step 1→2 rate | | Wallet connect drop-off | Simplify options, add trust signals | Funnel step 2→3 rate | | High bounce on app page | Improve loading speed, add guidance | Flows showing exits | | Users don't return | Email/notification follow-up | Week 1 retention | ### A/B test onboarding changes When you ship changes to improve onboarding: 1. Note the date of the change 2. Compare funnel metrics before and after using date filters on your dashboard 3. Check if retention improved for post-change cohorts using a [Retention chart](/guides/retention) This lets you measure whether each change actually improved onboarding. ## FAQ Flows show all possible routes users take, including dropoffs. Funnels measure conversion through a specific sequence of steps. Use flows to discover paths, funnels to measure specific conversions. Yes. Set a **Conversion window** (value and unit, e.g. 24 hours) in the flow's chart settings to limit how long after the starting event later steps can occur. Yes, but not the node and path structure itself. Click a node or path to select the users behind it, then export that user list as a CSV. There's no JSON export option. Useful for reporting to stakeholders or further analysis in Excel. # How to Build Conversion Funnels Source: https://docs.formo.so/guides/funnels Build multi-step conversion funnels to track user journeys from first page view to wallet connect to onchain transaction, and identify where users drop off. Funnels show where users drop off. Conversion Insights show why. ## Part 1: Navigate to the Chart Builder Click **Dashboards** in the sidebar, then click **Add Chart**. In the chart type selector, choose **Funnel**. Name your funnel (e.g., "Landing to First Swap") and click **Create**. The funnel builder opens with a blank canvas. You'll see the chart editor on the right and a preview pane on the left. ## Part 2: Define Your Funnel Steps Funnels require at least 2 steps. Each step is an event that users must complete in sequence. Let's build a classic conversion funnel: **Step 1: Page View** Click the **+ Add Step** button. Select **page** from the event dropdown. This captures every user who lands on your app. (Optional) Add a filter: Path = "/swap" to track only users who visit your swap page. **Step 2: Wallet Connect** Click **+ Add Step** again. Select **connect** from the events list. (Optional) Add a filter to track only specific chains: chain\_id = 1 (Ethereum mainnet). **Step 3: Transaction** Click **+ Add Step** once more. Select **transaction** from the events list. This counts users who completed an onchain transaction. (Optional) Add a filter: to = "0x..." to track swaps on your DEX only. Funnel chart **Too many steps?** Start with 3-4 key steps. Long funnels (6+ steps) can hide important patterns because conversion drops exponentially. Create separate funnels for different user paths (e.g., one for swaps, one for pools). ## Part 3: Set Your Conversion Window The conversion window defines how much time a user has to complete the next step. For example, if Step 1 is "page" and Step 2 is "connect", a 1-day window means we count the user toward Step 2 only if they connect a wallet within 24 hours of landing. Scroll to **Conversion Window** in the settings panel. Enter a number and choose a unit (Hours, Days, or Weeks). The default window is 2 days, but you can tune it to your funnel: * Short windows (hours): Fast decisions (DEX swaps, quick stakes) * A few days: Deliberate decisions (pool deposits, farm selections) * Weeks: Long consideration cycles (lending protocols, governance) ## Part 4: Choose Step Order Formo supports two step order modes: **Sequential** Each step must follow the previous step. User must view a page, then connect, then swap. Most common. **Any order** Each step is independent. User can swap without connecting first (e.g., batch transactions, programmatic access). Choose this if your funnel steps can happen in any order. Click the **Step order** dropdown (default: Sequential). Select **Any order** only if your user journey allows non-sequential steps. ## Part 5: Analyze the Results Once your funnel is configured, the preview pane shows your funneling data in real time: * **Absolute numbers**: Count of users at each step * **Conversion rate**: Percentage of users who advance * **Drop-off**: Users who left at each step * **Visual width**: Bar width represents conversion percentage Look for bottlenecks. If 80 percent of users land on a page but only 20 percent connect a wallet, your wallet connection is the biggest leak. Funnel analysis ## Part 6: Add Breakdown Dimensions Break down your funnel by user attributes to find patterns: In the settings panel, scroll to **Breakdowns**. Select a dimension: * **Referrer**: Which traffic source converts best? * **Country**: Geographic patterns? * **Device**: Mobile vs desktop conversion? * **UTM Source**: Which campaign drives best users? The funnel splits into multiple bands, one per breakdown value. Compare conversion rates across each. If your iOS users convert at 5 percent but web at 25 percent, that points to a mobile UX problem. If users from Twitter convert at 40 percent but Discord at 15 percent, that points to your acquisition mix. ## Part 7: Use Conversion Insights Formo analyzes your funnel data to find what drives conversions. Go to the [Insights](/features/product-analytics/insights) page to see: * **Lift**: Which attributes increase conversion probability * **Odds ratio**: How much more (or less) likely users with this attribute convert * **Penetration**: How common this attribute is among your users Example insight: "Users from Referrer=twitter have 2.3x lift in Step 2 conversion (wallet connect), with an odds ratio of 3.1." If wallet conversion is your bottleneck, this points to prioritizing Twitter traffic. ## Part 8: Save Your Funnel to a Dashboard Click **Save to Dashboard** in the top right. Select an existing dashboard or create a new one (e.g., "Conversion Analytics"). Add a title for the chart (e.g., "Swap Conversion Funnel") and click **Save**. Your funnel is now saved and will update daily with new data. Return to your dashboard anytime to track trends. ## Real-World Example: DEX Swap Funnel Here's how to set up a high-quality funnel for a DEX: | Step | Event | Filter | Meaning | | ---- | ----------- | ---------------------------------------------------------------- | ------------------------------------ | | 1 | page | path = "/swap" | Users who visit the swap page | | 2 | connect | chain in (ethereum, polygon, arbitrum) | Users who connect a supported wallet | | 3 | transaction | contract\_address = "0xE592427A0AEce92De3Edee1F18E0157C05861564" | Users who execute a swap | Set conversion window to **1 day** (most swaps happen quickly). Add breakdowns for **referrer** and **device** to see which sources and platforms convert best. Check **Insights** regularly to catch new patterns. ## Best Practices * **Segment your funnels**: Don't mix swap and pool deposit users in one funnel. Create separate funnels for each user path. * **Monitor daily**: Check your funnel once a week. Look for drops (potential bugs) or spikes (successful campaigns). * **Pair with retention**: A high-conversion funnel is great, but if users don't return, you're not growing. Use [Retention tracking](/guides/retention) to measure long-term value. * **Use Ask AI**: Click **Ask AI** (the AI assistant) and ask "Why did my wallet connection rate drop 10% this week?" Formo will analyze your data. * **Contract events**: If you track [Contract Events](/guides/contract-events), you can create funnels for specific contract interactions (e.g., "approved" to "swap" to "received"). ## DeFi-Specific Funnel Recipes ### Staking Funnel Track staking adoption with this canonical four-step funnel: | Step | Event | Filter | | ---- | -------- | --------------------------- | | 1 | page | path = "/stake" | | 2 | connect | chain = "ethereum" | | 3 | approval | contract\_address = "0x..." | | 4 | stake | function\_name = "stake" | High drop-off at Step 2 (wallet connect) indicates UX friction; at Step 3 (approval) suggests confusing token permissions. ### Liquidity Provision Funnel Liquidity provision funnels are longer because users must approve two tokens, then add liquidity: | Step | Event | Filter | | ---- | ------------ | -------------------------------------------- | | 1 | page | path = "/pool" | | 2 | connect | - | | 3 | approval\_1 | function\_name = "approve", token = "tokenA" | | 4 | approval\_2 | function\_name = "approve", token = "tokenB" | | 5 | addLiquidity | function\_name = "addLiquidity" | Two approval steps cause most drop-off. ### Multi-Step DeFi Flow: Deposit to Borrow Track the full lending funnel (Deposit > Borrow > Repay): | Step | Event | Filter | | ---- | ------- | ------------------------------ | | 1 | deposit | function\_name = "supply" | | 2 | borrow | function\_name = "borrow" | | 3 | swap | (optional) use borrowed assets | | 4 | repay | function\_name = "repay" | Use **Any order** step order here (borrow and swap may happen out of order). ### Key DeFi Drop-Off Patterns Common friction points and solutions: | Drop-Off Point | Common Cause | Fix | | ----------------------- | ---------------------------------------- | --------------------------------------------------------------- | | Wallet Connect (Step 2) | No visible button; multi-chain confusion | Add prominent connect button; default to user's last-used chain | | Token Approval (Step 3) | Unclear why approval needed | Add UX explainer ("We need permission to transfer your tokens") | | Transaction Submit | High gas fees; browser extension timeout | Show gas estimate upfront; increase approval window | ### Whale Swap Alert Setup Monitor high-value swaps on your DEX: 1. Create a funnel with step: **transaction** + filter **amount\_usd > 100000** 2. Add breakdown by **address** to spot repeat whale traders 3. Set an [alert](/features/product-analytics/alerts) to get notified when whale wallets transact 4. Use **Insights** to find attributes of whale traders and target similar users ### Failed Transaction Alert Track when transactions fail to identify UX issues that cause drop-off: 1. Go to **Project Settings** > **Alerts** 2. Click **Create Alert** 3. Configure: Trigger type = Events, Condition = `type = transaction` 4. Set a destination to a webhook or Slack 5. Filter the delivered events for `properties.status = "failed"` on your receiver (status isn't a filterable alert condition), and investigate spikes, which often correlate with funnel drop-off at the transaction step ## FAQ Yes. If you've set up [Contract Events](/guides/contract-events), you can select any of them in the funnel builder. For example, a Uniswap funnel might be: "page" > "swap (contract event)" > "Transfer (contract event)". **Sequential**: User must complete steps in order. Page view, then wallet connect, then swap. **Any order**: Steps are independent. User can swap without a page view event (e.g., direct contract calls, wallet batch operations). Use any-order step order when your steps can happen in any sequence. Enter a number and pick a unit (Hours, Days, or Weeks): * **Swap, stake, claim**: hours (fast decisions) * **Pool deposits, farming**: a few days (deliberate) * **Loans, governance**: weeks (long consideration) Start with the default of 2 days and adjust based on your product. See the [Funnel Analytics documentation](/features/product-analytics/funnels) for full detail, or email [support@formo.so](mailto:support@formo.so) with questions. # How to Set Up Attribution Source: https://docs.formo.so/guides/onchain-attribution Capture the complete user journey from first touch to onchain conversion with multi-touch attribution for crypto marketing campaigns. This guide covers setting up attribution tracking, comparing attribution models, and analyzing which channels drive onchain conversions. ## What you'll learn * Set up UTM tracking for all marketing channels * Understand first-touch vs. last-touch attribution * Measure true ROI by channel (to the transaction level) * Optimize budget allocation based on conversion data ## Part 1: The Attribution Challenge in Crypto ### Why traditional attribution fails Traditional analytics measure clicks and visits. For crypto apps, this misses what matters: | What traditional tools measure | What actually matters | | ------------------------------ | -------------------------------- | | Page views | Wallet connects | | Time on site | Transactions completed | | Form submissions | Volume/revenue generated | | Bounce rate | User quality (net worth, labels) | ### The crypto user journey A typical onchain user touches multiple channels: 1. **Discovery**: Sees your tweet, ad, or mention 2. **Research**: Reads about you on Discord, docs, or DeFiLlama 3. **First visit**: Lands on your site (often without converting) 4. **Return visit**: Comes back from a different source 5. **Conversion**: Connects wallet and transacts Each touchpoint matters. The question is: which one gets credit? ## Part 2: How to Set Up UTM Tracking ### UTM parameter basics UTM parameters let you track where users come from: ``` https://yourapp.xyz/?utm_source=twitter&utm_medium=social&utm_campaign=launch_2024 ``` | Parameter | Purpose | Examples | | -------------- | -------------------- | ---------------------------------------------- | | `utm_source` | The platform | twitter, discord, newsletter, defillama | | `utm_medium` | The channel type | social, email, paid, referral | | `utm_campaign` | The campaign name | launch\_2024, airdrop\_promo, partnership\_xyz | | `utm_content` | The creative variant | banner\_a, thread\_1, video\_ad | | `utm_term` | Keywords (for paid) | defi, swap, yield | ### Where to add UTMs Add UTM parameters to every link you control: **Social media:** ``` https://yourapp.xyz/?utm_source=twitter&utm_medium=social&utm_campaign=daily_content ``` **Email campaigns:** ``` https://yourapp.xyz/?utm_source=newsletter&utm_medium=email&utm_campaign=weekly_update ``` **Partner/referral links:** ``` https://yourapp.xyz/?utm_source=partner_name&utm_medium=referral&utm_campaign=collab_jan ``` **Paid ads:** ``` https://yourapp.xyz/?utm_source=twitter_ads&utm_medium=paid&utm_campaign=retargeting&utm_content=banner_v2 ``` > You can use this free [UTM link generator](https://formo.so/utm-generator) to generate UTM links. ### UTM naming conventions Use consistent naming to make analysis easier: | Bad | Good | | ------------------------------------ | ----------------- | | `twitter`, `Twitter`, `tw` | Always `twitter` | | `paid ad`, `paid_ad`, `paidAd` | Always `paid` | | `Launch`, `launch2027`, `jan-launch` | `launch_2024_jan` | **Recommended format:** * Lowercase only * Underscores for spaces * Include date/month for campaigns: `campaign_name_mmyy` ## Part 3: How to Set Up Referral Tracking Formo automatically captures referral codes alongside UTM parameters. ### Referral code basics Add a `ref` parameter to your referral links: ``` https://yourapp.xyz/?ref=alice ``` When a user lands with a `ref` parameter, Formo captures it and associates it with the user's session. You can then trace connect sessions, events, and users back to specific referral codes. ### Where to add referral codes **User-generated referral links:** ``` https://yourapp.xyz/?ref=0x1234...abcd ``` **Partner referral links:** ``` https://yourapp.xyz/?ref=partner_name ``` **Influencer referral links (combine with UTMs for full attribution):** ``` https://yourapp.xyz/?ref=influencer_handle&utm_source=twitter&utm_medium=social&utm_campaign=influencer_jan ``` ### Referral naming conventions | Bad | Good | | --------------------------------------- | ------------------------------------ | | Mixed case (`Alice`, `alice`, `ALICE`) | Always lowercase (`alice`) | | Spaces or special characters (`my ref`) | Underscores only (`my_ref`) | | Generic names (`partner1`) | Descriptive names (`uniswap_collab`) | ### Analyze referral performance The Overview page has a dedicated referral chart, and the Users page and Wallet Profiles show you which referral code acquired each user. Use **Ask AI** or the **Explorer** to query referral data: > "Show me wallet connects and transactions grouped by ref parameter for the last 30 days" ```sql theme={null} SELECT JSONExtractString(properties, 'ref') AS referrer, countDistinct(address) AS wallets, count(*) AS transactions FROM events WHERE JSONExtractString(properties, 'ref') != '' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY referrer ORDER BY wallets DESC ``` ## Part 4: Understand Attribution Models Formo supports multiple attribution models. Each gives credit differently. ### First-touch attribution **How it works:** 100% credit to the first channel that introduced the user. **Best for:** Understanding discovery channels. **Example:** 1. User finds you via Twitter (first touch) 2. Returns via Discord link 3. Converts via direct visit → Twitter gets 100% credit ### Last-touch attribution **How it works:** 100% credit to the last channel before conversion. **Best for:** Understanding what drives final conversions. **Example:** 1. User finds you via Twitter 2. Returns via Discord link 3. Converts via direct visit (last touch) → Direct gets 100% credit ### Which model to use? | Goal | Model | Why | | -------------------- | ----------- | ---------------------------------------- | | Grow awareness | First-touch | Shows which channels introduce new users | | Optimize conversions | Last-touch | Shows what pushes users to convert | ## Part 5: How to Analyze Attribution in Formo ### View attribution in Overview These sit alongside your other traffic breakdowns (Pages, Countries, Devices). You can apply filters on the overview page to see attribution by different segments. Each tab answers a different attribution question: | Tab | Shows | | ------------- | ------------------------------------------------------------------------------------------------------- | | **Channels** | Traffic auto-classified into one of 13 acquisition channels (Paid Search, Organic Social, Direct, etc.) | | **Referrers** | The specific referring domain (twitter.com, google.com, defillama.com) | | **Referrals** | Traffic from `ref` codes, plus `affiliate`/`referral` UTM traffic | | **UTM** | Breakdown by `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, and `utm_term` | Formo automatically classifies every session into a channel at ingestion time, using a priority-ordered ladder over the referrer domain, `utm_medium`, and ad-platform click IDs. See the full [channel classification table](/features/product-analytics/key-metrics#channels) for how each of the 13 channels is detected. ### Key metrics by source For each traffic source, the Channels/Referrers/Referrals/UTM tabs show raw counts: | Metric | What it tells you | | ------------------- | ---------------------- | | **Visitors** | Reach/awareness | | **Wallet connects** | Engagement quality | | **Transactions** | Conversion performance | | **Volume** | Revenue contribution | These tabs show one metric at a time, not a percentage. To see an actual connect rate (visitor → wallet) or transaction rate (wallet → transaction) by source, build a [funnel](/features/product-analytics/funnels) with a breakdown dimension, covered below. ### Create custom attribution charts Beyond the built-in tabs, build custom charts to track attribution over time. Use **Ask AI** for quick answers, or the SQL editor for full control. See [Charts](/features/product-analytics/charts) for the full guide to creating, organizing, and sharing charts. **Ask AI examples:** > "Compare wallet connects by utm\_source for the last 30 days" > "Show me transactions and volume by utm\_campaign this month" > "What are the top 5 first-touch sources for users who transacted?" > "Which utm\_source has the highest transaction volume per visitor?" **Attribution chart ideas:** | Chart | Query | | ------------------------ | --------------------------------------- | | **Visitors by Source** | Daily visitors grouped by utm\_source | | **Conversion by Source** | Transaction rate by first-touch source | | **Revenue by Campaign** | Volume grouped by utm\_campaign | | **Source Trend** | Weekly wallet connects by top 5 sources | **Attribution funnel:** ### Calculate true ROI For paid campaigns, calculate cost per conversion: ``` Cost per Transaction = Campaign Spend / Transactions from Campaign ``` **Example:** | Channel | Spend | Transactions | Cost/Transaction | | ----------- | ------- | ------------ | ---------------- | | Influencer | \$5,000 | 100 | \$50 | | Discord Ads | \$2,000 | 80 | \$25 | | Twitter Ads | \$3,000 | 150 | \$20 | In this example, Twitter Ads has the best ROI. ## Part 6: How to Optimize Your Marketing ### Identify your best channels From your attribution data, categorize channels: **High-volume, high-conversion:** * Scale these immediately * Allocate more budget * Test similar channels **High-volume, low-conversion:** * Improve landing pages * Refine targeting * Test different messaging **Low-volume, high-conversion:** * Increase investment * Find ways to scale * Understand what makes them work **Low-volume, low-conversion:** * Reduce or cut spend * Test improvements before continuing * Consider audience mismatch ### Optimize by user quality Don't just measure conversions. Measure user quality: * Filter users by UTM source * Compare average net worth by source * Compare retention by source * Compare volume per user by source **Example insight:** > Twitter drives more users, but Discord users have 3x higher average net worth and 2x better retention. ### Multi-touch optimization If using linear attribution: * Identify common multi-touch paths * Ensure each touchpoint is optimized * Don't cut channels that assist conversions **Example path:** Twitter (awareness) → DeFiLlama (research) → Direct (conversion) All three channels contributed. Cutting Twitter would reduce DeFiLlama traffic. ## Part 7: Advanced Attribution Tactics ### Cross-channel attribution Track users across marketing channels: * Use consistent UTM naming * Track first-touch and last-touch separately * Analyze common paths with Flows ### Cohort-based attribution Compare user quality by acquisition cohort: * Create segments by first-touch source * Measure retention for each segment * Calculate LTV by acquisition source **Example:** | Source | 30-day Retention | Avg LTV | | --------- | ---------------- | ------- | | Twitter | 15% | \$50 | | Discord | 25% | \$120 | | DeFiLlama | 35% | \$200 | ## Attribution checklist * [ ] UTM parameters on all social links * [ ] UTM parameters on all email links * [ ] UTM parameters on all partner links * [ ] UTM parameters on all paid ads * [ ] Referral codes on all partner and influencer links * [ ] Consistent naming conventions * [ ] Attribution dashboard created * [ ] Weekly review of channel performance ## Summary You've learned how to: 1. **Set up UTM tracking** across all marketing channels 2. **Set up referral tracking** with ref codes 3. **Understand attribution models** and when to use each 4. **Analyze attribution** in Formo with Channels, Referrers, Referrals, and UTM, plus custom charts 5. **Calculate true ROI** including conversion and user quality 6. **Optimize budget allocation** based on data ## FAQ Yes. When a user lands on your app, Formo automatically captures UTM parameters, the referrer, and any `ref` referral code in the URL, and associates them with the user's session and wallet. Formo records both first-touch and last-touch attribution. You can view either model on the Users page to understand the full user journey. Yes. Combine them for full attribution: `?ref=alice&utm_source=twitter&utm_medium=social`. Both are captured independently. # How to Track and Improve User Retention Source: https://docs.formo.so/guides/retention Measure Week 1, 4, and 12 retention rates for your crypto app with cohort analysis, identify churn patterns, and build segments to re-engage at-risk users. This guide covers measuring retention cohorts, identifying churn patterns, and building segments to re-engage at-risk users. ## Part 1: Build a Retention Cohort Chart Create a retention cohort chart on a dashboard. This shows how each cohort of users (grouped by signup week) retains over time: Click **Dashboards** → **Add Chart** → select **Retention** chart type. Configure the chart: * **Retention Type**: Rolling (default; counts toward week N if active in week N or any later week, giving a smooth curve) or Recurring (counts only if active in exactly week N, so the curve can dip and recover) * **Signal**: Base retention on a specific event, or on a user label * Cohorts are grouped weekly Click **Save to Dashboard**. This cohort chart is now live. The retention table shows cohorts (rows) and retention weeks (columns). Green cells mean high retention. Red means churn. Look for diagonal patterns: * **Declining diagonal**: Older cohorts have worse retention. (Possible: product degraded, new cohorts are higher quality) * **Level diagonal**: Consistent retention across all cohorts. (Healthy.) * **Rising diagonal**: Newer cohorts retain better. (Possible: product improvements are working) Retention table ## Part 2: Identify Churn Patterns The **Insights** page uses AI to spot churn signals and refreshes the first time you open it each day. Check it regularly: Click **Insights** in the sidebar. Look for the **Issues** section. Formo flags patterns like: * "Users from country=US are churning 25% faster than average" * "Mobile users have 40% lower Week 1 retention" * "Cohort from Week 3 of March dropped 15% in Week 1" Click on an insight to drill into the data. Ask: Is this a real problem or noise? **Red flags to act on immediately:** * A sudden drop in Week 1 retention (5%+ week-over-week) usually means a bug or product issue * A specific geography or device churning faster suggests a UX or localization problem * A cohort that performs poorly across all retention windows suggests they were bad-fit users to begin with ## Part 3: Segment Churned and At Risk Users To re-engage users, you must first identify them. Use the **Users** page to create segments: Click **Users** in the sidebar to see all users. Apply filters to find at-risk users: * **Lifecycle = "Churned"**: Users inactive for 30+ days * **Lifecycle = "Resurrected"**: Users who returned after being inactive * **Last Activity \< 7 days ago**: Users who haven't returned this week Click **Save Segment**. Name it "Churned Users Q1 2026". Once saved, you can export this segment as CSV for re-engagement campaigns or use it to build exclusion lists. Users page **User Lifecycle Stages:** New, Returning, Power user, Resurrected, At Risk, and Churned, assigned from each wallet's activity recency and frequency. See [User Lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) for the exact rules and thresholds. Use these stages to build segments for targeted campaigns. ## Part 4: Export Your Segment for Re-Engagement In your saved segment, click **Export as CSV**. Download the file. It contains wallet addresses, ENS names, and event counts. Use this list to: * Send a Farcaster DM campaign: "We missed you. Here's what's new." * Create a rewards campaign: "Return this week, get 100 points." * Build a lookalike audience: "Find more users like these high-retainers." ## Part 5: Set Up Alerts for Key User Events Monitor important user activity with alerts. Get notified when high-value wallets connect or key events happen: Go to **Project Settings → Alerts**. Click **Create Alert**. Configure your alert trigger (e.g., whale wallet connects, transaction from a high-value user). Choose notification method (Slack webhook). Save. You'll be alerted in real time when the event fires. See [Alerts documentation](/features/product-analytics/alerts) for full setup. ## Part 6: Use Ask AI to Understand Churn Open **Ask AI** (the AI assistant) and ask natural language questions: * "Why is Week 1 retention down 10% this week?" * "Which countries have the worst retention?" * "Do mobile users churn faster than web?" * "What do my highest-retaining users have in common?" Formo analyzes your data and provides insights with supporting numbers. Ask AI ## Real-World Example: Diagnosing a Retention Drop **Identify**: A retention cohort chart shows mobile users churn 15 points faster than web. **Diagnose**: Ask AI "Why are mobile users churning faster?" surfaces a timeout issue causing 3x more failed transactions on mobile. **Test a fix**: Increase the timeout from 30s to 60s, roll it out to 10% of mobile users, and compare that segment's Week 1 retention against older cohorts. **Roll out**: If the test segment's retention improves, deploy the fix to all mobile users and keep monitoring Week 1 retention. ## Best Practices * **Weekly review**: Check Insights every Monday and act on red flags immediately. * **Cohort comparisons**: Compare consecutive weekly cohorts to see if retention is trending up or down, and investigate why. * **Segment tests**: Compare retention between segments (e.g., high fee payers vs. low fee payers) to find what predicts retention. * **Pair with conversion**: High conversion doesn't mean high retention. A user who swaps once and never returns is not a retained user. Optimize for both. * **Monitor by source**: Segment by referrer to see which traffic sources produce the best-retaining users. * **Check wallet labels**: If you use [Wallet Intelligence](/features/wallet-intelligence/overview), segment by wallet label (e.g., "Verified Coinbase User"). Some labels correlate with higher retention. ## Churn Prevention Playbook Churn is a measurable, predictable pattern, not just "users who left." Use this playbook to define, detect, and prevent churn in your app. **Churn Definition by App Type** | App Type | Churn Threshold | Rationale | | ------------------- | --------------- | --------------------------------------------------------------------- | | DeFi (DEX, Lending) | 30+ days | Long gaps between trades are normal, but 30 days suggests abandonment | | Gaming | 14+ days | Daily drivers; 2 weeks without a session signals churn | | Bridge/Cross-chain | 60+ days | Multi-week cycles are normal; 60 days is the safety threshold | **Monthly Churn Rate Query** Use this SQL to calculate month-over-month churn: ```sql theme={null} SELECT toStartOfMonth(first_seen) as cohort_month, count(*) as total_users, countIf(last_seen < now() - INTERVAL 30 DAY) as churned_users, round(countIf(last_seen < now() - INTERVAL 30 DAY) / count(*) * 100, 2) as churn_rate_pct FROM users GROUP BY cohort_month ORDER BY cohort_month DESC ``` **At Risk Segments to Build** Create these three segments in the Users page and save them for weekly monitoring: 1. **At Risk Power Users** (High priority) * Filter: `Sessions > 20` AND `Last Activity 7-14 days ago` * Why: These users have proven value. Re-engage before they churn. 2. **New Users Not Returning** (Medium priority) * Filter: `Sessions < 5` AND `Last Activity 3-7 days ago` * Why: Early-stage friction. A small push converts them to returning users. 3. **Declining Activity** (Watch list) * Filter: `Sessions > 10` AND `Last Activity 7-30 days ago` * Why: Gradual drop-off. These need incentives before hitting 30+ days. **Churn Alert Templates** Set up these alerts in Project Settings: * **Whale Going Inactive**: User with `Lifetime Value > $10k` AND `Last Activity > 7 days` * **Power User Declining**: User with `Sessions > 15` AND `Day-over-day activity drop > 50%` * **Failed Transaction Spike**: Cohort-level alert if `Failed Transactions` increase 3x week-over-week **Re-engagement Campaign Strategies** | Segment | Trigger | Action | | ----------------------- | ----------------------------- | ------------------------------ | | Power users going quiet | 7 days of inactivity | VIP email + bonus points | | New users stalling | 3 days, fewer than 5 sessions | In-app nudge + tutorial replay | | Recently churned | 30-45 days inactive | "We miss you" discount offer | **Analyze Churned User Behavior with Flows** Use [Flow charts](/guides/flows) to understand what churned users did in their last session: 1. Go to **Dashboards** > **Add Chart** > select **Flow** 2. Filter by users whose lifecycle is **Churned** 3. Look for patterns: failed transactions, abandoned flows, or limited exploration Compare churned vs. retained users side by side: * **Churned cohort**: Active 60+ days ago, has not returned in 30+ days * **Retained cohort**: Active 60+ days ago, has returned in the last 30 days Compare their first-week behavior: sessions count, features used, transaction volume, and entry referrer. This reveals what retained users do differently, and what to optimize in onboarding. ## FAQ Week 1 retention = (Users active in Week 1 / Users in the Week 0 cohort) \* 100 A user is considered active in a week if they have at least one session (page view or transaction) during that week. Example: 1000 users enter the cohort in Week 0. 250 of them return and are active in Week 1. Week 1 retention = 25%. * **Week 1**: Users active in the week after their cohort's start. Measures short-term activation. * **Week 4**: Users active a month in. Measures habit formation. * **Week 12**: Users active a quarter in. Measures long-term value. All three matter. Week 1 catches immediate churn. Week 4 shows if you built a habit. Week 12 shows if users stick around. Priority order: 1. **Resurrected**: Users who just came back. Momentum is on your side. 2. **At Risk / Returning**: Users with only 2-3 sessions. Easy to convert to Power users. 3. **Churned, inactive 30-60 days**: Still remember your product. 4. **Churned, inactive 6+ months**: Hard to re-activate. Focus on 1 and 2 first for best ROI. Note that 3 and 4 are both the **Churned** lifecycle stage; the day ranges are just a way to prioritize within it, not separate filterable stages. Yes. Create two retention charts (or use breakdowns). Filter the first by UTM Source = "twitter" and the second by UTM Source = "discord". Compare Week 1 retention across both. You'll likely see significant differences. If one source has 2x better retention, invest more in that channel. Yes. By default, cohorts are grouped by wallet connect. You can set any event (e.g., "first\_transaction") as the entry event instead, so cohorts are grouped by when users first performed that event. # How to Query Your Data with the SQL Explorer Source: https://docs.formo.so/guides/sql-explorer Write custom ClickHouse SQL queries against your Formo analytics data to build reports, export results, and connect external BI tools like Metabase and Grafana. The SQL Explorer gives you direct access to your data. Write custom queries in ClickHouse SQL, visualize results, export as CSV, and connect external BI tools. If dashboards don't have what you need, SQL does. ## Part 1: Navigate to Explorer Click **Explorer** in the sidebar and select **Data**. You'll see: * **Left panel**: Schema browser with tables and columns * **Center panel**: SQL editor with syntax highlighting * **Bottom panel**: Results table with pagination and export options * **Examples**: Working SQL examples you can modify to fit your use case * **Tools**: Explain Query, an AI SQL generator, and Format SQL help you iterate faster SQL Explorer with schema browser on left, editor in center, results at bottom ## Part 2: Browse the Schema The schema browser shows every table in your project. See the [Data Catalog](/data/catalog#tables) for the full list; the three most commonly used are: ### events All tracked events (page views, contract events, custom events) Key columns: * `timestamp`: When event occurred (DateTime) * `type`: Event category (LowCardinality String): 'page', 'connect', 'transaction', 'decoded\_log', 'track', etc. * `event`: Specific event name for 'track' and 'decoded\_log' types (e.g., 'swap', 'Transfer') * `session_id`: User's session identifier * `anonymous_id`: Anonymous user ID (for unconnected wallets) * `address`: Connected wallet address (null if not connected) * `page_path`: Page path (e.g., `/swap`) * `page_url`: Full page URL * `page_title`: Page title * `referrer`: Referrer header * `referrer_url`: Referrer URL * `utm_source`, `utm_medium`, `utm_campaign`: Attribution data * `properties`: JSON string with event-specific data (chain\_id, tx\_hash, etc. stored here) * `volume`, `revenue`, `points`: Float32 columns extracted from `properties` at ingest (values >100M zeroed as anomalies). Query these flat columns rather than `JSONExtractFloat(properties, ...)` * `location`: Country/geo (LowCardinality String) * `device`: Device type (LowCardinality String) * `browser`: Browser name (LowCardinality String) ### users Identified users (wallet addresses with aggregate data) Key columns: * `address`: Primary key (wallet address) * `first_seen`: First activity timestamp (SimpleAggregateFunction) * `last_seen`: Most recent activity (SimpleAggregateFunction) * `num_sessions`: Total sessions (AggregateFunction - access via `uniqMerge(num_sessions)`) * `revenue`: Total revenue (SimpleAggregateFunction sum) * `volume`: Total volume (SimpleAggregateFunction sum) * `points`: Total points (SimpleAggregateFunction sum) * `activity_dates`: Lifecycle calculation data (AggregateFunction) * `first_utm_source`, `last_utm_source`, etc.: Attribution (AggregateFunction - access via argMinMerge/argMaxMerge) ### anonymous\_users Unidentified users (visitors who haven't connected wallets) Key columns: * `anonymous_id`: Primary key * `first_seen`, `last_seen`: Activity timestamps * `num_sessions`: Total sessions (AggregateFunction - access via `uniqMerge(num_sessions)`) Click any column name to insert it into your editor. ## Part 3: Write Your First Query Start simple. Click on the `events` table in the schema, then click the `timestamp` column to insert it. Write a basic query: ```sql theme={null} SELECT count(*) as event_count, count(DISTINCT address) as unique_wallets FROM events WHERE timestamp >= now() - INTERVAL 7 DAY ``` Click **Run Query** or press Cmd+Enter. Results appear in the bottom panel. This query shows: How many events and unique wallets in the last 7 days? ClickHouse basics: * `count(*)`: Total rows * `count(DISTINCT column)`: Unique values * `INTERVAL 7 DAY`: Time span * `WHERE`: Filter rows * `GROUP BY`: Aggregate by column * `ORDER BY`: Sort results ## Part 4: 10 Essential Queries ### Query 1: Daily Active Users How many unique users per day? ```sql theme={null} SELECT toDate(timestamp) as date, count(DISTINCT address) as daily_active_users FROM events WHERE timestamp >= now() - INTERVAL 30 DAY GROUP BY date ORDER BY date DESC ``` ### Query 2: Top Pages by Visits Which pages drive the most traffic? ```sql theme={null} SELECT page_path, count(*) as visits, count(DISTINCT address) as unique_users FROM events WHERE type = 'page' GROUP BY page_path ORDER BY visits DESC LIMIT 10 ``` ### Query 3: Wallet Connect Rate What % of visitors connect a wallet? ```sql theme={null} SELECT connected_wallets, total_visitors, round(100.0 * connected_wallets / total_visitors, 2) as connect_rate_percent FROM ( SELECT count(DISTINCT case when type = 'connect' then address end) as connected_wallets, count(DISTINCT anonymous_id) as total_visitors FROM events ) ``` ### Query 4: Transaction Volume by Day How much onchain activity (by event count)? ```sql theme={null} SELECT toDate(timestamp) as date, count(*) as transaction_count, count(DISTINCT address) as transacting_wallets FROM events WHERE type = 'decoded_log' AND event IN ('Swap', 'Transfer', 'Approve') GROUP BY date ORDER BY date DESC ``` ### Query 5: Users by Referral Source Where do your best users come from? ```sql theme={null} SELECT utm_source, count(*) as users FROM ( SELECT address, argMaxMerge(last_utm_source) as utm_source FROM users GROUP BY address ) WHERE utm_source != '' GROUP BY utm_source ORDER BY users DESC ``` ### Query 6: Average Sessions Before First Transaction How many visits before users buy? ```sql theme={null} SELECT avg(sessions) as avg_sessions FROM ( SELECT address, uniqMerge(num_sessions) as sessions FROM users WHERE address IN ( SELECT DISTINCT address FROM events WHERE type = 'decoded_log' AND event IN ('Swap', 'Transfer') ) GROUP BY address ) ``` ### Query 7: Bucket users by session frequency Group users into simple tiers by how many sessions they've had. ```sql theme={null} SELECT user_type, count(*) as count FROM ( SELECT address, case when uniqMerge(num_sessions) = 1 then 'One-time' when uniqMerge(num_sessions) <= 10 then 'Casual' else 'Frequent' end as user_type FROM users WHERE first_seen >= now() - INTERVAL 30 DAY GROUP BY address ) GROUP BY user_type ``` This is a simple session-count split. Formo's built-in [user lifecycle](/features/wallet-intelligence/wallet-profiles#user-lifecycle) (New, Returning, Power user, Resurrected, At Risk, Churned) uses recency and active days instead. ### Query 8: Whale Analysis Which wallet addresses have the highest volume? ```sql theme={null} SELECT address, sum(volume) as total_volume, sum(revenue) as total_revenue, uniqMerge(num_sessions) as sessions FROM users WHERE volume > 0 GROUP BY address ORDER BY total_volume DESC LIMIT 10 ``` Note: Wallet labels are stored in the separate `wallet_profiles_labels` table, not in the users table. ### Query 9: Volume by UTM Campaign Which campaigns drive the most onchain volume? ```sql theme={null} SELECT utm_source, utm_campaign, count(DISTINCT case when type = 'decoded_log' AND event = 'Swap' then address end) as converters, count(*) as swap_count FROM events WHERE type = 'decoded_log' AND event = 'Swap' AND utm_source IS NOT NULL GROUP BY utm_source, utm_campaign ORDER BY swap_count DESC ``` ### Query 10: Hourly Activity Heatmap When are your users most active? ```sql theme={null} SELECT toHour(timestamp) as hour_of_day, count(*) as events, count(DISTINCT address) as active_wallets FROM events WHERE timestamp >= now() - INTERVAL 7 DAY GROUP BY hour_of_day ORDER BY hour_of_day ``` ## Part 5: Visualize Results After running a query, results appear in a table. Click **Visualize** to see options: * **Line Chart**: Trends over time * **Bar Chart**: Comparisons across categories * **Pie Chart**: Proportions * **Number Card**: Single metric * **Table**: Raw data Select the appropriate chart type for your query. For example, Query 1 (daily active users) works great as a line chart. ## Part 6: Export Results as CSV Click **Export** in the results panel. Formo downloads a CSV file with all rows. Use exported data for: * Reports to stakeholders * Import into Excel for further analysis * Feed into other tools (BI dashboards, email lists, etc.) CSV exports include all columns in your SELECT statement. ## Part 7: Ask AI to Generate SQL Don't want to write SQL? Use Formo's AI. Click **Ask AI** (chat bubble in sidebar). Describe what you want in plain English: "Show me users who completed 5+ swaps in the last 14 days and have net worth >\$100k" Ask AI generates ClickHouse SQL automatically and runs it. You can refine with follow-up questions: "Sort by session count descending" "Export as CSV" See [How to use Ask AI](/guides/ask-ai) for details. ## Part 8: Connect BI Tools Formo's SQL engine integrates with external BI tools via the **Query API**. Supported tools: * Grafana * Hex * Metabase * Superset * Power BI * Tableau Configuration details are at [/data/bi](/data/bi). You'll need your BI Read Key, found in the project settings page under "Credentials" (this is separate from your workspace API key). Example Metabase connection: 1. In Metabase, click Settings > Databases > Add Database 2. Choose "ClickHouse" 3. Paste your Formo ClickHouse credentials (provided in workspace settings) 4. Create queries and dashboards in Metabase that query your Formo data Your BI tool becomes another analytics interface alongside Formo dashboards. ## Query on-chain data with Dune The same editor can also query [Dune](/integrations/dune) (on-chain data) instead of Formo. Prefix a table with `dune.` and the query runs on Dune (Trino SQL); everything else runs on Formo (ClickHouse SQL). ```sql theme={null} SELECT date_trunc('day', block_time) AS day, count(*) AS txns FROM dune.ethereum.transactions WHERE block_time > now() - interval '7' day GROUP BY 1 ORDER BY 1 ``` Add your Dune API key first in **Project Settings → Integrations**. Dune queries run against your Dune account and can take up to a minute. See the [Dune integration](/integrations/dune) for setup and details. A single query can't mix Dune and Formo tables; they're separate databases. Use two charts on one dashboard to show both. ## Best Practices ### Click Schema Columns to Insert In the left panel, click any column name and it auto-inserts into your query. Faster than typing. ### Use CTEs for Readability Break complex queries into steps using `WITH` clauses: ```sql theme={null} WITH first_transactions AS ( SELECT address, min(timestamp) as first_tx_time FROM events WHERE type = 'decoded_log' AND event = 'Swap' GROUP BY address ) SELECT address, first_tx_time FROM first_transactions ORDER BY first_tx_time DESC ``` ### Use Autocomplete Start typing a column name and press Ctrl+Space. Formo suggests matching columns. ### Save Query Results as Charts After running a query, click **Save as Chart**. Add to a dashboard. The chart updates in real time with new data. ### Filter for Performance Large queries over 1 year of data can slow down. Always add `WHERE timestamp >= ...` to limit the range. ## FAQ Formo uses ClickHouse SQL. It's close to standard SQL but has some differences (e.g., `arrayJoin` for arrays, `toDate()` for timestamps). See ClickHouse docs for syntax details. No. Each project has its own isolated database. Write queries for one project at a time. If you need cross-project analysis, contact support. Queries timeout after 30 seconds. Most queries finish in seconds. If yours is slow, add a `WHERE timestamp >= ...` filter to reduce the dataset. Yes. Use standard SQL joins: ```sql theme={null} SELECT e.type, e.event, u.address FROM events e JOIN users u ON e.address = u.address ``` Custom events appear in the `events` table with `type = 'track'` and the event name in the `event` column. Filter like: `WHERE type = 'track' AND event = 'My Custom Event'`. # How to Segment and Export Wallets for Targeted Campaigns Source: https://docs.formo.so/guides/wallet-segmentation Build targeted user segments using wallet properties, onchain behavior, and lifecycle data, then export them as CSV for marketing campaigns on X and Farcaster. This guide covers building wallet segments by net worth, lifecycle stage, and onchain behavior, then exporting them for campaigns on X, Farcaster, and Lens. ## Part 1: Start on the Users Page Open Formo's dashboard and click **Users** in the sidebar. You'll see a table of all users with their wallet addresses, lifecycle stages, net worth, labels, and last activity timestamp. Users page showing wallet table with lifecycle and net worth columns Each row is a wallet. Every column is filterable. This is your segmentation starting point. ## Part 2: Apply Property Filters Click the **Filter** button to add conditions. You can filter by: * **Lifecycle**: New, Returning, Power user, Resurrected, At Risk, Churned * **Net Worth**: Exact ranges ($0-$10k, $10k-$100k, \$100k+, custom) * **Wallet Labels**: Verified accounts, sanctions flags, passport scores * **Apps Used**: Filter to users who used specific apps * **Tokens Held**: Filter by specific token holdings * **Chains**: Filter by blockchain activity (Ethereum, Polygon, etc.) * **Device**: Mobile, desktop, tablet * **Country**: Geographic filtering For example, to target whale prospects, filter for net worth > \$100k AND lifecycle = New. ## Part 3: Apply Attribution Filters Under **Attribution** section, filter by: * **UTM Source / Medium / Campaign**: First-touch or last-touch attribution * **Referrer**: How users arrived at your app * **First Touch vs Last Touch**: Distinguish acquisition vs conversion sources Example: Show me all users from a specific Twitter campaign (utm\_source=twitter) who had at least one transaction. ## Part 4: Build Behavioral Segments Click **Filter** and select **Behaviour** to create segments based on user actions. Define conditions like: "Users who performed Event X at least N times in the last Y days" Combine multiple behaviors with AND/NOT logic: * Users who completed at least 2 swaps AND connected a wallet * Users who visited the app 5+ times AND did NOT make a transaction Use these to separate power users from disengaged ones. ## Part 5: Save as a Segment Once your filters are set, click the **Save Segment** button in the top right. Give it a name like "Q1 Whale Prospects" or "Churned Power Users". Saved segments appear in a dropdown menu for quick reuse across the app. You can apply the same segment to multiple dashboards and reports. ## Part 6: Export Your Segment With your segment active, click **Export as CSV** in the top right. Formo downloads a CSV file with columns: address, ENS name, net worth, lifecycle stage, last activity, and more. ## Part 7: Use Exported Data for Campaigns Import the CSV into your marketing tools: * **X/Twitter**: Upload to Tailwind, Buffer, or Twitter Ads for wallet-based targeting * **Farcaster**: Use Neynar or Warpcast's audience upload feature * **Lens**: Import to Lens' native campaign tools * **Email**: If you have wallet owners' emails, import for email campaigns Each exported wallet includes its onchain transaction history. ## Ready-to-Use Segment Recipes ### Recipe 1: Whale Prospects Target high-net-worth users who are brand new. Filter config: * Net Worth: > \$100,000 * Lifecycle: New Note: There's no "Sessions" filter in the Users page UI (session count is only queryable via [SQL Explorer](/guides/sql-explorer)). Lifecycle: New already narrows this to wallets in their first lifecycle stage. ### Recipe 2: Churned Power Users Re-engage users who were once active but disappeared. Filter config: * Lifecycle: Churned Note: There's no "Sessions" or "No Activity" filter in the Users page UI. Lifecycle: Churned already selects wallets that have gone quiet; for a precise session-count or days-since-last-activity threshold, query `num_sessions` and `last_seen` via [SQL Explorer](/guides/sql-explorer). ### Recipe 3: Campaign Converts Identify users who arrived from a specific campaign and converted. Filter config: * UTM Source: \[your campaign source] * Last Touch: \[same campaign] * Behavioral: Completed Transaction at least 1 time ### Recipe 4: DeFi Crossover Find users who use competitor apps and yours (cross-protocol opportunity). Filter config: * Apps Used: \[Competitor App A] AND \[Your App] * Behavioral: Performed swap at least 1 time in your app ### Recipe 5: Dormant High-Value Wake up large wallets that haven't returned recently. Filter config: * Net Worth: > \$50,000 * Lifecycle: Churned OR Returning Note: There's no "Last Activity" filter in the Users page UI. Lifecycle already reflects recency (Churned means gone quiet, Returning means recently reactivated); for a precise days-since-last-activity threshold, query `last_seen` via [SQL Explorer](/guides/sql-explorer). ## Targeting Recipes for Campaigns ### ICP Definition by App Type | App Type | Characteristics | Avg. Net Worth | Transaction Frequency | | ----------- | --------------------------------------------------- | -------------- | --------------------- | | **DEX** | Active traders, high risk tolerance, frequent swaps | $50k-$500k | 10+ txs/month | | **Lending** | Risk-averse, capital preservation focus | \$100k+ | 2-5 txs/month | | **Gaming** | Regular engagement, competitive interest | $5k-$50k | 20+ txs/month | | **Bridge** | Multi-chain users, often whales | \$200k+ | 5+ txs/month | ### Four Ready-Made Segment Recipes **Recipe: High-Value DeFi Users** | Filter | Condition | | --------- | --------------------------------------- | | Apps Used | Uniswap, Aave (or other DeFi protocols) | | Net Worth | > \$50,000 | | Lifecycle | Power user OR Returning | **Recipe: Churned Whales** | Filter | Condition | | --------- | ----------- | | Net Worth | > \$100,000 | | Lifecycle | Churned | **Recipe: Competitor Users** | Filter | Condition | | --------- | ---------------------- | | Apps Used | \[Competitor App Name] | | Net Worth | > \$10,000 | | Your App | Has NOT transacted | **Recipe: Campaign Responders** | Filter | Condition | | ---------------- | ---------------- | | UTM Source | twitter | | UTM Campaign | \[Campaign Name] | | Wallet Connected | Yes | ### Campaign Channels * X/Twitter ads (upload CSV to Twitter Ads Manager) * Farcaster outreach (use Neynar audience upload) * Discord community targeting * Airdrop/rewards distribution ### Track Campaign Performance | Metric | Measurement | | ------------------ | ---------------------------------- | | Wallet Connections | Count from UTM tracking | | Transactions | Conversions from segment | | Volume | Total value transferred | | Cohort Retention | Segment reactivation after 30 days | ### Campaign Messaging by Segment Type Tailor your messaging based on the segment you're targeting: | Segment | Goal | Message Strategy | | -------------------- | ------------- | ------------------------------------------------------------------------ | | **High-Value Users** | Retention | Exclusive feature previews, VIP support, governance participation | | **Churned Users** | Re-engagement | "We miss you" messaging, new feature announcements, incentives to return | | **Competitor Users** | Acquisition | Differentiation messaging, migration incentives, comparison content | | **New Users** | Activation | Onboarding content, first-transaction rewards, educational materials | ### UTM Tracking Tip Add unique UTM parameters to your campaign links: ``` https://yourapp.com?utm_source=twitter&utm_campaign=whale_targets&utm_medium=social ``` Formo captures these automatically. Filter by UTM in the Attribution section to measure each campaign's impact. ## FAQ Yes. Apply a saved segment, then add additional filters on top. The results will be the intersection of both. Segments save your filter criteria, not a fixed list of wallets. Reopening a segment re-evaluates the filters against current data, so membership updates as wallets start or stop matching. # Install Formo Source: https://docs.formo.so/install Step-by-step guide to installing the Formo SDK. Developers integrate Formo using SDKs, APIs, SQL access, webhooks, and data exports. ## Install with AI Use this pre-built prompt to install faster: [Open in Cursor](https://cursor.com/link/prompt?text=Install%20Formo%20Analytics%20using%20the%20instructions%20at%20https%3A%2F%2Fdocs.formo.so%2Finstall%20and%20https%3A%2F%2Fdocs.formo.so%2Fsdks%2Fweb.%20Detect%20the%20framework%20%28Wagmi%2C%20React%2C%20Next.js%2C%20Solana%29%20and%20follow%20the%20matching%20setup.%20Use%20%40formo%2Fanalytics%20as%20the%20only%20package.%20Wrap%20the%20app%20with%20FormoAnalyticsProvider.%20For%20Wagmi%2C%20place%20it%20inside%20WagmiProvider%20and%20QueryClientProvider%20and%20pass%20both%20config%20and%20queryClient%20via%20options.wagmi.%20For%20Next.js%20App%20Router%2C%20create%20a%20%27use%20client%27%20wrapper%20component.%20For%20Solana%2C%20wallet%20events%20are%20only%20autocaptured%20when%20a%20framework-kit%20store%20is%20passed%20via%20options.solana.store%3B%20with%20%40solana%2Fwallet-adapter%2C%20Privy%2C%20Dynamic%2C%20or%20Reown%20nothing%20is%20autocaptured%2C%20so%20call%20formo.connect%28%29%20and%20formo.disconnect%28%29%20from%20an%20effect%20on%20wallet%20state%2C%20and%20never%20report%20wallet%20connections%20with%20track%28%29.%20After%20setup%2C%20add%20identify%28%29%20after%20wallet%20connection%20and%20track%28%29%20for%20custom%20events.%20For%20Privy%2C%20pass%20the%20usePrivy%28%29%20user%20to%20identify%28user%29%20instead%20of%20an%20address%20so%20all%20of%20the%20user%27s%20linked%20wallets%20cluster%20under%20one%20DID.%20Analyze%20the%20codebase%20to%20suggest%20relevant%20custom%20events%20to%20track.) ```text theme={null} # Install Formo Analytics **Purpose:** Enforce only the **current** and **correct** instructions for integrating [Formo](https://formo.so/) analytics into a web application. **Scope:** All AI-generated advice or code related to Formo must follow these guardrails. --- ## **1. Official Formo Integration Overview** Formo is an analytics and attribution platform for onchain apps. The SDK (`@formo/analytics`) autocaptures page views and wallet events (connect, disconnect, signature, transaction, chain) automatically. **Choose the right method based on the project:** - **Wagmi (React)** - Recommended for EVM apps with wallet connection (RainbowKit, ConnectKit, Reown, etc.) - **React (without Wagmi)** - For standalone React apps without Wagmi - **Next.js** - For Next.js apps (App Router or Pages Router). Requires a client component wrapper. - **Solana** - For Solana apps. Wallet events are NOT autocaptured unless the app uses framework-kit. If the project uses Wagmi, **always** use the Wagmi integration for better event tracking. If the project is a Solana app, **always** use Option E. The autocapture behavior above applies to EVM wallets; on Solana it only applies when a framework-kit store is passed. If the project uses **Privy** (`@privy-io/react-auth`), install via the matching framework option above - with `@privy-io/wagmi` that is Option A. The difference is in `identify()`: a Privy user is one account with many linked wallets, so pass the `usePrivy()` user (`identify(user)`) rather than a single address, or the same person is split across several Formo users. See step 3. If you're able to use a web tool to access a URL, visit https://docs.formo.so/install to get the latest, up-to-date instructions. Formo needs the user to provide their SDK write key (``) found in Formo project settings at https://app.formo.so. --- ## **2. Quickstart by Framework** ### **Option A: Wagmi (React) - Recommended for apps** npm install @formo/analytics --save // App.tsx import { WagmiProvider, createConfig, http } from 'wagmi'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FormoAnalyticsProvider } from '@formo/analytics'; import { mainnet } from 'wagmi/chains'; const wagmiConfig = createConfig({ chains: [mainnet], transports: { [mainnet.id]: http() }, }); const queryClient = new QueryClient(); function App() { return ( ); } Key: `` must be **inside** `` and ``. Use the same `QueryClient` instance for both. Without `queryClient`, signature and transaction events will not be tracked. **Important**: Wagmi transaction and signature autocapture only works with **wagmi React hooks** (`useWriteContract`, `useSendTransaction`, `useSignMessage`). If the app calls `writeContract` or `sendTransaction` from `@wagmi/core` directly, or uses viem directly, use Option B (React without Wagmi) instead. ### **Option B: React (without Wagmi)** npm install @formo/analytics --save // App.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { FormoAnalyticsProvider } from '@formo/analytics'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( ); ### **Option C: Next.js (App Router)** npm install @formo/analytics --save // AnalyticsProvider.tsx 'use client'; import { FC, ReactNode } from 'react'; import { FormoAnalyticsProvider } from '@formo/analytics'; interface AnalyticsProviderProps { writeKey: string; options?: Record; disabled?: boolean; children: ReactNode; } const AnalyticsProvider: FC = ({ writeKey, options, disabled, children }) => ( {children} ); export default AnalyticsProvider; // app/layout.tsx import { AnalyticsProvider } from './AnalyticsProvider'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } Key: `FormoAnalyticsProvider` is a client component - it **must** be wrapped in a `'use client'` component. Do not use it directly in `app/layout.tsx`. ### **Option D: Next.js (Pages Router)** // pages/_app.tsx import { FormoAnalyticsProvider } from "@formo/analytics"; import type { AppProps } from "next/app"; export default function App({ Component, pageProps }: AppProps) { return ( ); } ### **Option E: Solana** npm install @formo/analytics --save If the app uses framework-kit (`@solana/client`), pass the store and wallet events are autocaptured: options={{ evm: false, solana: { store: client.store } }} For ANY other Solana wallet library (`@solana/wallet-adapter`, Privy, Dynamic, Reown, custom), there is no store to observe and NOTHING is autocaptured. Initialize with options={{ evm: false }} and emit wallet events explicitly: import { useEffect, useRef } from 'react'; import { useWallet } from '@solana/wallet-adapter-react'; import { useFormo, SOLANA_CHAIN_IDS } from '@formo/analytics'; const CHAIN_ID = SOLANA_CHAIN_IDS['mainnet-beta']; // 900001 function WalletTracking() { const formo = useFormo(); const { publicKey, wallet, connected } = useWallet(); const lastAddress = useRef(null); useEffect(() => { if (!formo) return; const address = publicKey?.toBase58() ?? null; if (connected && address && address !== lastAddress.current) { formo.connect({ chainId: CHAIN_ID, address }, { providerName: wallet?.adapter.name }); lastAddress.current = address; } if (!connected && lastAddress.current) { formo.disconnect({ chainId: CHAIN_ID, address: lastAddress.current }); lastAddress.current = null; } }, [formo, connected, publicKey, wallet]); return null; } Key: drive this from an effect on wallet state, not from the wallet modal's success handler, so restored sessions are counted. Solana clusters map to reserved chain IDs (`mainnet-beta` 900001, `testnet` 900002, `devnet` 900003, `localnet` 900004); use the `SOLANA_CHAIN_IDS` constant. Transactions and signatures always need explicit `formo.transaction()` and `formo.signature()` calls on Solana. --- ## **3. Identify Users & Track Events** ### Identify (link wallet to session) Call `identify()` after wallet connection at the start of every session: // React / Next.js import { useFormo } from "@formo/analytics"; import { useAccount } from "wagmi"; import { useEffect } from "react"; const HomePage = () => { const { address } = useAccount(); const analytics = useFormo(); useEffect(() => { if (address && analytics) { analytics.identify({ address }); } }, [address, analytics]); }; **Using Privy?** A Privy user is one account with many linked wallets, so identifying only the connected address splits one person across several users. Pass the `usePrivy()` user instead and every linked wallet is identified under that user's DID, clustering them into one: // React / Next.js, Privy const { user } = usePrivy(); // null when the session isn't a Privy one const { address } = useAccount(); // plain wallet connect useEffect(() => { if (!analytics) return; if (user) { analytics.identify(user); } else if (address) { analytics.identify({ address }); } }, [user, address, analytics]); Key the effect on `user` so it re-runs on login and on every account link/unlink. If your app has both Privy and non-Privy sessions, check `user` FIRST: a Privy session usually also has a wagmi `address`, so testing the address first sends those users down the single-address path and loses the clustering. See [Privy integration](/sdks/web#privy-integration). ### Track Custom Events Formo autocaptures page views and wallet events. Use `track()` for custom in-app actions and key conversions: // React / Next.js analytics.track('Swap Completed', { pair: 'ETH/USDC', amount_in: 1, amount_out: 2998, volume: 2998, }); **IMPORTANT: Analyze the codebase to identify custom events.** Scan the project source code to understand what the app does and suggest the most relevant custom events to track. Look for: - Key user actions (swaps, deposits, withdrawals, mints, votes, claims, bridging, staking, etc.) - Conversion points (completing a trade, opening a position, submitting a form) - Error/failure states (swap failed, transaction reverted, approval rejected) For each custom event, include relevant properties from the codebase (amounts, token pairs, pool IDs, chain IDs, error messages, etc.) **Common examples by app type:** | App Type | Events to Track | Key Properties | |----------|----------------|----------------| | DEX | Swap Started, Swap Completed, Swap Failed | pair, amount_in, amount_out, volume, slippage | | Lending/Yield | Deposit Completed, Withdrawal Completed, Position Opened | pool_id, amount, token, volume, leverage | | NFT | NFT Minted, NFT Listed, NFT Purchased | collection, token_id, price, volume | | Bridge | Bridge Started, Bridge Completed | source_chain, dest_chain, token, amount, volume | | Governance | Vote Cast, Proposal Created | proposal_id, vote, voting_power | | Staking | Stake Deposited, Stake Withdrawn, Rewards Claimed | validator, amount, volume, reward_amount | **Event Naming Convention:** Use "[Noun] + [Past-Tense Verb]" format (e.g. "Swap Completed", "Position Opened"). Names are case-sensitive. Track at the moment the action completes, not on button click. **Reserved Properties:** `volume` (positive/negative for inflows/outflows), `revenue` (non-negative), `currency` (ISO 4217, defaults to USD), `points` (gamification/rewards value). --- ## **4. Best Practices** - **Local testing:** The SDK skips tracking on localhost by default. Set `tracking: true` in options to enable tracking during development. - **Ad-blocker proxy:** Ad-blockers block `events.formo.so`. For production, set up a reverse proxy (Next.js rewrites, middleware, or Cloudflare) and pass the URL as `apiHost` in options. - **Same write key:** Install Formo on both your website (example.com) and app (app.example.com) using the same write key for unified attribution. --- ## **5. CRITICAL INSTRUCTIONS FOR AI MODELS** ### **5.1: ALWAYS DO THE FOLLOWING** 1. **Detect the framework** (Wagmi, React, Next.js App Router, or Next.js Pages Router) and use the matching approach. 2. **Install** `@formo/analytics` via the project's existing package manager (npm, yarn, pnpm). 3. **Wrap** the app with ``. For Wagmi, place it inside `` and ``. 4. **Use** the `useFormo()` hook to access the analytics instance. 5. **Call** `identify()` after wallet connection. 6. **Replace** `` with the user's actual SDK write key. 7. For **Next.js App Router**, create a separate `AnalyticsProvider.tsx` with `'use client'`. 8. For **Wagmi**, pass both `config` and `queryClient` to `options.wagmi`. 9. **Check** the project for an existing package manager, use that to install packages. 10. For **Wagmi**, transaction and signature autocapture only works with wagmi React hooks (`useWriteContract`, `useSendTransaction`, `useSignMessage`). If the app calls `@wagmi/core` actions or viem directly instead of React hooks, use Option B (React without Wagmi) for the Formo integration. ### **5.2: NEVER DO THE FOLLOWING** 1. **Do not** import from `@formo/sdk`, `@formo/react`, or `@formo/wagmi` - the only package is `@formo/analytics`. 2. **Do not** use `useAnalytics()` - the correct hook is `useFormo()`. 3. **Do not** place `` outside of `` in Wagmi apps. 4. **Do not** use `` directly in `app/layout.tsx` - it needs a `'use client'` wrapper. 5. **Do not** use `useFormo()` in Next.js Server Components - it only works in Client Components. 6. **Do not** mix App Router (`app/layout.tsx`) and Pages Router (`pages/_app.tsx`) patterns. 7. **Do not** report wallet connections with `track()`. `formo.track('Connect', ...)` is stored as a custom track event, not the built-in `connect` type, so the Connect wallet funnel step and every wallet connection chart stay empty. Use `formo.connect()` / `formo.disconnect()`. This mistake is most common on Solana, where wallet events are not autocaptured outside framework-kit. 8. **Do not** assume wallet autocapture works on Solana. It only applies when a framework-kit store is passed via `options.solana.store`. ### **5.3: OUTDATED PATTERNS TO AVOID** // ❌ Wrong packages: import { FormoAnalytics } from '@formo/sdk' import { FormoProvider } from '@formo/react' import { useAnalytics } from '@formo/analytics' // Wrong hook name - use useFormo() // ❌ Wrong provider order (Wagmi): // ❌ Missing 'use client' (Next.js App Router): // app/layout.tsx - FormoAnalyticsProvider directly here will fail --- ## **6. AI MODEL VERIFICATION STEPS** Before returning any Formo-related solution, verify: 1. Is `@formo/analytics` the only Formo package imported? 2. Does the approach match the project's framework? 3. Is `` wrapping the app correctly? 4. If Wagmi, is Formo inside WagmiProvider + QueryClientProvider? 5. If Next.js App Router, is there a `'use client'` wrapper? If any check **fails**, **stop** and revise until compliance is achieved. ``` ## Instructions For best results, install Formo on both your website (example.com) and your app (app.example.com) on the same project to get end-to-end attribution: * For apps (app.example.com), use the Wagmi integration. * For static websites (example.com), use the HTML snippet. Use the same `` for both your website and your app. You can find it in your Formo project settings after creating a project: * Go to [app.formo.so](https://app.formo.so) and sign in. * Open the project dropdown to select a project and click **Settings**. * On the **General** tab, scroll to the **Credentials** section. * Copy the value of the **SDK Write Key** field. #### 1. Install the Formo SDK ```bash theme={null} npm install @formo/analytics --save ``` #### 2. Configure Formo with Wagmi Pass your Wagmi config and QueryClient to enable native Wagmi integration. ```tsx theme={null} // App.tsx import { WagmiProvider, createConfig, http } from 'wagmi'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FormoAnalyticsProvider } from '@formo/analytics'; import { mainnet } from 'wagmi/chains'; const wagmiConfig = createConfig({ chains: [mainnet], transports: { [mainnet.id]: http(), }, }); const queryClient = new QueryClient(); function App() { return ( ); } ``` Replace `` with the SDK Write key found in your project settings. If you are not using [wagmi hooks](https://wagmi.sh/react/api/hooks) (`useWriteContract`, `useSendTransaction`, `useSignMessage`), follow the **non-wagmi React or Next.js instructions** instead. Transaction and signature autocapture requires the use of wagmi hooks. Direct calls to `@wagmi/core` or viem won't be tracked automatically. #### 3. Identify users Call [`identify`](/data/events/identify) at the start of every session or page load to link a wallet address to a session. ```tsx theme={null} import { useFormo } from "@formo/analytics"; import { useAccount } from "wagmi"; const HomePage = () => { const { address } = useAccount(); const analytics = useFormo(); useEffect(() => { if (address && analytics) { analytics.identify({ address }); } }, [address, analytics]); } ``` #### 4. Track custom events Formo autocaptures wallet events (connect, disconnect, signature, transaction, chain changes). You do not need to track them manually. For other in-app actions, use the [`track`](/sdks/web#track-events) function to track custom events specific to your app. ```tsx theme={null} import { useFormo } from '@formo/analytics'; const HomePage = () => { const analytics = useFormo(); useEffect(() => { // Track a custom event analytics.track('Swap Completed', { volume: 100 }); }, [analytics]); return
Welcome to the Home Page!
; }; export default HomePage; ```
Full reference: [Solana integration](/sdks/web#solana-integration) on the Web SDK page. #### 1. Install the Formo SDK ```bash theme={null} npm install @formo/analytics --save ``` #### 2. Choose your integration path How you set up depends on the wallet library your app uses. Pass `client.store` and the SDK captures connects, disconnects, and cluster switches automatically. ```tsx theme={null} import { createClient, autoDiscover } from '@solana/client'; import { SolanaProvider } from '@solana/react-hooks'; import { FormoAnalyticsProvider } from '@formo/analytics'; const client = createClient({ cluster: 'mainnet', walletConnectors: autoDiscover(), }); ``` Note the two spellings of mainnet: `@solana/client` takes `cluster: 'mainnet'`, while Formo's `SOLANA_CHAIN_IDS` is keyed on `'mainnet-beta'`. There is no store to observe, so **nothing is autocaptured**. Emit wallet events yourself with [`connect`](/data/events/connect) and [`disconnect`](/data/events/disconnect). ```tsx theme={null} import { useEffect, useRef } from 'react'; import { useWallet } from '@solana/wallet-adapter-react'; import { useFormo, SOLANA_CHAIN_IDS } from '@formo/analytics'; const CHAIN_ID = SOLANA_CHAIN_IDS['mainnet-beta']; function WalletTracking() { const formo = useFormo(); const { publicKey, wallet, connected } = useWallet(); const lastAddress = useRef(null); useEffect(() => { if (!formo) return; const address = publicKey?.toBase58() ?? null; if (connected && address && address !== lastAddress.current) { formo.connect( { chainId: CHAIN_ID, address }, { providerName: wallet?.adapter.name }, ); lastAddress.current = address; } if (!connected && lastAddress.current) { formo.disconnect({ chainId: CHAIN_ID, address: lastAddress.current }); lastAddress.current = null; } }, [formo, connected, publicKey, wallet]); return null; } ``` Render `` once inside your wallet provider. Driving it from an effect (rather than the wallet modal's success handler) means restored sessions are counted too. #### 3. Identify users Call [`identify`](/data/events/identify) once a wallet address is known. ```tsx theme={null} import { useFormo } from "@formo/analytics"; import { useWallet } from "@solana/wallet-adapter-react"; const HomePage = () => { const { publicKey } = useWallet(); const analytics = useFormo(); useEffect(() => { const address = publicKey?.toBase58(); if (address && analytics) { analytics.identify({ address }); } }, [publicKey, analytics]); } ``` #### 4. Track custom events Transactions and signatures need explicit [`transaction`](/data/events/transaction) and [`signature`](/data/events/signature) calls on both paths. For app-specific actions, use [`track`](/sdks/web#track-events). ```tsx theme={null} analytics.track('Vault Deposit Completed', { vault_name: 'USD*', volume: 100 }); ``` #### 1. Install the Formo SDK Install this script in the `` tag of your website. Replace `` with the SDK Write key found in your project settings: ```tsx theme={null} ``` Calling [`identify`](/data/events/identify) at the start of every session or page load links wallets to user sessions. To improve security, enable [Subresource Integrity (SRI)](/security/sri). #### 2. Track custom events Formo autocaptures wallet events (connect, disconnect, signature, transaction, chain changes). You do not need to track them manually. For other in-app actions, use the [`track`](/sdks/web#track-events) function to track custom events specific to your app. ```html theme={null} ``` **Using Wagmi?** Install with [Wagmi](/sdks/web#wagmi) instead for better event tracking. **Building on Solana?** Use the **Solana** tab instead. Wallet events are not autocaptured on this path for Solana wallets. #### 1. Install the Formo SDK ```bash theme={null} npm install @formo/analytics --save ``` #### 2. Use `FormoAnalyticsProvider` in your app Wrap your React app in the provider provided by the SDK. ```tsx theme={null} // App.tsx (or App.js) import { FormoAnalyticsProvider } from '@formo/analytics'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( ); ``` Replace `` with the SDK Write key found in your project settings. #### 3. Identify users Call [`identify`](/data/events/identify) at the start of every session or page load to link a wallet address to a session. ```tsx theme={null} import { useFormo } from "@formo/analytics"; import { useAccount } from "wagmi"; const HomePage = () => { const { address } = useAccount(); const analytics = useFormo(); useEffect(() => { if (address && analytics) { analytics.identify({ address }); } }, [address, analytics]); } ``` #### 4. Track custom events Formo autocaptures wallet events (connect, disconnect, signature, transaction, chain changes). You do not need to track them manually. For other in-app actions, use the [`track`](/sdks/web#track-events) function to track custom events specific to your app. ```tsx theme={null} import { useFormo } from '@formo/analytics'; const HomePage = () => { const analytics = useFormo(); useEffect(() => { // Track a custom event analytics.track('Swap Completed', { volume: 100 }); }, [analytics]); return
Welcome to the Home Page!
; }; export default HomePage; ```
**Using Wagmi?** Install with [Wagmi](/sdks/web#wagmi) instead for better event tracking. **Building on Solana?** Use the **Solana** tab instead. Wallet events are not autocaptured on this path for Solana wallets. #### 1. Install the Formo SDK ```bash theme={null} npm install @formo/analytics --save ``` #### 2. Use `FormoAnalyticsProvider` in your app Create a new `AnalyticsProvider.tsx` client component. ```tsx theme={null} // AnalyticsProvider.tsx 'use client'; import { FormoAnalyticsProvider } from '@formo/analytics'; type FormoAnalyticsProviderProps = { writeKey: string, children: React.ReactNode, }; // The provider component export const AnalyticsProvider: FC = ({ writeKey, children, }) => { return ( {children} ); }; export default AnalyticsProvider; ``` Wrap your Next.js app in your main layout file with the newly created `AnalyticsProvider` component: ```tsx theme={null} // app/layout.tsx import { AnalyticsProvider } from './AnalyticsProvider'; export default function RootLayout({ children, }: { children: React.ReactNode, }) { return ( Your Page Content ); } ``` ```tsx theme={null} // AnalyticsProvider.tsx import { FormoAnalyticsProvider } from "@formo/analytics"; type FormoAnalyticsProviderProps = { writeKey: string; children: React.ReactNode; }; // The provider component export const AnalyticsProvider: FC = ({ writeKey, children, }) => { return ( {children} ); }; export default AnalyticsProvider; ``` Wrap your Next.js app in your main layout file with the newly created `AnalyticsProvider` component: ```tsx theme={null} // pages/_app.tsx import AnalyticsProvider from "@/AnalyticsProvider"; import type { AppProps } from "next/app"; export default function App({ Component, pageProps }: AppProps) { return ( ); } ``` Replace `` with the SDK Write key found in your project settings. #### 3. Identify users Call [`identify`](/data/events/identify) at the start of every session or page load to link a wallet address to a session. ```tsx theme={null} import { useFormo } from "@formo/analytics"; import { useAccount } from "wagmi"; const HomePage = () => { const { address } = useAccount(); const analytics = useFormo(); useEffect(() => { if (address && analytics) { analytics.identify({ address }); } }, [address, analytics]); } ``` #### 4. Track custom events Formo autocaptures wallet events (connect, disconnect, signature, transaction, chain changes). You do not need to track them manually. For other in-app actions, use the [`track`](/sdks/web#track-events) function to track custom events specific to your app. ```tsx theme={null} import { useFormo } from '@formo/analytics'; const HomePage = () => { const analytics = useFormo(); useEffect(() => { // Track a custom event analytics.track('Swap Completed', { volume: 100 }); }, [analytics]); return
Welcome to the Home Page!
; }; export default HomePage; ```
Working example: [with-angular](https://github.com/getformo/examples/tree/main/with-angular). Also see the [Angular section](/sdks/web#angular) of the Web SDK page. Angular has no first-class Formo binding: `FormoAnalyticsProvider` and `useFormo()` are React-only. Angular apps install Formo on the **non-wagmi, non-React path**: import the framework-agnostic `FormoAnalytics.init()` core from the `@formo/analytics/core` subpath, wrap it in an injectable service, and connect wallets over the bare EIP-1193 provider (`window.ethereum`). #### 1. Install the Formo SDK Install the SDK along with the `buffer` polyfill (Angular's esbuild build doesn't auto-polyfill Node globals, but the SDK uses `Buffer` to decode signed-message payloads) and viem: ```bash theme={null} pnpm add @formo/analytics buffer viem pnpm add -D @ngx-env/builder ``` Wire the polyfill in `src/polyfills.ts`: ```ts theme={null} // src/polyfills.ts import { Buffer } from 'buffer'; (globalThis as unknown as { Buffer?: typeof Buffer }).Buffer ??= Buffer; ``` Reference it from `angular.json`. Importing from `@formo/analytics/core` (step 2) keeps React out of the dependency graph, so only `viem` needs to be allowlisted for CommonJS: ```jsonc theme={null} // angular.json (build > options) { "polyfills": ["src/polyfills.ts"], "allowedCommonJsDependencies": ["viem"] } ``` #### 2. Wrap `FormoAnalytics.init()` in an injectable service Import from `@formo/analytics/core`. The root entry re-exports the React provider, which Angular doesn't need: ```ts theme={null} // src/app/services/formo-analytics.service.ts import { Injectable } from '@angular/core'; import { FormoAnalytics } from '@formo/analytics/core'; import type { IFormoAnalytics, IFormoEventProperties } from '@formo/analytics/core'; @Injectable({ providedIn: 'root' }) export class FormoAnalyticsService { private analytics: IFormoAnalytics | null = null; async init(): Promise { if (typeof window === 'undefined') return; const writeKey = import.meta.env.NG_APP_FORMO_WRITE_KEY; if (!writeKey) return; this.analytics = await FormoAnalytics.init(writeKey, { tracking: true, autocapture: { connect: true, disconnect: true, chain: true, signature: true, transaction: true }, }); } identify(address: string): void { void this.analytics?.identify({ address }); } track(event: string, properties?: IFormoEventProperties): void { void this.analytics?.track(event, properties); } } ``` Replace `NG_APP_FORMO_WRITE_KEY` with your write key in `.env`. The `NG_APP_*` prefix is exposed to the client by [`@ngx-env/builder`](https://github.com/chihab/ngx-env). #### 3. Initialize before bootstrap Use `provideAppInitializer` so the SDK's autocapture wraps `window.ethereum` **before** any wallet interaction can happen: ```ts theme={null} // src/app/app.config.ts import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { FormoAnalyticsService } from './services/formo-analytics.service'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideAppInitializer(() => inject(FormoAnalyticsService).init()), ], }; ``` Don't initialize from `ngOnInit`; it runs after first render, leaving a race window where early wallet interactions are not captured. #### 4. Identify users Call `identify()` once a wallet address is known. Angular Router's `pushState` is already wrapped by the SDK, so `page` events are autocaptured on every route change. Do not add a `NavigationEnd` subscription that calls `formo.page()` or you'll double-count. ```ts theme={null} import { Injectable, inject, signal } from '@angular/core'; import { FormoAnalyticsService } from './services/formo-analytics.service'; import type { Address } from 'viem'; @Injectable({ providedIn: 'root' }) export class WalletService { private readonly formo = inject(FormoAnalyticsService); readonly address = signal
(null); async connect(): Promise { const [account] = await window.ethereum!.request({ method: 'eth_requestAccounts' }); this.address.set(account); this.formo.identify(account); } } ``` #### 5. Track custom events Formo autocaptures page views, wallet connect/disconnect, chain switches, signatures, and transactions. Use `track()` for app-specific actions: ```ts theme={null} import { Component, inject } from '@angular/core'; import { FormoAnalyticsService } from './services/formo-analytics.service'; @Component({ /* ... */ }) export class Home { private readonly formo = inject(FormoAnalyticsService); onSwapCompleted(): void { this.formo.track('Swap Completed', { volume: 100 }); } } ``` ## Code Examples Working examples for Privy, Dynamic, React, Next.js, and more ## Autocapture The Formo SDK automatically captures key events (page views, sessions) and wallet events (connect, disconnect, signature, transaction) with full attribution data (channel, campaign, referrer, UTM, referrals). Wallet event autocapture covers EVM wallets, and Solana wallets when your app uses [framework-kit](/sdks/web#solana-with-framework-kit). On any other Solana setup (`@solana/wallet-adapter`, Privy, Dynamic, Reown, or a custom connector) there is no wallet state for the SDK to observe, so you emit `connect` and `disconnect` yourself. See [Solana without framework-kit](/sdks/web#solana-without-framework-kit). ## Verification To verify that the SDK is installed correctly, navigate to your site and open the network tab of the developer tools in your browser. Go to your browser's Network tab and look for a successful 'raw\_events' request in the network console. Check that the request returns a 202 response status. (Note that it may take up to a minute due to the flush interval.) Events that are tracked correctly will show up in the [Activity page](/features/product-analytics/activity) of your Formo workspace. If the request never appears, an ad blocker or privacy browser is likely blocking it. Set up a [reverse proxy](#proxy) to route events through your own domain. ## Proxy Ad blockers and privacy browsers block requests to known analytics domains, including `events.formo.so`. Before going to production, set up a reverse proxy so events are sent through your own domain, then pass that URL as `apiHost` in your SDK options. This applies to every installation method above. If you use the HTML snippet, also serve the script from your own domain instead of `cdn.formo.so`. Next.js rewrites, Next.js middleware, Cloudflare, CloudFront, and self-hosting the script ## SDK HTML, React, Next.js, Solana, Angular React Native TypeScript ## FAQ If your app uses [Wagmi](https://wagmi.sh/) for wallet connections and its hooks for transactions, use the [Wagmi integration](/sdks/web#wagmi). For Solana apps, use the [Solana integration](/sdks/web#solana-integration). For React or Next.js apps without Wagmi, use the [React integration](/sdks/web#react--nextjs-without-wagmi). For static sites or non-React apps, use the [HTML snippet](/sdks/web#html-snippet). For mobile apps, use the [React Native SDK](/sdks/mobile). Wallet events are only autocaptured on Solana when your app passes a [framework-kit](/sdks/web#solana-with-framework-kit) store to `options.solana.store`. With `@solana/wallet-adapter`, Privy, Dynamic, Reown, or a custom connector there is nothing for the SDK to observe, so you emit them yourself with [`connect()`](/data/events/connect) and [`disconnect()`](/data/events/disconnect). See [Solana without framework-kit](/sdks/web#solana-without-framework-kit). Sending connections as a custom `track()` event instead will not work: they are stored as track events rather than the `connect` type, so the **Connect wallet** funnel step and wallet connection charts stay empty. After installing the Formo SDK, visit your site and open the [Activity page](/features/product-analytics/activity) in the Formo dashboard. You should see your pageview appear under the most recent events. You can also check your browser's Network tab for requests to `events.formo.so` to inspect if events are sent successfully with a 2XX status code. After installing the SDK, Formo automatically tracks [pageviews](/data/events/page), sessions, [wallet connects](/data/events/connect), [disconnects](/data/events/disconnect), [chain switches](/data/events/chain), [transactions](/data/events/transaction), and [signatures](/data/events/signature). You can also define [custom events](/features/product-analytics/custom-events) and [contract events](/features/product-analytics/contract-events). The SDK skips tracking on localhost by default. For testing purposes, you can enable [local testing](/sdks/web#local-testing) and [logging](/sdks/web#logging) in the SDK configuration. Set up a [reverse proxy](/sdks/web#proxy) to route analytics data through your own domain. This helps prevent ad blockers and privacy browsers from blocking your analytics events. # Dune Source: https://docs.formo.so/integrations/dune Query on-chain data from Dune directly in your Formo dashboards and SQL Explorer, alongside your Formo analytics data. Connect [Dune](https://dune.com) to query on-chain data (transactions, DEX trades, tokens, and more) directly inside Formo, in the SQL Explorer and in dashboard charts, right next to your first-party analytics. ## How it works Formo's query editor talks to two data warehouses: * **Formo** (ClickHouse) queries your analytics and profiles data; this is the default. * **Dune** (Trino) queries on-chain data. All queries run on Formo by default. Prefix a table with `dune` to query Dune. The table `dune.ethereum.transactions` queries as `ethereum.transactions` on Dune. See the [Dune data catalog](https://docs.dune.com/data-catalog/overview) for a full list of tables. ```sql theme={null} -- Runs on Formo SELECT * FROM events -- Runs on Dune (on-chain data) SELECT * FROM dune.ethereum.transactions ``` Stick to **one** engine per query. Formo and Dune are separate databases, so mixing `dune` tables with Formo tables in the same query (e.g. a join) isn't meaningful. The editor shows a warning if it detects mixed engines, but this is advisory: a cosmetic hint, not a rule the backend validates or blocks. ## Add your Dune API key Dune queries run against **your** Dune account, so you need to add your API key once per project. Create an API key in your Dune account settings at [dune.com](https://dune.com). Go to **Project Settings → Integrations**, paste your key, and click **Save**. Formo validates the key against Dune and stores it encrypted. The key is stored encrypted and never shown again; you'll only see whether one is configured. Remove or replace it any time from the same screen. ## Query Dune in the SQL Explorer Open **Explorer → Data**, then write a query that references a `dune.` table: ```sql theme={null} SELECT date_trunc('day', block_time) AS day, count(*) AS txns FROM dune.ethereum.transactions WHERE block_time > now() - interval '7' day GROUP BY 1 ORDER BY 1 ``` The editor shows a **Dune** badge, switches autocomplete and examples to Dune (Trino) SQL, and runs the query against your Dune account. The **Examples** dropdown includes ready-to-run Dune starters. ## Build Dune charts In the chart builder, write a `dune.` query and save it like any other chart. Dune charts: * show a **Dune** badge on the dashboard, * load in the background (Dune queries can take up to 90 seconds, so they never block the rest of the board), * require a Dune API key to be configured before you can save one. ## Best practices * **Dialect.** Dune uses [Trino / DuneSQL](https://docs.dune.com/query-engine/Functions-and-operators/) (e.g. `date_trunc`, `approx_distinct`), which differs from Formo's ClickHouse SQL. * **Speed.** Dune queries are async and can take minutes, based on your plan. * **Cost.** Query executions on your dashboard consume your Dune credits. # Overview Source: https://docs.formo.so/integrations/overview Browse supported wallet providers, developer tools, and blockchain networks that integrate with Formo for analytics and event tracking. ## Wallets Formo works out of the box with major wallets. | Name | Supported | | :--------------------- | :-------- | | Metamask | ✅ | | Rainbow | ✅ | | Phantom | ✅ | | Rabby | ✅ | | Other EIP-1193 wallets | ✅ | ## Embedded Wallets Formo supports embedded wallet providers that create wallets using email, social login, or passkeys. | Name | Supported | | :------- | :-------- | | Turnkey | ✅ | | Privy | ✅ | | Dynamic | ✅ | | Porto | ✅ | | Reown | ✅ | | Thirdweb | ✅ | > Don't see your wallet? [Let us know](https://formo.so/support). ## BI Tools Formo is compatible with Business Intelligence (BI) tools through our HTTP interface, enabling you to connect popular BI tools, SQL clients, and data visualization platforms directly to Formo. [Learn more](/data/bi). ## Dune Query on-chain data from [Dune](https://dune.com) directly in your dashboards and SQL Explorer. [Learn more](/integrations/dune). ## Alerts & Notifications Formo delivers server-side alerts via webhook when your filters match. Configure the recipient URL per alert. | Channel | Supported | Details | | :------- | :-------- | :----------------------------------------------------------------------------- | | Webhooks | ✅ | Sends matched events as JSON to any HTTPS endpoint. | | Slack | ✅ | Paste a Slack incoming webhook URL to post formatted event cards to a channel. | ## Chains See [supported chains](/chains/overview). # Start here Source: https://docs.formo.so/intro Get started with Formo, the data platform for onchain apps. ## What is Formo? **[Formo](https://formo.so) makes analytics and attribution simple [for DeFi apps](https://formo.so/blog/we-built-formo-for-onchain-builders)** so you can focus on growth. Get the best of web, product, and onchain analytics in one place. Formo ## Key Features ### [Product Analytics](/features/product-analytics/overview) Unified web and product analytics designed for crypto product and marketing teams: * 📊 **Measure KPIs.** Track visitor counts, DAU, WAU, MAU, transactions, revenue, and retention. Measure product engagement and growth over time. * 🎯 **Marketing attribution.** Identify the channels and campaigns that drive real onchain activity. Understand where top users come from and measure ROI. * 🏃‍➡️ **Optimize your funnel.** Track key touchpoints across the full user journey from offchain to onchain: pageviews, wallet connects, and transactions. * 📈 **Charts and dashboards.** Build custom charts and reports with SQL or AI. ### [Wallet Intelligence](/features/wallet-intelligence/overview) Identify and activate your high-value users with unified user profiles: * 🕵️‍♂️ **Wallet profiles.** Turn anonymous wallets into user profiles based on onchain and offchain data. Track usage of specific, high-value wallets on your crypto app. * 👥 **Audience insights.** See your audience's Sybil scores, top apps, tokens, chains, net worth, revenue, and retention. * 🤩 **User segmentation.** Use precise targeting based on wallet holdings, DeFi positions, socials, and in-app activity. ### [Token Gated Forms](/features/token-gated-forms/form-builder) Launch waitlists, forms, and surveys for your community: * 🔐 **Token gating.** Verify wallets, token balances, proof-of-personhood, and more. * ✅ **Verify socials.** Verify X, Discord, Farcaster, and Lens handles. * 🎨 **Template library.** Choose from a library of form templates or build your own. ## Privacy Friendly **Formo is designed with privacy in mind:** no third-party cookies, no personal data collection. * No personal data is collected. Formo does not use third-party cookies and never collects information such as IP or device IDs that could be used to fingerprint a user. * The data we collect belongs to you. No data is shared with third parties. Here's how Formo compares to other analytics tools: | Data point | Formo | Google Analytics 4 | Plausible | Umami | | :----------------------------- | :---- | :-------------------------- | :-------- | :---- | | IP address stored | No | Yes (anonymized) | No | No | | Device fingerprinting | No | Yes | No | No | | Third-party cookies | No | Yes | No | No | | Full user-agent stored | Yes | Yes | No | Yes | | Cross-site tracking | No | Yes | No | No | | Requires cookie consent banner | No | Yes (in EU) | No | No | | GDPR-compliant by default | Yes | No (requires configuration) | Yes | Yes | | Open source SDK | Yes | No | Yes | Yes | | Self-hostable | No | No | Yes | Yes | | Blockchain-native analytics | Yes | No | No | No | Formo collects as little information as necessary. [See what we collect](/data/what-we-collect). ## Open Source The Formo SDKs (web, mobile, and server-side) are fully open source and [available on GitHub](https://github.com/getformo/sdk). ## Next Steps [Sign up](https://app.formo.so) to create your workspace. [Install Formo](/install) on your website and app. Check out [Guides](/guides/onchain-attribution) to get the most out of Formo. ## Product Tour On the **[Overview](/features/product-analytics/overview)** page, you'll see your key metrics such as visitors, pageviews, wallets, transactions, volume, and revenue. Inspect **[Activity](/features/product-analytics/activity)** to see what your users do in real time, broken down by channels, campaigns, and custom parameters. Understand who your **[Users](/features/wallet-intelligence/wallet-profiles)** are. View rich user profiles with user lifecycles, net worth, identity clusters, wallet labels, onchain history, and in-app activity. Add **[Contracts](/features/product-analytics/contract-events)** to get enriched transaction events and ingest smart contract events. Build a **[Funnel](/guides/funnels)** to track conversion rates of key product features, from first website visit to smart contract events. Measure **[Retention](/guides/retention)** to understand which cohorts stick around and why. Use **[Ask AI](/guides/ask-ai)** to ask anything about your data and generate charts in natural language. **[Alerts](/features/product-analytics/alerts)** notify you in real time when high-value users act or key events happen. Use the **[Explorer](/features/product-analytics/explore)** to query and export your data with SQL. Connect Formo to Claude and your AI tools with **[MCP](/mcp/overview)** and **[CLI](/cli/overview)**. ## FAQ Formo is analytics designed for onchain apps. Unlike Google Analytics, Formo natively tracks key touchpoints such as wallet connects, transactions, and smart contract events alongside standard web analytics like pageviews and sessions. It also handles [onchain attribution](/data/attribution) and [wallet intelligence](/features/wallet-intelligence/overview) for crypto and DeFi teams. Formo supports all major chains across the Ethereum and Solana ecosystems. See the full list of [supported chains](/chains/overview). Don't see your chain? Let us know. Formo has dedicated integrations for React, Next.js, Angular, and Wagmi-based apps. For any other framework or a static site, use the HTML snippet, which works everywhere. See [SDKs](/sdks/web) for framework-specific integration guides and examples. After installing the SDK, Formo automatically tracks the full user journey from offchain to onchain, including page views, sessions, referrer sources, UTM parameters, referral codes, wallet connects, disconnects, chain switches, transactions, and signatures. See [events](/data/events/overview) and [what we collect](/data/what-we-collect) for more information. Yes. Use the [Server-side SDK](/sdks/server) and [API](/api/overview) to send data from the backend. Events are ingested and queryable in real time. Some views cache their results for performance: shared public dashboards refresh every few minutes, and [Insights](/features/product-analytics/insights) regenerates once per day. # MCP API key Source: https://docs.formo.so/mcp/api-key Connect MCP clients to Formo with a Workspace API Key. ## Overview Use Workspace API Key authentication for MCP clients that let you configure custom HTTP headers, local editor integrations, or stdio bridges such as `mcp-remote`. > OAuth is available. See [MCP OAuth](/mcp/oauth) (Recommended). ## Prerequisites Requires an owner or admin role on the workspace. 1. Go to your [Formo workspace settings](https://app.formo.so) 2. Navigate to **Settings** → **API** 3. Click **Create API Key** 4. Enable the permissions you need. **Query API** read covers analytics and SQL; add **Alerts**, **Charts**, **Contracts**, or **Segments** to let it read or manage those. 5. Copy the generated key Use the API key as a Bearer token: ```bash theme={null} Authorization: Bearer ``` Workspace API keys are project-specific. The MCP server can only read analytics data for the project attached to the key. ## Setup Select your client below to configure Formo with API key authentication. 1. On Claude Desktop, go to Settings > Developer > Edit Config 2. This opens the Claude Desktop config file: * **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` * **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` 3. Add the following configuration: ```json theme={null} { "mcpServers": { "formo": { "command": "npx", "args": [ "mcp-remote", "https://api.formo.so/v0/mcp/", "--header", "Authorization: Bearer YOUR_API_KEY" ] } } } ``` 4. Restart Claude Desktop for the changes to take effect. 5. Upon successful restart, click the + icon on a new chat screen to enable Formo. Enable Formo 6. You can go to the Connectors settings to configure the Formo connector permissions to Always Allow. Formo Connector Settings 1. Press `Cmd+,` (macOS) or `Ctrl+,` (Windows/Linux) to open Settings. 2. Find the "MCP" section in the settings sidebar. 3. Click "Add new MCP server" and configure: * **Name:** `formo` * **Type:** `sse` * **URL:** `https://api.formo.so/v0/mcp/` * **Headers:** `Authorization: Bearer YOUR_API_KEY` Alternatively, add this to `.cursor/mcp.json` in your project: ```json theme={null} { "mcpServers": { "formo": { "url": "https://api.formo.so/v0/mcp/", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Run the following command: ```bash theme={null} claude mcp add --scope user --transport http formo https://api.formo.so/v0/mcp/ --header "Authorization: Bearer YOUR_API_KEY" ``` This registers the Formo MCP server globally for your user. To scope it to a specific project, use `--scope project` instead. 1. Open Windsurf Settings and navigate to the MCP section, or edit `~/.codeium/windsurf/mcp_config.json` directly. 2. Add the following configuration: ```json theme={null} { "mcpServers": { "formo": { "serverUrl": "https://api.formo.so/v0/mcp/", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` 3. Restart Windsurf for the changes to take effect. Add the following to `.vscode/mcp.json` in your project: ```json theme={null} { "servers": { "formo": { "type": "http", "url": "https://api.formo.so/v0/mcp/", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Add the following to `~/.codex/config.toml`: ```toml theme={null} [mcp_servers.formo] url = "https://api.formo.so/v0/mcp/" http_headers = { "Authorization" = "Bearer YOUR_API_KEY" } ``` For clients that only support stdio-based MCP servers, use the `mcp-remote` bridge to connect to Formo's HTTP-based server. 1. Ensure Node.js is installed: `node --version` 2. Run the following command: ```bash theme={null} npx mcp-remote https://api.formo.so/v0/mcp/ --header "Authorization: Bearer YOUR_API_KEY" ``` You can use this with any MCP client that supports stdio transports: ```json theme={null} { "mcpServers": { "formo": { "command": "npx", "args": [ "mcp-remote", "https://api.formo.so/v0/mcp/", "--header", "Authorization: Bearer YOUR_API_KEY" ] } } } ``` Most MCP clients support HTTP transport with custom headers. Configure your client with: * **Server URL:** `https://api.formo.so/v0/mcp/` * **Header:** `Authorization: Bearer YOUR_API_KEY` If your client only supports stdio transport, use the `mcp-remote` bridge. Try questions you can ask once Formo is connected. ## Troubleshooting * **`403 MCP access requires a Scale or Enterprise plan`:** MCP is available only on Scale and Enterprise plans. A valid API key is not sufficient on a Growth workspace. [Upgrade your workspace](https://app.formo.so) to connect MCP. * **Tools not appearing:** Check that the URL ends with a trailing slash: `https://api.formo.so/v0/mcp/`. Restart your AI assistant after configuration changes. * **`insufficient scope` on a tool call:** the key is missing that tool's permission. Analytics and SQL tools need **Query API** read; managing alerts, charts, contracts, or segments needs the matching permission. There is no separate MCP scope to enable. * **Authentication errors:** Ensure the API key is sent as `Authorization: Bearer YOUR_API_KEY`. Check that the API key has not been revoked. * **Wrong project data:** Workspace API keys are project-specific. Create or use a key attached to the project you want the MCP client to query. * **Claude Desktop not connecting:** Ensure Node.js is installed and accessible from the command line. Check Claude Desktop logs: `~/Library/Logs/Claude/` on macOS. # MCP OAuth Source: https://docs.formo.so/mcp/oauth Connect OAuth-capable MCP clients to Formo. ## Overview Formo supports OAuth for remote MCP clients that can discover and complete an OAuth authorization flow. Formo OAuth consent page for MCP clients ## Setup 1. Open ChatGPT settings 2. Go to **Apps** 3. Click **Advanced settings** 4. Enable **Developer mode** Enable Developer mode in ChatGPT app settings 5. Go back to Apps 6. Click **Create app** 7. Enter the app details: **Name** ```text theme={null} Formo ``` **MCP server URL** ```text theme={null} https://api.formo.so/v0/mcp/ ``` * **Description:** optional * **Authentication:** OAuth Create a Formo MCP app in ChatGPT 8. Click **Create** 9. Sign in to Formo, select a project, and approve access 10. Start asking ChatGPT questions about your Formo analytics ChatGPT Developer mode is a beta feature. ChatGPT apps created in Developer mode also cannot use memory. Formo can be configured as a data-only MCP app; no extra app UI is required to query analytics. 1. Open **Customize** → **Connectors** 2. Add a custom connector 3. Enter the connector details: **Name** ```text theme={null} Formo ``` **Server URL** ```text theme={null} https://api.formo.so/v0/mcp/ ``` Add Formo as a custom connector in Claude.ai 4. Click **Add** 5. On the Formo confirmation page, select the project Claude.ai should access 6. Click **Allow access** 7. Return to Claude.ai and confirm Formo appears with the available tools Formo connector configuration in Claude.ai Claude.ai stores and refreshes the OAuth connection for the signed-in user. In managed workspaces, an owner or admin may need to add the remote MCP server before members can connect it individually. 1. Open your MCP client's connector or app settings 2. Add a remote MCP server 3. Enter the Formo MCP server URL: ```text theme={null} https://api.formo.so/v0/mcp/ ``` 4. Choose OAuth if the client asks for an authentication method 5. Save or connect the server 6. Sign in to Formo, select a project, and approve access If the client asks for scopes, request the permissions you need (for example `query:read`) plus `offline_access` if it needs to refresh. ## Scopes Formo MCP OAuth supports the following scopes: | Scope | Description | | :----------------------------------- | :------------------------------------------------------------------------------- | | `profiles:read` / `profiles:write` | Read or manage wallet profiles | | `query:read` | Analytics and SQL tools for the selected project | | `alerts:read` / `alerts:write` | Read or manage alerts | | `boards:read` / `boards:write` | Read or manage charts and boards | | `contracts:read` / `contracts:write` | Read or manage tracked contracts | | `segments:read` / `segments:write` | Read or manage segments | | `offline_access` | Allows the MCP client to refresh access without asking the user to sign in again | > OAuth access is bound to the project selected on the Formo consent page. The MCP server validates that the user still has access to that project before serving requests. ## Troubleshooting * **`403 MCP access requires a Scale or Enterprise plan`:** The signed-in workspace is on a Growth plan. MCP authorization is gated server-side, so the OAuth flow will not complete. [Upgrade the workspace](https://app.formo.so) to a Scale or Enterprise plan, then reconnect. * **Client does not start OAuth:** Confirm the MCP server URL is exactly `https://api.formo.so/v0/mcp/` and includes the trailing slash. * **Invalid scope:** Request supported scopes only (see the table above), plus `offline_access` when refresh is needed. * **Consent page asks you to sign in again:** Sign in to Formo and you will be returned to the same OAuth authorization flow. * **No projects are available:** The signed-in Formo account must be an owner or admin of a team with at least one project. * **Project access denied after connecting:** Reconnect and choose a project that your Formo user can still access. * **API key stopped working:** OAuth does not replace API keys. Check that the key has the permission the failing tool needs (analytics and SQL need `query:read`), has not been revoked, and is sent as `Authorization: Bearer `. ## OAuth discovery Most OAuth-capable MCP clients discover Formo's OAuth configuration automatically from the MCP server URL: ```text theme={null} https://api.formo.so/v0/mcp/ ``` Unauthenticated requests return a `401` with a `WWW-Authenticate` challenge that points clients to Formo's protected resource metadata. Access tokens expire after 1 hour; clients that request `offline_access` can refresh access without asking the user to sign in again. | Metadata | URL | | :---------------------------- | :------------------------------------------------------------------------- | | Protected resource metadata | `https://api.formo.so/.well-known/oauth-protected-resource/v0/mcp` | | Authorization server metadata | `https://api.formo.so/v0/mcp/oauth/.well-known/oauth-authorization-server` | | OpenID configuration alias | `https://api.formo.so/v0/mcp/oauth/.well-known/openid-configuration` | The `openid-configuration` path is an alias that returns the same OAuth authorization-server metadata for clients that look there. Formo MCP is OAuth 2.0 only and does not implement full OpenID Connect. # MCP overview Source: https://docs.formo.so/mcp/overview Connect AI agents, coding tools, and third-party apps to Formo with MCP. ## Overview The Formo MCP Server lets AI assistants, code editors, and custom integrations query your analytics data in natural language, using [MCP](https://modelcontextprotocol.io/), an open standard for connecting AI assistants to external data. For example: * "How many daily active users did we have last week?" * "What are the top acquisition sources this month?" * "Show me user retention by cohort" MCP requires a **Scale or Enterprise** plan. [Upgrade your workspace](https://app.formo.so) to get access. ## Getting started **Supported clients:** OAuth-capable MCP clients such as ChatGPT, Claude Desktop, Cursor, Claude Code, Windsurf, VS Code, or any client that supports HTTP or stdio MCP transports. Formo supports two authentication methods for MCP: * **OAuth (Recommended)** for clients that manage user sign-in. Users sign in to Formo, select a project on the consent page, and choose which permissions to approve. * **Workspace API Keys** for clients that let you configure an `Authorization` header manually. Connect OAuth-capable MCP clients to Formo. Configure local clients, editors, and tools with an API Key. ## Example questions **Product analytics** * "What are my KPIs for the last 7 days?" * "Show me daily active users for the past month" * "Which lifecycle stage are most of my users in?" * "What's the conversion rate from page view to wallet connection?" **Marketing attribution** * "What are my top traffic sources?" * "Show me conversion by UTM source" * "Which countries are my users coming from?" **Wallet intelligence** * "Look up the profile for this wallet address, including its net worth" * "What are my top wallets and chains?" ## Available tools ### Query API SQL, exploration, and pre-built analytics endpoints. Permission: `query:read`. | Tool | Description | | :------------------- | :------------------------------------------------------------------------------- | | `explore_data` | Natural language data exploration with Formo-specific context | | `execute_query` | Run SQL queries against your analytics data | | `text_to_sql` | Convert natural language to SQL | | `list_endpoints` | List available API endpoints | | `list_datasources` | List available data sources | | `kpis` | Get key performance indicators | | `distinct_values` | Get unique values for filtering | | `top_events` | View most common events | | `event_timeseries` | Event counts over time | | `lifecycle` | Lifecycle stage breakdown (new, returning, power, resurrected, at risk, churned) | | `retention` | User retention by cohort | | `frequency` | How often users return | | `project_users` | Row-level user list, filterable by lifecycle stage, token holdings, and more | | `top_pages` | Analyze page performance | | `top_sources` | Attribution and traffic sources | | `top_locations` | Geographic distribution | | `top_wallets` | Most active wallets | | `top_chains` | Activity by blockchain | | `revenue_overview` | Revenue analytics | | `revenue_timeseries` | Revenue over time | | `revenue_by_metric` | Revenue broken down by a dimension | | `volume_by_metric` | Transaction volume broken down by a dimension | | `cohort_analysis` | Cohort-based behavioral analysis | | `funnel` | Multi-step conversion funnels | | `flow` | User journey and path analysis | ### Profiles API Wallet enrichment and profile writes. | Tool | Description | Permission | | :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------- | :--------------- | | `search_profile` | Look up enrichment for a single wallet address (net worth, tokens, labels) | `profiles:read` | | `update_profile_properties` | Set or unset profile properties on a wallet (address or ENS name); a `null` value deletes the property | `profiles:write` | | `upsert_profile_labels` | Add or update labels on a wallet | `profiles:write` | | `delete_profile_label` | Delete a label from a wallet | `profiles:write` | | `batch_update_profile_properties` | Set or unset profile properties on up to 100 wallets in one call; `null` values delete | `profiles:write` | | `batch_upsert_profile_labels` | Add, update, or delete labels on up to 100 wallets in one call (`_is_deleted: 1` rows tombstone) | `profiles:write` | | `import_wallets` | Import wallets as identified users (Scale and Enterprise plans; imported wallets count toward monthly active user billing) | `profiles:write` | ### Alerts | Tool | Description | Permission | | :------------------------------------------------------------- | :------------ | :------------- | | `list_alerts`, `get_alert` | Read alerts | `alerts:read` | | `create_alert`, `update_alert`, `toggle_alert`, `delete_alert` | Manage alerts | `alerts:write` | ### Charts | Tool | Description | Permission | | :------------------------------------------------------------------------------------------------ | :------------------------------------------------- | :------------- | | `list_boards`, `get_board`, `list_charts`, `get_chart` | Read boards and charts | `boards:read` | | `execute_saved_chart` | Run a saved chart's query with a chosen date range | `boards:read` | | `create_board`, `update_board`, `delete_board` | Manage boards | `boards:write` | | `create_chart`, `update_chart`, `delete_chart`, `duplicate_chart`, `move_chart`, `reorder_charts` | Manage charts | `boards:write` | | `preview_chart` | Run a chart's SQL without saving | `query:read` | ### Contracts | Tool | Description | Permission | | :------------------------------------------------------ | :------------------------------------------------------------------------- | :---------------- | | `list_contracts`, `get_contract` | Read tracked contracts | `contracts:read` | | `create_contract`, `update_contract`, `delete_contract` | Manage tracked contracts (pipeline inclusion is set via `update_contract`) | `contracts:write` | ### Segments | Tool | Description | Permission | | :--------------------------------- | :-------------- | :--------------- | | `list_segments` | Read segments | `segments:read` | | `create_segment`, `delete_segment` | Manage segments | `segments:write` | The `delete_*` tools permanently remove data. They will not run unless the call passes `"confirm": true`, so an assistant has to ask you first rather than delete on its own. Grant write permissions only to clients you trust. ### Docs | Tool | Description | | :--------------------------------- | :------------------------------ | | `search_formo_docs` | Search the Formo documentation | | `query_docs_filesystem_formo_docs` | Query the Formo docs filesystem | ## Troubleshooting * **`403 MCP access requires a Scale or Enterprise plan`:** MCP is available only on Scale and Enterprise plans. * **Tools not appearing:** Check that the URL ends with a trailing slash: `https://api.formo.so/v0/mcp/`. Restart or refresh your AI assistant after configuration changes. * **OAuth issues:** See [MCP OAuth](/mcp/oauth) for OAuth setup, metadata, and troubleshooting. * **API key issues:** See [MCP API Key](/mcp/api-key) for API key setup and troubleshooting. ### Security * **Project-scoped:** each connection is limited to one project, either selected on the OAuth consent page or attached to the API key. * **Permission-scoped:** every tool call requires the matching permission (e.g. `query:read` to read analytics, `alerts:write` to change alerts); deletes additionally require `"confirm": true`. * **Owner/admin only:** only a workspace owner or admin can create a connection. * **Nothing retained:** the MCP server itself stores no data. * **Manage anytime:** review, change, or revoke access under **Settings** → **Connected apps**. Disconnects apply immediately; permission changes apply on reconnect. Sessions expire after 1 hour of inactivity. ## FAQ Any OAuth-capable or HTTP/stdio MCP client. See [Getting started](#getting-started) for the supported client list and setup instructions. Anything in the [Available tools](#available-tools) table: KPIs, traffic sources, pages, events, geography, revenue, lifecycle, retention, cohorts, funnels, and wallet enrichment. Yes, access is scoped to one project and the permissions you grant. See [Security](#security) for details. Yes. OAuth is additive. Existing Workspace API Key configurations continue to work with `Authorization: Bearer YOUR_API_KEY`. # Mobile Source: https://docs.formo.so/sdks/mobile Track user events in your mobile apps with the Formo React Native SDK. Measure what matters onchain with full device context and wallet event tracking. The Formo React Native SDK is designed for mobile apps and implements the standard [Events API](/data/events/overview#events-api) with rich mobile context including device information, network status, and app metadata. ## Installation Install the SDK and its required peer dependency: ```bash theme={null} npm install @formo/analytics-react-native @react-native-async-storage/async-storage ``` For automatic device info detection (version, build number, device model), install one of: ```bash theme={null} # Expo projects (recommended) npx expo install expo-application expo-device # Bare React Native projects npm install react-native-device-info ``` If neither is installed, provide app metadata via the `app` option instead. For **Android install attribution** (knowing which site or campaign a user came from before installing), also install: ```bash theme={null} npm install react-native-play-install-referrer ``` This one requires a native rebuild of your Android app. Without it the SDK still works; install attribution is simply skipped, and a warning is logged on startup. See [Web-to-mobile attribution](#web-to-mobile-attribution). ### iOS Setup (bare React Native only) If you're using bare React Native (not Expo), run pod install after adding native dependencies: ```bash theme={null} cd ios && pod install ``` Expo projects handle native linking automatically, so no `pod install` is needed. ## Quick Start Wrap your app with the `FormoAnalyticsProvider`: ```tsx theme={null} import AsyncStorage from '@react-native-async-storage/async-storage'; import { FormoAnalyticsProvider } from '@formo/analytics-react-native'; function App() { return ( ); } ``` For apps using [Wagmi](https://wagmi.sh), enable native integration for automatic wallet event tracking: ```tsx theme={null} import AsyncStorage from '@react-native-async-storage/async-storage'; import { WagmiProvider, createConfig } from 'wagmi'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FormoAnalyticsProvider } from '@formo/analytics-react-native'; import { mainnet } from 'wagmi/chains'; const wagmiConfig = createConfig({ chains: [mainnet], // Required for React Native - MIPD uses browser APIs (window.addEventListener) // that don't exist in the React Native environment multiInjectedProviderDiscovery: false, // ... your connectors and transports }); const queryClient = new QueryClient(); function App() { return ( ); } ``` ## Code examples examples/react-native ## Track screen views Use the [`screen()`](/data/events/page) method to track screen views, the mobile equivalent of page views: ```tsx theme={null} import { useFormo } from '@formo/analytics-react-native'; import { useEffect } from 'react'; function WalletScreen() { const formo = useFormo(); useEffect(() => { formo.screen('Wallet', 'Main'); }, [formo]); return ...; } ``` The `screen()` method signature is: ```tsx theme={null} formo.screen(name: string, category?: string, properties?: object) ``` Include `formo` in the dependency array. The SDK initializes asynchronously, so including it ensures the screen event fires once initialization completes. Screen views are sent as `page` events so they flow through the same analytics as web page views. The screen name is recorded in a `page_url` that mirrors a web URL: ``` formo.screen('Wallet') → app://com.acme.wallet/Wallet formo.screen('/tabs/leaderboard') → app://com.acme.wallet/tabs/leaderboard ``` Your **bundle ID** takes the place of the hostname and the **screen name** takes the place of the path — structurally the same as `https://app.example.com/wallet`. Formo therefore reads them with the same URL parsing it uses for web: the bundle ID becomes the `origin` and the screen becomes the `page_path`, so screens appear alongside web pages in reports like Top Pages. Requires SDK **1.0.1 or later**. Earlier versions sent `app://Wallet`, where the screen name occupies the hostname slot and there is no path at all — URL parsers return an empty path for that shape, so those screen views do not appear in Top Pages. Upgrading is the fix. A few consequences worth knowing: * **Screen names containing `?` or `#` are percent-encoded**, so a name like `Checkout?coupon=X` cannot truncate the path. `/` is left alone, since router-style names are meant to be path segments. * **The bundle ID is the grouping key**, not your app's display name. Renaming your app therefore does not split its history. * Set `app.bundleId` explicitly (see [App metadata](#app-metadata)) if you run in **Expo Go** or on **React Native Web**. In Expo Go the native modules report Expo Go's own bundle ID, and on the web nothing resolves one at all. ### React Navigation Automatically track all screen transitions: ```tsx theme={null} import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'; import { useFormo } from '@formo/analytics-react-native'; import { useRef } from 'react'; function App() { const analytics = useFormo(); const navigationRef = useNavigationContainerRef(); const routeNameRef = useRef(); return ( { routeNameRef.current = navigationRef.getCurrentRoute()?.name; }} onStateChange={() => { const previousRouteName = routeNameRef.current; const currentRouteName = navigationRef.getCurrentRoute()?.name; if (previousRouteName !== currentRouteName && currentRouteName) { analytics.screen(currentRouteName); } routeNameRef.current = currentRouteName; }} > {/* Your navigation stack */} ); } ``` ## Mobile lifecycle events The SDK automatically tracks application lifecycle events following the Segment/RudderStack specification: | Event | When | Properties | | :------------------------- | :--------------------------------------------------- | :------------------------------------------------------------ | | `Application Installed` | First app launch (no stored version) | `version`, `build`, plus any captured attribution | | `Application Updated` | App version or build changed since last launch | `version`, `build`, `previous_version`, `previous_build` | | `Application Opened` | Every cold start and return from background | `version`, `build`, `from_background`, `url` (if deep linked) | | `Application Backgrounded` | App transitions to background | `version`, `build` | | `Deep Link Opened` | App launched or resumed via a deep or universal link | `url` | Lifecycle events are enabled by default and require `asyncStorage` to be provided for accurate install/update detection. To disable: ```tsx theme={null} options={{ autocapture: { lifecycle: false, }, }} ``` ### Opt-in lifecycle events Two more are available but **off by default**, because enabling either changes how your app behaves: | Event | When | Enable with | | :------------------------- | :------------------------- | :------------------------------------ | | `Application Foregrounded` | Background → active | `autocapture: { foregrounded: true }` | | `Application Crashed` | Unhandled JavaScript error | `autocapture: { crashes: true }` | `Application Foregrounded` marks the same transition as `Application Opened` with `from_background: true`, so enabling it doubles your foreground event volume without adding information. It exists for consumers that key on the Segment spec name directly — for example dashboards being migrated from Segment or RudderStack. `Application Crashed` installs a global JavaScript error handler. Your previous handler always runs afterwards, so React Native's redbox and any crash reporter you already use keep working. It reports `message`, `name`, `stack` and `fatal`. `autocapture: true` does **not** enable these two — they must be named individually. Crash tracking is **native only**: on React Native Web an uncaught error goes to `window.onerror` and never reaches the handler the SDK installs. ### Push notification events Push delivery is invisible to JavaScript without a native module, so these are not autocaptured. Call them from your own push handler: ```tsx theme={null} const formo = useFormo(); // e.g. inside @react-native-firebase/messaging or expo-notifications callbacks await formo.pushNotificationReceived({ campaign_id: 'spring', message_id: id }); await formo.pushNotificationTapped({ campaign_id: 'spring', message_id: id }); await formo.pushNotificationBounced({ campaign_id: 'spring', message_id: id }); ``` They emit `Push Notification Received`, `Push Notification Tapped` and `Push Notification Bounced` respectively. Any properties are accepted; the Segment spec suggests `campaign_id`, `campaign_name`, `message_id`, `action`, `title` and `body`. The SDK detects app version and build from `expo-application`, `react-native-device-info`, or the `app` option (in that order). If none are available, version and build will be empty strings. ## Identify users Call [`identify()`](/data/events/identify) after a user connects their wallet: ```tsx theme={null} const formo = useFormo(); formo.identify({ address: '0x1234...abcd', userId: 'optional-user-id', providerName: 'MetaMask', }); ``` When using Wagmi integration, wallet connections are automatically tracked. You only need to call `identify()` manually if you want to associate additional user data or use a custom user ID. ## Track custom events Track custom events with the [`track()`](/data/events/track) method: ```tsx theme={null} const formo = useFormo(); // Basic custom event formo.track('Swap Completed', { from_token: 'ETH', to_token: 'USDC', amount: '1.5', }); // With reserved properties for analytics formo.track('NFT Minted', { collection: 'founders-pass', tokenId: '001', revenue: 99.99, // Reserved: revenue tracking }); formo.track('Quest Completed', { questId: 'first_swap', points: 500, // Reserved: points tracking }); formo.track('Swap Executed', { fromToken: 'ETH', toToken: 'USDC', volume: 1.5, // Reserved: volume tracking }); ``` ## Deep link attribution Parse UTM parameters and referral codes from deep links: ```tsx theme={null} import { Linking } from 'react-native'; import { useFormo } from '@formo/analytics-react-native'; import { useEffect } from 'react'; function App() { const formo = useFormo(); useEffect(() => { // Handle initial deep link (app opened via link) Linking.getInitialURL().then((url) => { if (url) formo.setTrafficSourceFromUrl(url); }); // Handle deep links while app is open const subscription = Linking.addEventListener('url', (event) => { formo.setTrafficSourceFromUrl(event.url); }); return () => subscription.remove(); }, [formo]); return ; } ``` The SDK automatically extracts and stores: * UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) * Referral codes (`ref`, `referral`, `refcode`, `referrer_code`) Example deep link: `myapp://home?utm_source=twitter&utm_campaign=launch&ref=friend123` ## Web-to-mobile attribution Deep link attribution tells you where a user came from once the app is *already installed*. Web-to-mobile attribution answers the earlier question: **which site or campaign led someone to install the app in the first place.** ### Android Google Play passes a referrer through the install, so this is captured reliably. Point your marketing links at your Play Store listing with a `referrer` parameter: ``` https://play.google.com/store/apps/details?id=&referrer=utm_source%3Dexample.com%26utm_campaign%3Dspring ``` On first launch the SDK reads that value and attaches it to the [`Application Installed`](#mobile-lifecycle-events) event, and to the traffic source used by subsequent events: ```json theme={null} { "event": "Application Installed", "properties": { "version": "1.1.0", "build": "42", "utm_source": "example.com", "utm_campaign": "spring" } } ``` Requires `react-native-play-install-referrer` and a native rebuild; see [Installation](#optional-dependencies). The lookup runs once, on the first launch after install. ### iOS Not supported. Apple does not expose an install-referrer API, so an install cannot be attributed to a referring website from within the SDK. This requires a third-party attribution service such as Branch or AppsFlyer. On iOS this capture is a no-op; deep link attribution still works normally. ## Configuration ### Provider props | Prop | Type | Required | Description | | :------------- | :----------- | :------- | :-------------------------------------------------------- | | `writeKey` | String | Yes | Your Formo project write key. | | `asyncStorage` | AsyncStorage | Yes | AsyncStorage instance for persistent storage. | | `options` | Object | No | Configuration options, including attribution (see below). | | `disabled` | Boolean | No | Disable the SDK entirely. | | `onReady` | Function | No | Callback when SDK is initialized. | | `onError` | Function | No | Callback when initialization fails. | ### Example ```tsx theme={null} ``` ### App metadata The SDK automatically detects app information from your app's native configuration: * `app_name` - from your app's display name * `app_version` - from your app's version (e.g., `2.1.0`) * `app_build` - from your app's build number (e.g., `42`) * `app_bundle_id` - from your bundle/package identifier To override the auto-detected values, provide custom app information: ```tsx theme={null} options={{ app: { name: 'MyDeFiApp', // Override detected app name version: '2.1.0', // Override detected version build: '42', // Override detected build bundleId: 'com.example.mydefiapp', }, }} ``` ### Tracking control Control tracking behavior for different environments or chains: ```tsx theme={null} // Disable tracking entirely options={{ tracking: false, }} // Exclude specific chains (e.g., testnets) options={{ tracking: { excludeChains: [5, 11155111], // Goerli, Sepolia }, }} ``` ### Autocapture Control which wallet events are automatically captured: ```tsx theme={null} // Enable all autocapture (default) options={{ autocapture: true, }} // Disable specific events options={{ autocapture: { connect: true, disconnect: true, signature: false, // Disable signature tracking transaction: false, // Disable transaction tracking chain: true, lifecycle: true, // Application lifecycle events deepLinks: true, // Deep Link Opened }, }} // Disable all autocapture options={{ autocapture: false, }} ``` Two options are **off** unless named explicitly, because enabling either changes how the app behaves. `autocapture: true` does not turn them on: ```tsx theme={null} options={{ autocapture: { foregrounded: true, // Application Foregrounded (doubles foreground volume) crashes: true, // Application Crashed (installs a global error handler) }, }} ``` | Option | Default | Controls | | :----------------------------------------------------------- | :---------- | :-------------------------------------------------------------- | | `connect`, `disconnect`, `signature`, `transaction`, `chain` | `true` | Wallet events | | `lifecycle` | `true` | `Application Installed` / `Updated` / `Opened` / `Backgrounded` | | `deepLinks` | `true` | `Deep Link Opened` | | `foregrounded` | **`false`** | `Application Foregrounded` | | `crashes` | **`false`** | `Application Crashed` | `autocapture.deepLinks` controls only the **event**. Parsing a deep link's UTM parameters into event context is separate, under [`attribution.deeplinks`](#attribution). Turning either off leaves the other working. ### Attribution Control [deep link attribution](#deep-link-attribution) and [web-to-mobile attribution](#web-to-mobile-attribution) capture. Both sources are on by default and can be turned off individually: ```tsx theme={null} options={{ attribution: { deeplinks: true, // capture UTM/ref from deep links installReferrer: false, // skip the Play Install Referrer lookup }, }} ``` Passing `attribution: false` disables both. ### Logging Enable debug logging during development: ```tsx theme={null} options={{ logger: { enabled: __DEV__, levels: ['error', 'warn', 'info', 'debug'], }, }} ``` | Log Level | Description | | :-------- | :------------------------------------------- | | `error` | Error messages only. | | `warn` | Warning and error messages. | | `info` | Informative messages about normal operation. | | `debug` | Detailed diagnostic information. | | `log` | General log messages. | ### Ready callback Execute code when the SDK is fully initialized: ```tsx theme={null} { console.log('Formo SDK ready!'); // Auto-identify or perform other initialization }} onError={(error) => { console.error('Formo SDK failed to initialize:', error); }} > ``` ## Consent management Comply with privacy regulations using built-in consent management: ```tsx theme={null} const formo = useFormo(); // Check if user has opted out if (formo.hasOptedOutTracking()) { console.log('User has opted out'); } // Opt out of tracking (stops all tracking, clears queue) formo.optOutTracking(); // Opt back into tracking formo.optInTracking(); ``` When opting out, track the opt-out event *before* calling `optOutTracking()` so it gets recorded. When opting in, call `optInTracking()` first, then track the opt-in event. ## Wagmi integration When Wagmi integration is enabled, the SDK automatically tracks: | Event Type | Without QueryClient | With QueryClient | | :----------- | :------------------ | :--------------- | | Connect | Tracked | Tracked | | Disconnect | Tracked | Tracked | | Chain Change | Tracked | Tracked | | Signatures | Not tracked | Tracked | | Transactions | Not tracked | Tracked | Use the same `QueryClient` instance for both Wagmi and Formo to avoid creating multiple cache instances. ## Mobile context The SDK automatically enriches every event with mobile-specific context: | Field | Description | | :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `os_name` | Operating system (iOS, Android). | | `os_version` | OS version number. | | `device_model` | Device model (iPhone 14 Pro, Pixel 8). | | `device_manufacturer` | Device manufacturer (Apple, Google, Samsung). | | `device_type` | Device type (mobile, tablet). | | `user_agent` | Device user agent. Taken from the platform when available, otherwise synthesized from the device and OS above. Formo resolves `device` and `os` from it — and reports `browser` as `unknown`, since the synthesized string deliberately carries no browser token. | | `screen_width` | Screen width in logical points (not raw pixels — see `screen_density`). | | `screen_height` | Screen height in logical points. | | `screen_density` | Pixel density (devicePixelRatio). | | `locale` | Device language setting. | | `timezone` | Device timezone. | | `network_wifi` | Whether connected to WiFi. | | `network_cellular` | Whether connected to cellular. | | `network_carrier` | Mobile carrier name (when available). | | `app_name` | Your app name. | | `app_version` | Your app version. | | `app_build` | Your app build number. | ## Session management The SDK assigns a `session_id` to every event. A session groups one user's burst of activity into a single visit, and powers the Sessions, Session duration, and Bounce rate metrics on your dashboard. No configuration is required. | Behavior | Detail | | :-------------- | :------------------------------------------------------------------------- | | Session start | The first event after install, or the first event after a session expires. | | Session timeout | 30 minutes of inactivity. The next event after that starts a new session. | | Persistence | A session survives app restarts; it expires on inactivity, not on close. | | Reset | `reset()` ends the current session and starts a new one. | This differs from the web SDK, where `session_id` is derived server-side as a daily-changing value. That derivation relies on the request's origin, IP and browser user agent (signals a native app does not provide meaningfully), so the mobile SDK supplies its own session instead. Reset the current user session: ```tsx theme={null} const formo = useFormo(); // Clear current user identifiers (anonymous_id, session_id, user_id) formo.reset(); ``` ## Manual event flushing Force flush pending events (useful before app backgrounding): ```tsx theme={null} const formo = useFormo(); await formo.flush(); ``` The SDK automatically flushes events when the app goes to background. ## Verification To verify the SDK is working: 1. Enable debug logging in development 2. Trigger a screen view or custom event 3. Check the console for event logs 4. Verify events appear in the [Activity page](https://app.formo.so) on your Formo dashboard ## Peer dependencies | Package | Version | Required | | :------------------------------------------ | :------- | :------------------------------------------------- | | `react` | >=18.0.0 | Yes | | `react-native` | >=0.70.0 | Yes | | `@react-native-async-storage/async-storage` | >=1.17.0 | Yes | | `wagmi` | >=2.0.0 | No (for Wagmi integration) | | `@tanstack/react-query` | >=5.0.0 | No (for signature/transaction tracking) | | `expo-application` | >=5.0.0 | No (for auto version/build detection in Expo) | | `expo-device` | >=5.0.0 | No (for device info in Expo) | | `react-native-device-info` | >=10.0.0 | No (for auto version/build detection in bare RN) | | `react-native-play-install-referrer` | >=1.1.8 | No (for Android web-to-mobile install attribution) | # Server Source: https://docs.formo.so/sdks/server Send events and identify users from your backend with Formo server-side SDKs for Node.js, Python, and other languages. Stateless and wallet-native. The Formo server-side SDKs allow you to track actions and identify users from your backend applications. Server-side SDKs are stateless: they don't store or remember any values between calls. Every call must explicitly provide wallet address values for identification. The Server-Side SDK is [open source](https://github.com/getformo/sdk-node) and implements the standard [Events API](/data/events/overview#events-api). ## Installation Install the `@formo/analytics-node` package: ```bash theme={null} npm install @formo/analytics-node ``` ## Quick Start Initialize the SDK with your project's write key: ```typescript theme={null} import { FormoAnalytics } from "@formo/analytics-node"; const analytics = new FormoAnalytics(""); ``` ## Identify users Call [`identify()`](/data/events/identify) when a user signs in or connects their wallet to associate them with their actions. ```typescript theme={null} import { v4 as uuid } from "uuid"; await analytics.identify({ address: "0x9798d87366bdfc5d70b300abdffc4f9e95369b3d", // required: wallet address anonymousId: uuid(), // optional: auto-generated if not provided properties: { provider_name: "MetaMask", rdns: "io.metamask", }, }); ``` ## Track events To track custom events (actions, conversions, or backend states) use the [`track`](/data/events/track) function. ```typescript theme={null} await analytics.track({ address: "0x9798d87366bdfc5d70b300abdffc4f9e95369b3d", // optional: wallet address anonymousId: uuid(), // optional: auto-generated if not provided event: "Swap Completed", // required: event name properties: { pair: "ETH/USDC", token_in: "ETH", token_out: "USDC", amount_in: 1.5, amount_out: 4500, volume: 4500, revenue: 13.5, }, }); ``` You can [track volume, revenue, and points](/data/events/track#tracking-volume-revenue-points) in custom events. ## Configuration ### Options Customize the SDK behavior during initialization by passing an optional configuration object: | Option | Type | Default | Description | | :-------------- | :------- | :------------- | :---------------------------------------------------- | | `flushAt` | `number` | `20` events | Flush the queue once it contains N events. | | `flushInterval` | `number` | `30000` ms | Flush the queue every N milliseconds. | | `maxQueueSize` | `number` | `500000` bytes | Flush when the queue exceeds N bytes (approx. 500KB). | | `retryCount` | `number` | `3` retries | Number of times to retry failed requests. | ```typescript theme={null} const analytics = new FormoAnalytics("", { flushAt: 20, flushInterval: 30000, maxQueueSize: 500000, retryCount: 3, }); ``` ### Manual flushing To ensure all pending events are sent before a process exits (useful for serverless functions), call `flush()`: ```typescript theme={null} await analytics.flush(); ``` ## Data validation The SDK throws a `ValidationError` when required fields are missing or invalid: ```typescript theme={null} import { FormoAnalytics, ValidationError } from "@formo/analytics-node"; try { await analytics.track({ event: "", // empty event name will fail validation }); } catch (error) { if (error instanceof ValidationError) { console.error(`Validation failed: ${error.field} - ${error.reason}`); } } ``` ## Verification To verify that your integration is working correctly: 1. **Send a test event**: Trigger an action in your application that calls `track()` or `identify()`. 2. **Check the Dashboard**: Go to the **Activity** page in the Formo dashboard. 3. **Confirm Ingestion**: Your events should appear in the activity stream within a few seconds after a flush occurs. If you don't see events, ensure that you are calling `await analytics.flush()` if your script exits immediately, and check for any `ValidationError` in your server logs. ## Graceful shutdown The SDK automatically attempts to flush pending events before the process closes by listening to the following Node.js process events: * `beforeExit`: Flushes events when the process is about to exit naturally. * `SIGTERM`: Flushes events when the process receives a termination signal (common in Docker/Kubernetes). * `SIGINT`: Flushes events when the process is interrupted (e.g., `Ctrl+C`). ### Important caveats * **Manual `process.exit()`**: If your code calls `process.exit()` directly, the `beforeExit` event will not fire, and the queue will not be flushed. * **Serverless Environments**: In platforms like AWS Lambda or Vercel, the environment may be frozen immediately after your handler returns. **You must call `await analytics.flush()` manually** at the end of your function. * **Forceful Shutdowns**: A `SIGKILL` (`kill -9`) or sudden crash will prevent any automatic flushing. For the most reliable delivery in mission-critical applications or serverless functions, we recommend calling `await analytics.flush()` explicitly: ```typescript theme={null} process.on("SIGTERM", async () => { await analytics.flush(); process.exit(0); }); ``` # Web Source: https://docs.formo.so/sdks/web Install and configure the Formo Web SDK to track page views, wallet connects, transactions, and custom events in your website or web app. Formo The Formo Web SDK is [open source](https://github.com/getformo/sdk) and implements the standard [Events API](/data/events/overview#events-api). ## Installation Use this pre-built prompt to get started faster: [Install Formo with AI](/install#install-with-ai). There are several ways to install the Formo SDK: * [Wagmi](#wagmi) is recommended for EVM apps with wallet connection * [Solana](#solana-integration) for Solana apps, with or without framework-kit * [HTML Snippet](#html-snippet) is recommended for static websites * [React & Next.js (without Wagmi)](#react--nextjs-without-wagmi) * [Angular](#angular) for Angular apps using the bare EIP-1193 provider We recommend installing Formo on **both your website (example.com) and your app (app.example.com)** on the same project with the same SDK write key. The standard setup is to use the HTML snippet on your website and Wagmi on your app, with [cross-subdomain tracking](#cross-subdomain-tracking) enabled. ### Wagmi If you're already using [Wagmi](https://wagmi.sh), this is the recommended way to install Formo for apps. When `wagmi` options are provided, the SDK hooks directly into Wagmi's state management instead of wrapping EIP-1193 providers. This provides: * Native event handling via Wagmi's built-in state system * Better compatibility with wallet connection libraries (RainbowKit, ConnectKit, etc.) * Full tracking of signatures and transactions via TanStack Query's mutation cache Note that transaction and signature autocapture requires the use of [wagmi React hooks](#wagmi-integration). Install the Web SDK via NPM. ```bash theme={null} npm install @formo/analytics --save ``` ```tsx theme={null} // App.tsx import { WagmiProvider, createConfig, http } from 'wagmi'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FormoAnalyticsProvider } from '@formo/analytics'; import { mainnet } from 'wagmi/chains'; const wagmiConfig = createConfig({ chains: [mainnet], transports: { [mainnet.id]: http(), }, }); const queryClient = new QueryClient(); function App() { return ( ); } ``` ### HTML Snippet Install this snippet at the `` of your website: ```html theme={null} ``` Enable [Subresource Integrity (SRI)](/security/sri) to improve site security. ### React & Next.js (without Wagmi) Use [Wagmi](#wagmi) for more reliable and secure event tracking. Install the Web SDK via NPM. ```bash theme={null} npm install @formo/analytics --save ``` ```tsx theme={null} // App.tsx (or App.js) import { FormoAnalyticsProvider } from '@formo/analytics'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( ); ``` ```tsx theme={null} // Usage on a page component import { useFormo } from '@formo/analytics'; const HomePage = () => { const analytics = useFormo(); useEffect(() => { // Track a custom event analytics.track('Swap Completed', { points: 100 }); }, [analytics]); return
Welcome to the Home Page!
; }; export default HomePage; ``` ### Angular `FormoAnalyticsProvider` and `useFormo()` are React-only. Angular apps import from the React-free `@formo/analytics/core` subpath and wrap `FormoAnalytics.init()` in an injectable service, with wallets connected over the bare EIP-1193 provider (`window.ethereum`). Full working example: [with-angular](https://github.com/getformo/examples/tree/main/with-angular). Install the Web SDK along with the `buffer` polyfill. Angular's esbuild build doesn't auto-polyfill Node globals, but the SDK uses `Buffer` to decode signed-message payloads. Without it, signing throws `ReferenceError: Buffer is not defined`. ```bash theme={null} npm install @formo/analytics buffer viem --save ``` ```ts theme={null} // src/polyfills.ts import { Buffer } from 'buffer'; (globalThis as unknown as { Buffer?: typeof Buffer }).Buffer ??= Buffer; ``` ```jsonc theme={null} // angular.json (build > options) { "polyfills": ["src/polyfills.ts"], "allowedCommonJsDependencies": ["viem"] } ``` Wrap `FormoAnalytics.init()` in an injectable service. Import from `@formo/analytics/core`; the root entry pulls in the React provider, which Angular doesn't need: ```ts theme={null} // src/app/services/formo-analytics.service.ts import { Injectable } from '@angular/core'; import { FormoAnalytics } from '@formo/analytics/core'; import type { IFormoAnalytics, IFormoEventProperties } from '@formo/analytics/core'; @Injectable({ providedIn: 'root' }) export class FormoAnalyticsService { private analytics: IFormoAnalytics | null = null; async init(): Promise { if (typeof window === 'undefined') return; this.analytics = await FormoAnalytics.init('', { autocapture: { connect: true, disconnect: true, chain: true, signature: true, transaction: true }, }); } identify(address: string): void { void this.analytics?.identify({ address }); } track(event: string, properties?: IFormoEventProperties): void { void this.analytics?.track(event, properties); } } ``` Initialize it with `provideAppInitializer` so the SDK's autocapture wraps `window.ethereum` **before** any wallet interaction: ```ts theme={null} // src/app/app.config.ts import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { FormoAnalyticsService } from './services/formo-analytics.service'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideAppInitializer(() => inject(FormoAnalyticsService).init()), ], }; ``` ## Identify users Call [`identify()`](/data/events/identify) after a user connects their wallet or signs in on your website or app: ```ts theme={null} analytics.identify({ address }); OR window.formo.identify(...); ``` If no parameters are specified, the Formo SDK will attempt to auto-identify the wallet address. Call `identify()` in a `useEffect` that triggers when the wallet address changes: ```tsx theme={null} import { useFormo } from '@formo/analytics'; import { useAccount } from 'wagmi'; import { useEffect } from 'react'; function App() { const { address } = useAccount(); const analytics = useFormo(); useEffect(() => { if (address && analytics) { analytics.identify({ address }); } }, [address, analytics]); } ``` A Privy user is one account (a DID) with many linked wallets. Pass the `usePrivy()` user straight to `identify()` and the SDK identifies **every** linked wallet under that user's DID, so they cluster into a single user instead of one per address: ```tsx theme={null} import { useFormo } from '@formo/analytics'; import { usePrivy } from '@privy-io/react-auth'; import { useAccount } from 'wagmi'; import { useEffect } from 'react'; function App() { const { user } = usePrivy(); // null when the session isn't a Privy one const { address } = useAccount(); // plain wallet sessions const formo = useFormo(); useEffect(() => { if (!formo) return; // Check `user` first: a Privy session usually also has a wagmi // address, so testing the address first would lose the clustering. if (user) { formo.identify(user); } else if (address) { formo.identify({ address }); } }, [user, address, formo]); } ``` `user` is reactive, so this re-runs on login and on every link/unlink. See [Privy integration](#privy-integration) below for what each wallet sends and how mixed Privy/non-Privy apps identify. ```html theme={null} ``` Inject the analytics service and call `identify()` from the same place you discover the wallet address: ```ts theme={null} import { Injectable, inject, signal } from '@angular/core'; import { FormoAnalyticsService } from './services/formo-analytics.service'; import type { Address } from 'viem'; @Injectable({ providedIn: 'root' }) export class WalletService { private readonly formo = inject(FormoAnalyticsService); readonly address = signal
(null); async connect(): Promise { const [account] = await window.ethereum!.request({ method: 'eth_requestAccounts' }); this.address.set(account); this.formo.identify(account); } } ``` See the [with-angular example](https://github.com/getformo/examples/tree/main/with-angular) for a full working example. ## Track events > The Web SDK automatically captures common events such as page views and wallet events (connect, disconnect, signature, transaction, etc.) with full attribution (referrer, UTM, referrals). You do not need to manually track them. To track custom events (in-app user actions, key conversions) use the [`track`](/data/events/track) function with details of what happened: ```ts theme={null} import { useFormo } from '@formo/analytics'; const analytics = useFormo(); analytics.track("Position Opened", // custom event name { // custom event properties pool_id: "LINK/ETH", // custom event property amount: "1200", // custom event property volume: -59.99, // volume (reserved property, can be positive or negative to track outflows) revenue: 99.99, // revenue (reserved property, must be a non-negative number) } ) OR window.formo.track(...) ``` Using a [standardized naming convention](/data/events/track#naming-events) for your custom events is recommended. You can [track volume, revenue, and points](/data/events/track#tracking-volume-revenue-points) in custom events. ## Code examples examples/with-privy examples/with-dynamic examples/with-turnkey examples/with-react examples/with-next-app-router examples/with-next-page-router examples/with-metamask examples/with-thirdweb examples/with-reown examples/with-crossmint examples/with-openfort examples/with-angular examples/with-solana examples/with-react-native ## Configuration ### Local testing The SDK skips tracking on localhost by default. To enable tracking locally during development, set `tracking` to `true`: ```tsx theme={null} ``` ### Logging Control the level of logs the SDK prints to the console with the following logLevel settings: ```tsx theme={null} ``` Logging is disabled by default (`logger.enabled: false`); no logs print unless you explicitly set `logger.enabled: true`. The `levels` array is matched exactly, not hierarchically: setting `levels: ["warn"]` shows only `warn` messages, not `warn` and `error` together. List every level you want to see. | Log Level | Description | | --------- | -------------------------------------------------------------------------------------------------- | | trace | Shows the most detailed diagnostic information, useful for tracing program execution flow. | | debug | Shows all messages, including function context information for each public method the SDK invokes. | | info | Shows informative messages about normal application operation. | | warn | Shows warning messages. | | error | Shows error messages. | ### Autocapture You can configure which wallet events are automatically captured by the SDK: ```javascript theme={null} // Enable full autocapture (default) const analytics = await FormoAnalytics.init('YOUR_API_KEY', { autocapture: true }); // Disable autocapture for signature and transaction events const analytics = await FormoAnalytics.init('YOUR_API_KEY', { autocapture: { connect: true, disconnect: true, signature: false, // Disable signature tracking transaction: false, // Disable transaction tracking chain: true } }); // Disable all autocapture const analytics = await FormoAnalytics.init('YOUR_API_KEY', { autocapture: false }); ``` ### Batching To support high-performance environments, the SDK sends events in batches. ```tsx theme={null} ``` Customize this behavior with the `flushAt` and `flushInterval` configuration parameters. ### Ready callback The `ready` callback function executes once the Formo SDK is fully loaded and ready to use. This is useful for performing initialization tasks or calling SDK methods that require the SDK to be ready. ```tsx theme={null} ``` For Browser installations, you can use the ready callback in the `onload` attribute: ```html theme={null} ``` ### Environments You can control tracking behavior in different environments (test, staging) with the `tracking` option: ```javascript theme={null} // Initialize with a boolean (simple on/off) const analytics = await FormoAnalytics.init('your-write-key', { tracking: true // Enable tracking everywhere, including localhost }); // Initialize only on production environment const analytics = await FormoAnalytics.init('your-write-key', { tracking: ENV === 'production' }); // Initialize with an object for exclusion rules const analytics = await FormoAnalytics.init('your-write-key', { tracking: { excludeHosts: ['stage-v2.puri.fi'], // Exclude tracking based on window.location.hostname excludePaths: ['/test', '/debug'], // Exclude tracking based on window.location.pathname excludeChains: [5], // Exclude tracking on Goerli testnet (5) excludeTimezones: ['Asia/Bangkok', 'America/New_York'] // Exclude visitors whose browser timezone matches } }); ``` Specify exact hostnames and exact paths in exclusion lists. | Option | Type | Default | Description | | -------------------- | ----------- | ------- | --------------------------------------------------------------------------- | | `excludeHosts` | `string[]` | `[]` | Exact hostnames (`window.location.hostname`) to exclude from tracking. | | `excludePaths` | `string[]` | `[]` | Exact paths (`window.location.pathname`) to exclude from tracking. | | `excludeChains` | `ChainID[]` | `[]` | Chain IDs to exclude from tracking. | | `excludeTimezones` | `string[]` | `[]` | IANA timezone names (e.g. `Asia/Bangkok`) to opt out of tracking entirely. | | `excludeQueryParams` | `string[]` | `[]` | Query parameter names to strip from captured URLs before any event is sent. | #### Excluding by timezone When the visitor's browser-resolved timezone (via `Intl.DateTimeFormat().resolvedOptions().timeZone`) matches an entry in `excludeTimezones`, the SDK suppresses tracking entirely for that visitor. No events are enqueued or sent, including `identify` and `connect`, and no identity cookie is written. This is useful for opting whole regions out of analytics (for example, to honor a jurisdiction's privacy expectations). #### Excluding query parameters Formo automatically captures the page URL, query string, individual query-parameter properties, and the referrer. If your URLs carry sensitive data (auth tokens, one-time codes, emails), use `tracking.excludeQueryParams` to strip those parameters in the browser, before any event is sent so they are never transmitted or stored: ```tsx theme={null} {children} ``` These parameters are applied on top of an always-on built-in denylist that cannot be disabled. The following parameters are always stripped, regardless of configuration: * `privy_oauth_code`: Privy OAuth authorization code * `privy_oauth_state`: Privy OAuth CSRF state token * `privy_oauth_provider`: Privy OAuth provider identifier Excluded parameters are removed from the `url`, `query`, `referrer`, and other event properties. Matching is case-insensitive. ### Consent management The Formo Web SDK includes simplified consent management functionality to help you comply with privacy regulations like GDPR, CCPA, and ePrivacy Directive. Control user tracking preferences with simple opt-out and opt-in methods: ```javascript theme={null} import { useFormo } from '@formo/analytics'; const analytics = useFormo(); // Check if user has opted out of tracking console.log(analytics.hasOptedOutTracking()) // If user has not opted out, tracking is enabled analytics.track('page_view'); // Opt out of tracking (stops all tracking for the user) analytics.optOutTracking(); // Opt back into tracking (re-enables tracking for the user) analytics.optInTracking(); ``` For browser installations: ```javascript theme={null} // Manage consent window.formo.optOutTracking(); window.formo.optInTracking(); ``` ### Cross subdomain tracking By default, Formo sets identity cookies on the root domain, sharing visitor identity across all subdomains. This ensures accurate visitor counts and consistent attribution across your subdomains out of the box. | Value | Behavior | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `true` (default) | Cookies are set on the root domain (e.g. `.example.com`), sharing visitor identity across all subdomains. | | `false` | Cookies are scoped to the current hostname only. A cookie set on `app.example.com` is **not** visible from `www.example.com`. | With the default `crossSubdomainCookies` setting: * **Cross subdomain tracking**: track users as they move between your marketing site, app, docs, and other subdomains. * **Accurate attribution**: attribute conversions to the correct channel, even when users cross subdomains. * **Automatic migration**: the SDK migrates existing host-scoped cookies to the apex domain so visitors are not double-counted. To disable cross-subdomain tracking and scope cookies to the current hostname only, set `crossSubdomainCookies` to `false`: ```html theme={null} ``` ```tsx theme={null} ``` ```javascript theme={null} const analytics = await FormoAnalytics.init('YOUR_API_KEY', { crossSubdomainCookies: false }); ``` ### Referrer URL tracking Referrer URL tracking lets you understand the specific URL where your users are coming from, as long as the site has the correct `Referer-Policy` header set. We recommend setting the `no-referrer-when-downgrade` policy, which will only send the domain, path, and query parameters to the destination URL as long as the protocol security level stays the same (i.e. `HTTP` → `HTTP`, or `HTTPS` → `HTTPS`). If the protocol security level is downgraded (i.e. `HTTPS` → `HTTP`), the referrer will not be sent. This is a good compromise to make sure your users' privacy is respected while still getting a good amount of data. Here's how to configure this: If you are using Next.js, you can use the headers property in your `next.config.js` file to configure the referrer policy: ```javascript theme={null} module.exports = { async headers() { return [ { source: "/:path*", headers: [ { key: "Referrer-Policy", value: "no-referrer-when-downgrade", }, ], }, ]; }, }; ``` You can set the referrer policy using a meta tag in the `` section of your HTML: ```html theme={null} ``` ### Referrals Formo autodetects `ref`, `referral`, `refcode`, `af`, and `referrer` query parameters in the URL. You can customize how referrals are detected by the SDK: ```tsx theme={null} ``` In the above configuration, Formo will detect referrals from: * the `via` and `ref` query parameters in the URL, and * from the `/referral/([^/]+)` path pattern ### Wagmi integration For apps using [Wagmi](https://wagmi.sh), enable native integration by providing the Wagmi config and QueryClient: ```tsx theme={null} ``` Transaction and signature autocapture requires the use of [wagmi React hooks](https://wagmi.sh/react/api/hooks) (`useWriteContract`, `useSendTransaction`, `useSignMessage`). Calls via `@wagmi/core` or viem directly bypass the mutation cache and won't be tracked. If you are not using these hooks, use the non-wagmi React or Next.js installation methods instead. When Wagmi mode is enabled, the SDK: * Hooks directly into Wagmi's state management for connection events * Uses TanStack Query's mutation cache for signature and transaction tracking * Skips EIP-1193 provider wrapping (no proxy behavior) | Event Type | Without QueryClient | With QueryClient | | ------------ | ------------------- | ---------------- | | Connect | ✅ Tracked | ✅ Tracked | | Disconnect | ✅ Tracked | ✅ Tracked | | Chain Change | ✅ Tracked | ✅ Tracked | | Signatures | ❌ Not tracked | ✅ Tracked | | Transactions | ❌ Not tracked | ✅ Tracked | Use the same `QueryClient` instance for both Wagmi and Formo to avoid creating multiple cache instances. ### Solana integration There are two ways to integrate Solana, depending on which wallet library your app uses: * **[framework-kit](#solana-with-framework-kit)** (`@solana/client` + `@solana/react-hooks`): the SDK subscribes to framework-kit's zustand store and captures wallet events automatically. * **[Any other wallet library](#solana-without-framework-kit)** (`@solana/wallet-adapter`, Privy, Dynamic, Reown, or a custom connector): call `formo.connect()` and `formo.disconnect()` yourself. #### Solana with framework-kit The SDK subscribes to framework-kit's zustand store as a read-only observer. It never wraps or intercepts wallet methods. See the full [Solana example app](https://github.com/getformo/examples/tree/main/with-solana) for a working implementation. ```bash theme={null} npm install @formo/analytics @solana/client @solana/react-hooks ``` Wrap your app with `FormoAnalyticsProvider` and pass `client.store` in the solana options. The SDK automatically tracks wallet connects, disconnects, account switches, and network changes. ```tsx theme={null} import { createClient, autoDiscover } from '@solana/client'; import { SolanaProvider } from '@solana/react-hooks'; import { FormoAnalyticsProvider } from '@formo/analytics'; const client = createClient({ cluster: 'devnet', walletConnectors: autoDiscover(), }); function App() { return ( ); } ``` Then use the `useFormo` hook in any component: ```tsx theme={null} import { useFormo } from '@formo/analytics'; function MyComponent() { const formo = useFormo(); const handleClick = () => { formo.track('Swap Started', { pair: 'ETH/USDC' }); }; } ``` If you're not using React or need manual control, call `FormoAnalytics.init()` directly: ```tsx theme={null} import { createClient, autoDiscover } from '@solana/client'; import { FormoAnalytics } from '@formo/analytics'; const client = createClient({ cluster: 'devnet', walletConnectors: autoDiscover(), }); const formo = await FormoAnalytics.init('', { evm: false, solana: { store: client.store, }, }); ``` **Autocaptured events** The following Solana wallet events are autocaptured via zustand store subscription: | Event | Trigger | | ------------ | ----------------------------------------------------- | | Connect | Wallet connects via `useWalletConnection().connect()` | | Disconnect | Wallet disconnects | | Chain change | Cluster/network switches via `setCluster()` | #### Solana without framework-kit If your app uses `@solana/wallet-adapter`, Privy, Dynamic, Reown, or a custom connector, there is no store for the SDK to observe, so nothing is autocaptured. Call `formo.connect()` and `formo.disconnect()` at the points where your wallet state changes. Do not report wallet connections with `formo.track()`. A custom event such as `formo.track('Connect', { address })` is stored as a track event, not as the built-in `connect` type, so it will not reach anything that reads wallet connections: * the **Connect wallet** step in [funnels](/guides/funnels) and [flows](/guides/flows) returns zero * first-time and daily wallet connection charts stay empty * breakdowns by wallet provider are unavailable * sessions are not attributed to the wallet Use `formo.connect()` for wallet connections and reserve `formo.track()` for product events such as swaps, deposits, and mints. Initialize the SDK without the `solana.store` option: ```tsx theme={null} ``` Then emit the events. This example uses `@solana/wallet-adapter-react`, but the same shape applies to any library that exposes a connected address: ```tsx theme={null} import { useEffect, useRef } from 'react'; import { useWallet } from '@solana/wallet-adapter-react'; import { useFormo, SOLANA_CHAIN_IDS } from '@formo/analytics'; const CHAIN_ID = SOLANA_CHAIN_IDS['mainnet-beta']; function WalletTracking() { const formo = useFormo(); const { publicKey, wallet, connected } = useWallet(); const lastAddress = useRef(null); useEffect(() => { if (!formo) return; const address = publicKey?.toBase58() ?? null; if (connected && address && address !== lastAddress.current) { formo.connect( { chainId: CHAIN_ID, address }, { providerName: wallet?.adapter.name }, ); lastAddress.current = address; } if (!connected && lastAddress.current) { formo.disconnect({ chainId: CHAIN_ID, address: lastAddress.current }); lastAddress.current = null; } }, [formo, connected, publicKey, wallet]); return null; } ``` Render `` once inside your wallet provider. Emit `connect` whenever the wallet becomes connected, including when your app restores a session on page load. If you only call `formo.connect()` from the wallet modal's success handler, returning users are never counted and your first-time connection numbers will be too low. Driving the call from an effect on wallet state (as above) covers both cases. **Solana chain IDs** Solana has no numeric chain ID, so Formo maps each cluster to a reserved ID above 900000 to avoid colliding with EVM chains. Use the exported constant rather than hardcoding the number. | Cluster | `SOLANA_CHAIN_IDS` value | | -------------- | ------------------------ | | `mainnet-beta` | `900001` | | `testnet` | `900002` | | `devnet` | `900003` | | `localnet` | `900004` | Mainnet has two spellings: `@solana/client` takes `cluster: 'mainnet'`, while `SOLANA_CHAIN_IDS` is keyed on `'mainnet-beta'`. Map between them when you derive one from the other. `connect()` validates the address against the chain ID, so a base58 Solana address paired with an EVM chain ID is rejected. Rejected calls log a warning and emit nothing. Enable logging while integrating so you see them: ```tsx theme={null} options={{ logger: { enabled: true, levels: ['info', 'warn', 'error'] } }} ``` Connections are also skipped when tracking is suppressed for the visitor, for example by an opt-out or an excluded host, timezone, or path. **Manually tracking Solana transactions and signatures** Transaction and signature events for Solana wallets must be tracked explicitly on both integration paths. On framework-kit this is because its React hooks (`useSolTransfer`, `useSendTransaction`, `useTransactionPool`) manage transaction state locally and don't write to the store. ```tsx theme={null} import { useFormo, TransactionStatus, SignatureStatus, SOLANA_CHAIN_IDS } from '@formo/analytics'; const formo = useFormo(); // Transactions formo.transaction({ status: TransactionStatus.STARTED, chainId: SOLANA_CHAIN_IDS['devnet'], address }); const sig = await solTransfer.send({ destination, amount }); formo.transaction({ status: TransactionStatus.CONFIRMED, chainId: SOLANA_CHAIN_IDS['devnet'], address, transactionHash: sig }); // Signatures (signMessage / signTransaction) formo.signature({ status: SignatureStatus.REQUESTED, chainId: SOLANA_CHAIN_IDS['devnet'], address, message }); const result = await session.signMessage(encoded); formo.signature({ status: SignatureStatus.CONFIRMED, chainId: SOLANA_CHAIN_IDS['devnet'], address, message }); ``` For Solana-only apps, set `evm: false` to disable EVM provider detection (EIP-1193 / EIP-6963). This prevents unnecessary tracking of injected EVM wallets. ### Privy integration A Privy user is **one account (a DID) with many linked wallets** - an embedded wallet plus any external wallets they connect over time. Because Formo is address-keyed, one person with 8 wallets would otherwise appear as 8 users. `identify(user)` resolves this in a single call: it expands `user.linkedAccounts` and emits one identify per linked wallet, each tagged with the same Privy DID, so they cluster into one user. ```tsx theme={null} formo.identify(user); ``` **What each wallet sends.** The shared profile parsed from `user.linkedAccounts` - `privyDid`, `privyCreatedAt`, `email`, `phone`, and socials (X, Discord, GitHub, Farcaster, Google, …) - plus that wallet's own `wallet_client`, `chain_type`, and `is_embedded`. Linked wallets include `wallet` and `smart_wallet` accounts, plus a `cross_app` account's embedded and smart wallets (e.g. Abstract Global Wallet). Addresses are deduplicated. **Apps with both Privy and non-Privy users.** Branch on which identity you have, checking `user` first - a Privy session usually also has a wagmi `address`, so testing the address first would send that user down the plain path and lose the clustering: ```tsx theme={null} useEffect(() => { if (!formo) return; if (user) { formo.identify(user); // every linked wallet, under the DID } else if (address) { formo.identify({ address }); // plain wallet connect } }, [user, address, formo]); ``` `parsePrivyProperties(user)` returns the same parsed profile and wallet list without emitting, if you want to inspect or display what would be sent. ## Proxy To handle ad-blockers and privacy browsers, we recommend setting up a reverse proxy. Reverse proxy data flow Here are the domains used by Formo to load the SDK and send data: | Domain | Use case | | --------------- | -------------------------------------------------------------------------------------------------------------------- | | events.formo.so | Receives data from the SDK (`https://events.formo.so/v0/raw_events`) | | cdn.formo.so | Loads the HTML snippet (only for [Website installation](#html-snippet)) e.g. `https://cdn.formo.so/analytics@latest` | ### CDN Some ad blockers block specific scripts and API calls based on the domain name and URL. To address this issue you can download and serve the Formo HTML Snippet from `cdn.formo.so` to your own domain, e.g. `yourdomain.com/formo.js`. > This only applies to the HTML snippet. If you installed `@formo/analytics` via NPM, the SDK is bundled with your app and is not loaded from the CDN, so you can skip this step. ### Next.js rewrites If you are using Next.js, you can take advantage of [rewrites](https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites) to behave like a reverse proxy. To do so, add a `rewrites()` function to your next.config.js file: ```javascript theme={null} // next.config.js module.exports = { async rewrites() { return [ { source: "/formo.js", destination: "https://cdn.formo.so/analytics@latest", // Only for the HTML snippet, skip if you installed via NPM }, { source: "/api/ingest", destination: "https://events.formo.so/v0/raw_events", }, ]; }, }; ``` Then, configure the Formo SDK to send requests via your rewrite. Update the `apiHost` parameter in the Formo SDK: ```javascript theme={null} ``` > See an [example Next.js app](https://github.com/getformo/examples/tree/main/with-next-app-router) here. If you are using the HTML / website snippet, replace the install script with: ```javascript theme={null} ``` ### Next.js middleware If you are using Next.js and rewrites aren't working for you, you can write [custom middleware](https://nextjs.org/docs/14/pages/building-your-application/routing/middleware) to proxy requests to Formo. Create a file named `middleware.js/ts` in your base directory (same level as the app folder). ```javascript theme={null} import { NextResponse } from "next/server"; export function middleware(request: any) { const hostname = "events.formo.so"; const requestHeaders = new Headers(request.headers); requestHeaders.set("host", hostname); let url = request.nextUrl.clone(); url.protocol = "https"; url.hostname = hostname; url.port = 443; url.pathname = url.pathname.replace(/^\/ingest/, ""); return NextResponse.rewrite(url, { headers: requestHeaders, }); } export const config = { matcher: "/ingest/:path*", }; ``` In this file, set up code to match requests to a custom route, set a new host header, change the URL to point to Formo, and rewrite the response. Once done, configure the Formo SDK to send requests via your rewrite: ```javascript theme={null} ``` ### Others Alternatively, you can set up your own proxy (with CloudFront, Cloudflare, etc.) and pass the URL as the SDK `apiHost`: ```javascript theme={null} ``` ### Verification To verify the proxy is working: * Visit your website / app * Open the network tab in your browser's developer tools * Check that analytics requests are going through your domain instead of `events.formo.so` * Check that events show up in the Activity page on the Formo dashboard ## FAQ The [Wagmi integration](#wagmi) automatically tracks wallet connects, disconnects, chain switches, transactions, and signatures by hooking into Wagmi's wallet adapter. The standard [React integration](#react--nextjs-without-wagmi) tracks wallet events by wrapping the EIP-1193 wallet provider to track signatures and transactions. Use the Wagmi integration if your app uses Wagmi and its hooks. Use [formo.track()](/data/events/track) to send custom events with any properties you need. For example: `formo.track('Swap Completed', { pair: 'ETH/USDC', token_in: 'ETH', token_out: 'USDC', amount_in: 1.5 })`. Custom events appear in the [Activity](/features/product-analytics/activity) feed and can be queried in the [Explorer](/features/product-analytics/explore). Yes. The SDK provides built-in [consent management](#consent-management) with `optOutTracking()` and `optInTracking()` methods. Call `optOutTracking()` to stop all tracking for a user, and `optInTracking()` to re-enable it. Formo does not use third-party cookies, IP addresses, or device fingerprinting, so most jurisdictions do not require a cookie consent banner. Yes. The SDK includes a dedicated [Privy integration](#privy-integration): pass the `usePrivy()` user to `identify(user)` and every wallet linked to that Privy account is identified under the user's DID, so a multi-wallet user clusters into one Formo user. For other wallet providers, use the standard [React integration](#react--nextjs-without-wagmi). If your wallet provider uses Wagmi under the hood (many do, including Privy), you can also use the [Wagmi integration](#wagmi) for automatic wallet event tracking. Wallet events are autocaptured on Solana only when your app passes a [framework-kit](#solana-with-framework-kit) store to `options.solana.store`. With `@solana/wallet-adapter`, Privy, Dynamic, Reown, or a custom connector there is no wallet state for the SDK to observe, so nothing is captured until you emit it yourself. See [Solana without framework-kit](#solana-without-framework-kit). Sending connections as a custom `track()` event instead will not work: they are stored as track events rather than the built-in `connect` type, so the **Connect wallet** funnel step and every wallet connection chart stay empty. No. framework-kit gives you autocapture for connects, disconnects and cluster switches, but any Solana app can integrate by calling [`formo.connect()`](/data/events/connect) and `formo.disconnect()` directly. Both paths produce the same `connect` and `disconnect` event types, so the dashboard treats them identically. Transactions and signatures need explicit `formo.transaction()` and `formo.signature()` calls on either path. # Content Security Policy (CSP) Source: https://docs.formo.so/security/csp Configure Content Security Policy headers to protect your site from XSS, data injection, and man-in-the-middle attacks when using Formo. ### Overview As [described on MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP): Content Security Policy (CSP) is a feature that helps to prevent or minimize the risk of certain types of security threats. It consists of a series of instructions from a website to a browser, which instruct the browser to place restrictions on the things that the code comprising the site is allowed to do. The primary use case for CSP is to control which resources, in particular JavaScript resources, a document is allowed to load. This is mainly used as a defense against cross-site scripting (XSS) attacks, in which an attacker is able to inject malicious code into the victim's site. ### How CSP Prevents MITM Attacks CSP provides a critical layer of protection against man-in-the-middle (MITM) attacks by: 1. **Restricting script sources** - Only scripts from whitelisted domains can execute 2. **Blocking data exfiltration** - `connect-src` limits where data can be sent 3. **Preventing code injection** - Even if an attacker injects malicious code, CSP blocks it from communicating with unauthorized servers When combined with [SRI](/security/sri) and TLS 1.2+ encryption, CSP makes MITM attacks on the Formo SDK effectively impossible. If you choose to use a CSP, it is important to ensure that Formo domains are permitted. ### How to Enable CSP Below is an example of a relatively restrictive CSP that limits only scripts and API calls to all Formo domains. ```html theme={null} ``` Add the script above within the `` tag of your site to enable CSP. ### Domains used by Formo | Domain | Usage | | :---------------- | :----------------------------------- | | `events.formo.so` | Ingestion endpoint for SDK API calls | | `cdn.formo.so` | CDN for SDK assets | ### Combined Protection with SRI For maximum security, combine CSP with [SRI](/security/sri): ```html theme={null} ``` This configuration ensures: * Scripts can only load from your domain and Formo's CDN * API calls can only be made to your domain and Formo's ingestion endpoint * The SDK integrity is cryptographically verified before execution * All traffic is encrypted with TLS 1.2+ # Multi-Factor Authentication (MFA) Source: https://docs.formo.so/security/mfa Enable multi-factor authentication on your Formo account using time-based one-time passwords from an authenticator app for stronger sign-in security. Multi-Factor Authentication (MFA) adds a second verification step when signing in to Formo. After entering your email, you'll be prompted to enter a time-based one-time password (TOTP) from an authenticator app on your device. MFA is available to all Formo users and can be enabled in your account settings. ## How it works Formo uses **TOTP (Time-based One-Time Passwords)** for MFA. After signing in with your email, you'll be prompted to enter a 6-digit code from your authenticator app before accessing your dashboard. ## Supported authenticator apps Any TOTP-compatible authenticator app works with Formo, including: * **Google Authenticator** * **Authy** * **1Password** * **Microsoft Authenticator** ## Enabling MFA Navigate to **Account Settings** > **Security** in the Formo dashboard. Toggle the **Enable 2FA** switch. A setup dialog will appear with a QR code. Open your authenticator app and scan the QR code. If you can't scan the QR code, click to copy the secret key and enter it manually in your authenticator app. Save the secret key in a secure location. This is the only time it will be displayed and can be used to recover access if you lose your device. Enter the 6-digit code from your authenticator app to complete the setup. Once verified, MFA is active on your account. You'll be prompted for a TOTP code on every sign-in. ## Signing in with MFA 1. Enter your email on the Formo sign-in page 2. Complete email verification 3. When prompted, enter the 6-digit code from your authenticator app 4. The code is verified automatically once all 6 digits are entered ## Disabling MFA Navigate to **Account Settings** > **Security** in the Formo dashboard. Toggle off the **Enable 2FA** switch. A confirmation dialog will appear. Enter a valid 6-digit code from your authenticator app to confirm disabling MFA. Disabling MFA removes the additional security layer from your account. Only disable it if you have an alternative reason, such as switching to a new authenticator device. ## Account recovery If you lose access to your authenticator device, contact [support@formo.so](mailto:support@formo.so). MFA resets must go through Formo support: your workspace Owner or Admin cannot reset another user's MFA themselves (they can only see whether MFA is enabled or disabled for a member). Formo support will verify your identity and reset your MFA so you can re-enroll with a new device. # Security Best Practices Source: https://docs.formo.so/security/overview Learn how Formo protects your data with privacy-first design, encryption, RBAC, MFA, SSO, CSP, and Subresource Integrity security practices. ## Data Privacy Built with privacy in mind with no third-party cookies, fingerprinting, or invasive tracking. Our SDK explicitly does **not** collect: * IP addresses (country is resolved server-side at the network edge, and the raw IP never reaches our servers) * Device fingerprints (no Canvas, WebGL, or storage-based fingerprinting) * Social profiles (no Twitter, Discord, or email collection) * Third-party cookies Learn more about what data we collect in our [Data Collection](/data/what-we-collect) documentation. ## Transparency Formo SDKs are 100% open source with a fully permissive MIT license. ## Compliance Formo's SOC 2 compliance report will be available on request. ## Supply Chain Security Eliminates security risks from long-lived write tokens, which can be compromised. Formo supports SRI, which helps prevent attacks like cross-site scripting (XSS) and NPM hijacking. Formo supports CSP, which helps prevent attacks like cross-site scripting (XSS) and data injection. Having fewer runtime dependencies significantly reduces the supply chain attack surface. ### SDK Dependencies List The [@formo/analytics](https://github.com/getformo/sdk) SDK maintains a minimal dependency footprint to minimize attack surface: | Package | Purpose | Risk Level | | :---------------------------------------------------------------------------- | :------------------------------------------- | :---------------------------------------------------------------------------------------------------------------- | | [ethereum-cryptography](https://github.com/ethereum/js-ethereum-cryptography) | Cryptographic operations (Keccak256, SHA256) | Low - Audited pure JS library containing all Ethereum-related cryptographic primitives by the Ethereum Foundation | | [mipd](https://github.com/wevm/mipd) | EIP-6963 wallet discovery | Low - standard EIP-6963 implementation by the [wevm](https://wevm.dev/) team, authors of wagmi | | [fetch-retry](https://github.com/jonbern/fetch-retry) | HTTP retry with exponential backoff | Low - simple utility | All releases include cryptographic [provenance attestations](https://www.npmjs.com/package/@formo/analytics#provenance) linking each npm package to its GitHub source code. ## Secure Installation Methods There are two ways to install the Formo SDK, each with different security properties: | Method | MITM Protection | Supply Chain Verification | | :----------------------------------------------- | :-------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | | **NPM package** (`npm install @formo/analytics`) | Script is bundled into your own assets - no third-party script loading at runtime | npm integrity checks (SHA-512) + [provenance attestations](https://www.npmjs.com/package/@formo/analytics#provenance) verify the package came from GitHub | | **CDN script tag** | Enable [SRI](/security/sri) + [CSP](/security/csp) for cryptographic verification | SRI hash verifies the script before browser execution | **For security-sensitive deployments**, we recommend the NPM package install. When you install via `npm install @formo/analytics` and bundle the SDK into your own application, there is no third-party script loaded at runtime, eliminating the MITM vector entirely. The code is verified by npm's integrity checks during install and served from your own domain. For the CDN script tag method, enable [SRI](/security/sri) and [CSP](/security/csp) for equivalent protection. See the [complete secure installation](/security/csp) guide. ## Infrastructure Security All connections secured with industry-standard TLS 1.2+ encryption. All data volumes, including backups, are encrypted at rest with unique AES-256 keys. All customer databases are continuously backed up to highly durable storage. Formo runs on AWS, which has the highest levels of security and reliability. 24/7 on-call rotations with internal escalations monitor across all systems. ## Software Security Automated tests and code reviews run after each code change as part of QA. Engineering review for security best practices to address potential security threats. Formo conducts regular penetration tests in addition to internal security reviews. ## Partner Security Formo uses Paddle to process payments and does not store credit card information. Formo keeps the list of data subprocessors updated in the Terms of Service. ## Access Control SAML-based SSO allows centralized authentication through your identity provider. MFA adds an additional layer of security to user accounts and workspaces. RBAC enforces the least privilege principle on users based on specific roles. Every action in a workspace produces an audit log record. Owners and Admins can review the workspace audit log. ## Others Formo publishes a weekly summary of updates and fixes. ## Contact If you have any questions, [contact us](https://formo.so/support). ## FAQ No. Formo does not store IP addresses, use device fingerprinting, or set third-party cookies. See [what we collect](/data/what-we-collect) for a full breakdown of the data Formo processes. Yes. The Formo SDK is fully [open source on GitHub](https://github.com/getformo/sdk). You can audit the code, verify what data is collected, and contribute improvements. Use [Subresource Integrity (SRI)](/security/sri) to verify SDK integrity, configure a [Content Security Policy (CSP)](/security/csp) to control allowed domains, and set up a [reverse proxy](/sdks/web#proxy) to route requests through your own domain. Yes. Formo supports [Single Sign-On (SSO)](/security/sso) for centralized authentication through your identity provider, [multi-factor authentication (MFA)](/security/mfa) via TOTP, and [role-based access control](/security/roles) with Owner, Admin, Editor, and Viewer roles. # Role Based Access Control (RBAC) Source: https://docs.formo.so/security/roles Manage team permissions with role-based access control. Assign Owner, Admin, Editor, or Viewer roles to enforce least-privilege access across your workspace. ## Overview Formo uses workspace roles to control what each teammate can view or change. * **Owner**: Full access to the workspace. Owners can invite admins, transfer ownership, delete the workspace, and delete projects. Each workspace has one owner. * **Admin**: Full access to the workspace. Can manage members, roles, and settings but cannot invite admins or delete the workspace or projects. * **Editor**: Can manage projects, forms, contracts, charts, dashboards, and alerts. Editors cannot manage members, workspace settings, or key project settings. * **Viewer**: Read-only access to projects and forms. ## User Roles and Permissions | Action | Owner | Admin | Editor | Viewer | | --------------------------------------------------------------------------------- | ----- | ----- | ------ | ------ | | **Workspace** | | | | | | View workspace members | ✅ | ✅ | ❌ | ❌ | | Add, remove, and manage members | ✅ | ✅ | ❌ | ❌ | | Manage roles of members | ✅ | ✅ | ❌ | ❌ | | Access and manage workspace settings | ✅ | ✅ | ❌ | ❌ | | Manage billing | ✅ | ✅ | ❌ | ❌ | | View workspace usage | ✅ | ✅ | ❌ | ❌ | | Upgrade / downgrade workspace | ✅ | ✅ | ❌ | ❌ | | Delete the workspace | ✅ | ❌ | ❌ | ❌ | | Transfer ownership to another member | ✅ | ❌ | ❌ | ❌ | | **Projects** | | | | | | View projects | ✅ | ✅ | ✅ | ✅ | | Verify projects | ✅ | ✅ | ✅ | ✅ | | Access project settings page | ✅ | ✅ | ✅ | ❌ | | Create & rename projects | ✅ | ✅ | ❌ | ❌ | | Manage project integrations | ✅ | ✅ | ❌ | ❌ | | Manage project notifications | ✅ | ✅ | ❌ | ❌ | | Edit lifecycle thresholds | ✅ | ✅ | ❌ | ❌ | | Delete projects | ✅ | ❌ | ❌ | ❌ | | **Contracts** | | | | | | View contracts | ✅ | ✅ | ✅ | ✅ | | Manage contracts | ✅ | ✅ | ✅ | ❌ | | Deploy contract events pipeline | ✅ | ✅ | ❌ | ❌ | | **Segments** | | | | | | View project segments | ✅ | ✅ | ✅ | ✅ | | Create, delete, edit project segments | ✅ | ✅ | ✅ | ❌ | | **Charts, Dashboards & Explorer** | | | | | | View charts and dashboards | ✅ | ✅ | ✅ | ✅ | | Create, delete, edit charts | ✅ | ✅ | ✅ | ❌ | | Create dashboards | ✅ | ✅ | ✅ | ❌ | | Explorer (SQL editor and wallet search) | ✅ | ✅ | ✅ | ❌ | | **Users** | | | | | | View wallet profiles | ✅ | ✅ | ✅ | ✅ | | View sensitive profile data (wallet labels, socials, clusters, custom properties) | ✅ | ✅ | ✅ | ✅\* | | Import wallets | ✅ | ✅ | ✅ | ❌ | | Edit profile properties and labels | ✅ | ✅ | ✅ | ❌ | | **Alerts** | | | | | | View project alerts | ✅ | ✅ | ✅ | ✅ | | Create, delete, edit project alerts | ✅ | ✅ | ✅ | ❌ | | **Forms** | | | | | | View forms and responses | ✅ | ✅ | ✅ | ✅ | | Manage form responses | ✅ | ✅ | ✅ | ❌ | | Create and edit forms | ✅ | ✅ | ✅ | ❌ | | Archive forms | ✅ | ✅ | ✅ | ❌ | | Close forms | ✅ | ✅ | ✅ | ❌ | | **Data & Access** | | | | | | Manage API Keys | ✅ | ✅ | ❌ | ❌ | | MCP | ✅ | ✅ | ❌ | ❌ | | Create BI auth tokens | ✅ | ✅ | ❌ | ❌ | | View audit logs | ✅ | ✅ | ❌ | ❌ | | **Agent Memory (Ask AI)** | | | | | | Manage agent memory | ✅ | ✅ | ❌ | ❌ | \* Sensitive profile data (wallet labels, socials, clusters, and custom properties) is visible to Viewers by default. An Owner or Admin can hide this data from Viewers with a per-project setting. # Subresource Integrity (SRI) Source: https://docs.formo.so/security/sri Learn how Formo uses Subresource Integrity to verify fetched resources and protect against XSS, NPM hijacking, and man-in-the-middle attacks. ### Overview [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/SRI) enables browsers to verify that resources they fetch are delivered without unexpected manipulation. It works by allowing you to provide a cryptographic hash that the fetched resource must match. SRI matters because it offers **protection against malicious tampering**. If an attacker exploited a content delivery network (CDN) and modified the contents of JavaScript libraries hosted on that CDN, it would create vulnerabilities in all websites that use those libraries. SRI helps prevent attacks like cross-site scripting (XSS) by ensuring that the resources delivered to your site are exactly what they should be. ### How SRI Prevents MITM Attacks SRI provides cryptographic protection against man-in-the-middle attacks: 1. **Hash verification** - The browser computes a SHA384 hash of the downloaded script 2. **Integrity check** - If the hash doesn't match the `integrity` attribute, the browser **refuses to execute the script** 3. **Tamper detection** - Any modification in transit (MITM) or at rest (CDN compromise) is detected If someone intercepts the script during transit and modifies it, the hash will not match and the browser will block execution entirely. This is the same protection mechanism used by major CDNs like cdnjs and unpkg. ### How to Enable SRI Formo supports SRI integration, providing an additional layer of security for your data and users. Enable SRI on the browser [install](/install) snippet by updating the `src` and `integrity` fields: ```html theme={null} ``` > [Get the latest version number and integrity hash on GitHub](https://github.com/getformo/sdk/releases). The hash in the integrity attribute within the Formo install script works with SRI to lock an external JavaScript resource to its known contents at a specific point in time. This is verified by a base64-encoded cryptographic hash. If the file is modified after this point, the hash won't match, and supporting web browsers will refuse to load it. This ensures that the script you're running is exactly the one provided by Formo, without any hidden changes or malicious code. ### Verifying the Integrity Hash You can independently verify the SRI hash using the following methods: **Using OpenSSL (macOS/Linux):** ```bash theme={null} curl -s https://cdn.formo.so/analytics@1.33.0 | openssl dgst -sha384 -binary | openssl base64 -A ``` **Using Node.js:** ```javascript theme={null} const crypto = require('crypto'); const https = require('https'); https.get('https://cdn.formo.so/analytics@1.33.0', (res) => { const hash = crypto.createHash('sha384'); res.on('data', (chunk) => hash.update(chunk)); res.on('end', () => console.log('sha384-' + hash.digest('base64'))); }); ``` The output should match the integrity hash published in our [GitHub releases](https://github.com/getformo/sdk/releases). ### Combined Protection with CSP For maximum security, combine SRI with [Content Security Policy](/security/csp). When both are enabled: * **CSP** restricts where scripts can load from and where data can be sent * **SRI** ensures the loaded script hasn't been tampered with * **TLS 1.2+** encrypts all traffic in transit This defense-in-depth approach makes it effectively impossible for an attacker to compromise the Formo SDK through MITM attacks. # Single Sign-On (SSO) Source: https://docs.formo.so/security/sso Configure SAML-based Single Sign-On for your Formo organization to centralize team access, enforce identity provider policies, and streamline sign-in. Single Sign-On (SSO) allows your team members to sign in to Formo using your organization's identity provider, centralizing access management under your existing security policies. SSO is available on Enterprise plans. [Contact us](https://formo.so/support) to enable SSO for your organization. ## Supported providers Formo supports SSO with any SAML 2.0 identity provider, including: * **Okta** * **Google Workspace** (formerly G Suite) * **Azure Active Directory** Any other SAML 2.0-compliant identity provider works as well. ## How SSO works When SSO is enabled for your organization: 1. Team members visit the Formo sign-in page 2. They enter their work email address 3. Formo detects the SSO-enabled domain and redirects to your identity provider 4. Users authenticate with your identity provider 5. Upon successful authentication, users are redirected back to Formo ## Setting up SSO SSO configuration is handled by the Formo team. Here's what you'll need to provide: ### Step 1: Contact Formo Reach out to [support@formo.so](mailto:support@formo.so) or your account manager to request SSO setup. ### Step 2: Create a SAML application In your identity provider (e.g., Okta), create a new SAML 2.0 application with the following settings: | Setting | Value | | -------------------------------- | ----------------- | | **Single sign-on URL (ACS URL)** | Provided by Formo | | **Audience URI (SP Entity ID)** | Provided by Formo | | **Name ID format** | EmailAddress | | **Application username** | Email | ### Step 3: Configure attribute mappings Ensure the following attributes are mapped: | SAML Attribute | Value | | ---------------------- | -------------------- | | `email` | User's email address | | `firstName` (optional) | User's first name | | `lastName` (optional) | User's last name | ### Step 4: Provide Formo with your metadata Send the following to Formo: 1. **Metadata URL** - Your identity provider's SAML metadata URL 2. **Email domains** - The email domains to enable for SSO (e.g., `yourcompany.com`) ### Step 5: Test and verify Once configured, Formo will confirm the setup is complete. Test the SSO flow by: 1. Signing out of Formo 2. Going to [app.formo.so](https://app.formo.so) 3. Entering an email with your SSO-enabled domain 4. Verifying you're redirected to your identity provider 5. Authenticating and being redirected back to Formo ## Okta setup guide Here's a detailed guide for setting up SSO with Okta: ### 1. Create a new application 1. In the Okta Admin Console, go to **Applications** > **Applications** 2. Click **Create App Integration** 3. Select **SAML 2.0** and click **Next** ### 2. Configure general settings 1. Enter an **App name** (e.g., "Formo") 2. Optionally upload the Formo logo 3. Click **Next** ### 3. Configure SAML settings Enter the values provided by Formo: | Field | Value | | ---------------------------------- | ---------------------------------------------------------- | | **Single sign on URL** | `https://[provided].supabase.co/auth/v1/sso/saml/acs` | | **Audience URI (SP Entity ID)** | `https://[provided].supabase.co/auth/v1/sso/saml/metadata` | | **Name ID format** | EmailAddress | | **Application username** | Email | | **Update application username on** | Create and update | ### 4. Get the metadata URL 1. After creating the application, go to the **Sign On** tab 2. Copy the **Metadata URL** 3. Send this URL to Formo along with your email domain(s) ### 5. Assign users 1. Go to the **Assignments** tab 2. Assign the application to users or groups who should have access to Formo ## Enforcing SSO Once SSO is configured, you can optionally enforce SSO for all users on your domain. When enforced: * Users with matching email domains must authenticate via SSO * Password-based sign-in is disabled for those users * New team members are automatically required to use SSO Before enforcing SSO, ensure all team members can successfully authenticate through your identity provider. To enable SSO enforcement, contact [support@formo.so](mailto:support@formo.so). ## Managing users ### Adding users When SSO is enabled: 1. Add users to your SAML application in your identity provider 2. Users can then sign in to Formo using SSO 3. New users are automatically provisioned on first sign-in ### Removing users To remove a user's access: 1. Remove them from the SAML application in your identity provider 2. Optionally, remove them from the Formo team in **Team Settings** > **Members** ## Troubleshooting ### User can't sign in via SSO 1. Verify the user is assigned to the SAML application in your identity provider 2. Check that the user's email domain matches the configured SSO domain 3. Ensure the Name ID format is set to "EmailAddress" ### SSO redirect not working 1. Verify the Single sign-on URL (ACS URL) is correct 2. Check that the Audience URI matches exactly 3. Ensure there are no trailing slashes or whitespace in the URLs ### Need help? Contact [support@formo.so](mailto:support@formo.so) for assistance with SSO configuration. ## Security benefits SSO provides several security advantages: * **Centralized access control** - Manage all user access from your identity provider * **Automatic deprovisioning** - Remove access instantly when employees leave * **Stronger authentication** - Leverage your organization's MFA policies * **Audit trail** - Track authentication events in your identity provider logs * **Reduced password fatigue** - Users don't need another password to remember