From 0dede0f2f008dc53b04404ac0fb065cfc539c5d7 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 15:09:04 +0700 Subject: [PATCH 1/7] Update to latest API --- src/commands/profiles.ts | 24 +- src/index.ts | 31 +- src/lib/client.ts | 33 +- src/lib/forward.ts | 28 + src/lib/openapi.json | 6700 +++++++++++++++++++++++++++++++ test/commands/alerts.test.ts | 15 +- test/commands/boards.test.ts | 15 +- test/commands/charts.test.ts | 20 +- test/commands/contracts.test.ts | 8 +- test/commands/profiles.test.ts | 8 +- test/commands/segments.test.ts | 7 +- 11 files changed, 6817 insertions(+), 72 deletions(-) create mode 100644 src/lib/forward.ts create mode 100644 src/lib/openapi.json diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index bcc28d4..48c77d4 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -39,8 +39,8 @@ profiles.command('get', { export interface SearchProfilesOptions { address?: string - limit?: number - offset?: number + page?: number + size?: number orderBy?: string orderDir?: string expand?: string @@ -54,8 +54,8 @@ export function searchProfilesRun(options: SearchProfilesOptions) { const params: Record = {} if (options.address) params.address = options.address - if (options.limit !== undefined) params.limit = options.limit - if (options.offset !== undefined) params.offset = options.offset + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size if (options.orderBy) params.order_by = options.orderBy if (options.orderDir) params.order_dir = options.orderDir if (options.expand) params.expand = options.expand @@ -78,8 +78,8 @@ profiles.command('search', { description: 'Search wallet profiles with optional filters', options: z.object({ address: z.string().optional().describe('Filter by wallet address'), - limit: z.coerce.number().optional().describe('Max results to return'), - offset: z.coerce.number().optional().describe('Pagination offset'), + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 1000)'), orderBy: z .enum([ 'last_onchain', @@ -108,15 +108,19 @@ profiles.command('search', { .describe('Logic operator for combining conditions: "and" (default) or "or"'), }), examples: [ - { options: { limit: 10 }, description: 'List first 10 profiles' }, + { options: { size: 10 }, description: 'List first 10 profiles' }, { - options: { orderBy: 'net_worth_usd', orderDir: 'desc', limit: 5 }, + options: { orderBy: 'net_worth_usd', orderDir: 'desc', size: 5 }, description: 'Top 5 profiles by net worth', }, + { + options: { page: 2, size: 20 }, + description: 'Get the second page of 20 profiles', + }, { options: { conditions: '[{"field":"net_worth_usd","op":"gt","value":10000}]', - limit: 20, + size: 20, }, description: 'Search profiles with net worth > 10000', }, @@ -124,7 +128,7 @@ profiles.command('search', { options: { conditions: '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]', logic: 'or', - limit: 20, + size: 20, }, description: 'Search profiles matching either condition', }, diff --git a/src/index.ts b/src/index.ts index 37e6265..129b878 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { profiles } from './commands/profiles'; import { query } from './commands/query'; import { segments } from './commands/segments'; import { clearConfig, getApiKey, readConfig, saveConfig } from './lib/config'; +import { forwardToFormo } from './lib/forward'; +import openapiSpec from './lib/openapi.json'; import { banner, color, error, info, success, warn } from './lib/ui'; const DASHBOARD_URL = 'https://app.formo.so'; @@ -37,15 +39,11 @@ function loginGuide(): string { ].join('\n'); } -interface ValidateApiKeyResponse { - isSuccess: boolean; - message?: string; - data?: { - validated: boolean; - details: string; - teamId: string; - scopes: { project_id?: string } | null; - }; +interface ValidateApiKeyData { + validated: boolean; + details: string; + teamId: string; + scopes: { project_id?: string } | null; } async function validateAndFetchWorkspace( @@ -60,12 +58,12 @@ async function validateAndFetchWorkspace( if (!res.ok) return null; - const body = (await res.json()) as ValidateApiKeyResponse; - if (!body.isSuccess || !body.data) return null; + const body = (await res.json()) as ValidateApiKeyData; + if (!body.validated) return null; return { - workspace: body.data.details, - projectId: body.data.scopes?.project_id ?? '', + workspace: body.details, + projectId: body.scopes?.project_id ?? '', }; } catch { return null; @@ -296,6 +294,13 @@ cli.command(contracts); cli.command(segments); cli.command(importCmd); +// ── api: OpenAPI-driven raw access (auto-generated subcommands from openapi.json) ── + +cli.command('api', { + fetch: forwardToFormo, + openapi: openapiSpec, +}); + // Show banner when run with no args (root help) const args = process.argv.slice(2); const isRootHelp = diff --git a/src/lib/client.ts b/src/lib/client.ts index 451aea0..c0d6d34 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -20,15 +20,32 @@ function createClient() { (res) => res.data, (error: AxiosError) => { const status = error.response?.status - const data = error.response?.data as - | { message?: string; error?: string | { message?: string; code?: string } } + const body = error.response?.data as + | { + error?: { + code?: string + message?: string + doc_url?: string + param?: string + details?: Record + } + } | undefined - const errorField = data?.error - const message = - data?.message ?? - (typeof errorField === 'object' ? errorField?.message : errorField) ?? - error.message - throw Object.assign(new Error(message), { status, code: error.code }) + const apiError = body?.error + const baseMessage = apiError?.message ?? error.message + const parts: string[] = [] + parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage) + if (apiError?.param) parts.push(`Param: ${apiError.param}`) + if (apiError?.doc_url) parts.push(`Docs: ${apiError.doc_url}`) + const message = parts.join('\n ') + throw Object.assign(new Error(message), { + status, + code: apiError?.code, + docUrl: apiError?.doc_url, + param: apiError?.param, + details: apiError?.details, + transportCode: error.code, + }) }, ) diff --git a/src/lib/forward.ts b/src/lib/forward.ts new file mode 100644 index 0000000..5e419b4 --- /dev/null +++ b/src/lib/forward.ts @@ -0,0 +1,28 @@ +import { getApiKey } from './config' + +const API_BASE_URL = 'https://api.formo.so' + +export async function forwardToFormo(req: Request): Promise { + const apiKey = getApiKey() + const incoming = new URL(req.url) + const upstream = new URL(incoming.pathname + incoming.search, API_BASE_URL) + + const headers = new Headers(req.headers) + headers.delete('host') + headers.delete('content-length') + if (apiKey) headers.set('authorization', `Bearer ${apiKey}`) + if (!headers.has('content-type') && req.method !== 'GET' && req.method !== 'HEAD') { + headers.set('content-type', 'application/json') + } + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'follow', + } + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = await req.text() + } + + return fetch(upstream, init) +} diff --git a/src/lib/openapi.json b/src/lib/openapi.json new file mode 100644 index 0000000..b84b83c --- /dev/null +++ b/src/lib/openapi.json @@ -0,0 +1,6700 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Formo Public API", + "description": "REST API for managing Formo projects, analytics, alerts, boards, charts, contracts, segments, and AI chat.\n\n**Auth.** All endpoints require a workspace API key with the appropriate scopes — see `x-api-scopes`.\n\n**Response shape.** Successful responses return the resource directly (or `{ data: [...], total, page, size, has_more }` for paginated lists). HTTP status carries success/failure — there is no envelope wrapping success bodies.\n\n**Errors.** Every non-2xx response uses the `Error` envelope: `{ error: { code, message, doc_url, param?, details? } }`. Branch on the machine-readable `code` (see `ErrorCode` enum) and follow `doc_url` to the matching section of the [errors reference](https://docs.formo.so/api/errors).\n\n**Idempotency.** Pass an `Idempotency-Key` header on POST/PUT/PATCH/DELETE to make retries safe; the response is cached for 24 h and replayed on duplicate keys.", + "version": "0.1.0", + "contact": { + "name": "Formo", + "url": "https://formo.so" + } + }, + "servers": [ + { + "url": "https://api.formo.so", + "description": "API Server (boards, alerts, contracts, segments, profiles, query, import)" + }, + { + "url": "https://events.formo.so", + "description": "Events Server (event ingestion)" + } + ], + "security": [ + { + "WorkspaceApiKey": [] + } + ], + "components": { + "securitySchemes": { + "WorkspaceApiKey": { + "type": "http", + "scheme": "bearer", + "description": "Workspace API key (e.g. `formo_xxx`). Create one in the Formo dashboard under Team Settings > API Keys." + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Standard error envelope returned by every public API endpoint for any non-2xx response. The HTTP status code carries success/failure; the body provides a machine-readable `code`, a human-readable `message`, and a `doc_url` pointing at the matching section of the docs so agents can fetch context on the fly.", + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message", + "doc_url" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string", + "description": "Human-readable error description. Wording may change between releases — branch on `code`, not `message`." + }, + "doc_url": { + "type": "string", + "format": "uri", + "description": "Link to the matching section of the errors reference at https://docs.formo.so/api/errors." + }, + "param": { + "type": "string", + "description": "When the error pertains to a specific request field, the dotted path to that field (e.g. `body.trigger_filters.0.value`)." + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Code-specific extra context. For `INVALID_VALIDATION_REQUEST` this is a `{ fieldPath: message }` map of every Zod validation failure." + } + } + } + }, + "required": [ + "error" + ] + }, + "ErrorCode": { + "type": "string", + "description": "Stable, enumerated error codes. New codes may be added in any release; clients should treat unknown codes as the closest matching HTTP status family.", + "enum": [ + "INTERNAL_SERVER_ERROR", + "INVALID_VALIDATION_REQUEST", + "UNAUTHORIZED", + "BAD_REQUEST", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "INVALID_CHAIN_ID", + "CONTEXT_LIMIT_EXCEEDED", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "IDEMPOTENCY_IN_PROGRESS", + "INVALID_IDEMPOTENCY_KEY" + ] + }, + "Alert": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "trigger_type": { + "type": "string", + "enum": [ + "event", + "user" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "trigger_filters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + }, + "numericThreshold": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + } + }, + "recipient": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "email", + "slack", + "webhook" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "value" + ] + } + }, + "project_id": { + "type": "string" + }, + "has_secret": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "trigger_type", + "status", + "project_id" + ] + }, + "Board": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "project_id": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "description": "Whether the board is publicly accessible" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "project_id", + "enabled" + ] + }, + "StepFilterCondition": { + "type": "object", + "description": "A filter condition for an event property on a funnel step. The `op` field specifies the comparison operator and `value` is the value to compare against. For `in` and `notIn` operators the value is a pipe-delimited string (e.g. `\"metamask|rainbow|coinbase\"`).", + "properties": { + "op": { + "type": "string", + "enum": [ + "equals", + "notEquals", + "in", + "notIn", + "gt", + "gte", + "lt", + "lte", + "startsWith", + "endsWith", + "includes" + ], + "description": "Comparison operator. Use `in` / `notIn` for multi-value matching (pipe-delimited value string)." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The comparison value. For `in` / `notIn`, use a pipe-delimited string: `\"metamask|rainbow|coinbase\"`." + } + }, + "required": [ + "op", + "value" + ] + }, + "FunnelStep": { + "type": "object", + "description": "A single step in a funnel or user-path flow.\n\n`type` and `event` are required. Any additional property key becomes a filter condition using the `StepFilterCondition` schema (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`).\n\nStandard filterable columns: `origin`, `device`, `browser`, `os`, `location`, `referrer`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`. Any other key is treated as a JSON event property and extracted via `JSONExtractString(properties, '')`.", + "properties": { + "type": { + "type": "string", + "enum": [ + "event", + "track", + "decoded_log" + ], + "description": "`event` — built-in page/connect/transaction events; `track` — custom tracked events; `decoded_log` — decoded smart-contract events." + }, + "event": { + "type": "string", + "minLength": 1, + "description": "Event name (e.g. `page`, `connect`, `transaction`, or a custom track event name)." + } + }, + "required": [ + "type", + "event" + ], + "additionalProperties": { + "$ref": "#/components/schemas/StepFilterCondition" + } + }, + "ConversionWindow": { + "type": "object", + "description": "Time window within which a user must complete all funnel steps (measured from Step 1). Defaults to 1 day if omitted.", + "properties": { + "value": { + "type": "integer", + "minimum": 1, + "description": "Number of time units." + }, + "unit": { + "type": "string", + "enum": [ + "minute", + "hour", + "day", + "week", + "month" + ], + "description": "Time unit. `month` = 30 days, `week` = 7 days." + } + }, + "required": [ + "value", + "unit" + ] + }, + "AnalyticsFilterCondition": { + "type": "object", + "description": "A single analytics filter condition. Use `in` / `notIn` with a pipe-delimited string value (e.g. `\"a|b|c\"`) for multi-value matches.", + "properties": { + "field": { + "type": "string", + "description": "Column to filter on. Standard session columns: `device`, `browser`, `os`, `location`, `referrer`, `ref`, `origin`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`. Track-event columns: `event`, `type`. Numeric event properties: `volume`, `revenue`, `points`." + }, + "op": { + "type": "string", + "enum": [ + "equals", + "notEquals", + "greater", + "less", + "greaterOrEqual", + "lessOrEqual", + "in", + "notIn" + ], + "description": "Comparison operator." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Value to compare against. For `in` / `notIn`, pass a pipe-delimited string (e.g. `\"chrome|firefox\"`)." + } + }, + "required": [ + "field", + "op", + "value" + ] + }, + "RetentionUserFilter": { + "type": "object", + "description": "A user-level segment filter applied to the retention chart cohort.", + "properties": { + "field": { + "type": "string", + "description": "The user property to filter on (e.g. `device`, `browser`, `os`, `location`, `utm_source`, `utm_medium`, `utm_campaign`)." + }, + "op": { + "type": "string", + "enum": [ + "equals", + "notEquals", + "in", + "notIn", + "gt", + "gte", + "lt", + "lte", + "startsWith", + "endsWith", + "includes" + ], + "description": "Comparison operator." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The value to compare against." + } + }, + "required": [ + "field", + "op", + "value" + ] + }, + "ChartSettings": { + "type": "object", + "description": "Chart-type-specific configuration. The fields that apply depend on `chart_type`:\n\n- **funnel**: `funnelType`, `conversionWindow`, `breakdown`\n- **user_paths**: `startStep`, `endStep`, `maxSteps`, `nodesPerStep`, `conversionWindow`, `filters`\n- **retention**: `retentionFilter`, `retentionUserFilters`\n\nAll fields are optional at the schema level; see per-type validation rules for which are functionally required.", + "properties": { + "funnelType": { + "type": "string", + "enum": [ + "closed", + "open" + ], + "default": "closed", + "description": "**Funnel only.** `closed` — users must complete steps in strict order with no intervening events. `open` — users may complete steps in order but other events may occur between steps." + }, + "conversionWindow": { + "$ref": "#/components/schemas/ConversionWindow", + "description": "**Funnel & user_paths.** Maximum time from Step 1 for a user to complete all steps." + }, + "breakdown": { + "type": "string", + "enum": [ + "device", + "browser", + "os", + "referrer", + "ref", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content" + ], + "description": "**Funnel only.** Split each funnel bar by this dimension. The top categories are shown individually; the rest are collapsed into 'Others'." + }, + "startStep": { + "$ref": "#/components/schemas/FunnelStep", + "description": "**user_paths — required.** The event where the user flow begins." + }, + "endStep": { + "oneOf": [ + { + "$ref": "#/components/schemas/FunnelStep" + }, + { + "type": "null" + } + ], + "description": "**user_paths — optional.** The event where the user flow ends. If `null` the flow is open-ended." + }, + "maxSteps": { + "type": "integer", + "minimum": 2, + "maximum": 10, + "default": 3, + "description": "**user_paths.** Maximum number of steps to show in the flow (2–10). Values above 10 are clamped to 10." + }, + "nodesPerStep": { + "type": "integer", + "minimum": 2, + "maximum": 10, + "default": 5, + "description": "**user_paths.** Maximum number of unique event nodes visible per step (2–10). Values above 10 are clamped to 10." + }, + "filters": { + "type": "string", + "description": "**user_paths.** JSON-encoded string of additional filters applied to the path query." + }, + "retentionFilter": { + "oneOf": [ + { + "$ref": "#/components/schemas/FunnelStep" + }, + { + "type": "null" + } + ], + "description": "**retention.** Event that qualifies a returning visit as 'retained'. If `null`, any event counts as a return." + }, + "retentionUserFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RetentionUserFilter" + }, + "description": "**retention.** Zero or more user-segment filters that narrow the cohort (e.g. only desktop users, only users from a specific UTM source)." + } + } + }, + "CreateChartRequest": { + "type": "object", + "description": "Request body for creating a chart.", + "properties": { + "projectId": { + "type": "string", + "description": "Project the chart belongs to." + }, + "query": { + "type": "string", + "minLength": 1, + "description": "SQL query that powers the chart.\n\n- **`funnel`** — pass `\"SELECT 1\"`; the actual query is auto-generated from `steps`.\n- **`retention`** — can be omitted or pass `\"\"`; data is fetched from the retention pipe directly.\n- **All other types** — required; must be a valid SQL string." + }, + "chart_type": { + "type": "string", + "enum": [ + "table", + "number", + "funnel", + "bar", + "line", + "pie", + "stacked", + "user_paths", + "retention" + ], + "description": "Visualization type. Determines which other fields are required:\n\n| `chart_type` | Extra required fields |\n|---|---|\n| `table` | `query` |\n| `number` | `query` (must return 1 row × 1 column) |\n| `bar` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `line` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `pie` | `query`, `y_axis` (exactly 1) |\n| `stacked` | `query`, `x_axis`, `y_axis` (exactly 1), `group_by` |\n| `funnel` | `steps` (≥ 2), `query` placeholder `\"SELECT 1\"` |\n| `user_paths` | `query`, `settings.startStep` |\n| `retention` | none (`query` ignored) |" + }, + "title": { + "type": "string", + "minLength": 1, + "description": "Display name shown on the chart and board." + }, + "description": { + "type": "string", + "description": "Optional description." + }, + "x_axis": { + "type": "string", + "description": "Column name for the X axis. Required for `bar`, `line`, and `stacked`." + }, + "y_axis": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column name(s) used as Y axis metrics.\n\n- `bar` / `line` — at least 1 element required.\n- `pie` / `stacked` — exactly 1 element required." + }, + "group_by": { + "type": "string", + "description": "Column to group / stack series by. Required for `stacked`." + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FunnelStep" + }, + "minItems": 2, + "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep` — add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`)." + }, + "settings": { + "$ref": "#/components/schemas/ChartSettings" + } + }, + "required": [ + "projectId", + "chart_type", + "title" + ] + }, + "UpdateChartRequest": { + "type": "object", + "description": "Request body for updating an existing chart.", + "properties": { + "chartId": { + "type": "string", + "description": "ID of the chart to update." + }, + "projectId": { + "type": "string", + "description": "Project the chart belongs to." + }, + "query": { + "type": "string", + "minLength": 1, + "description": "SQL query that powers the chart.\n\n- **`funnel`** — pass `\"SELECT 1\"`; the actual query is auto-generated from `steps`.\n- **`retention`** — can be omitted or pass `\"\"`; data is fetched from the retention pipe directly.\n- **All other types** — required; must be a valid SQL string." + }, + "chart_type": { + "type": "string", + "enum": [ + "table", + "number", + "funnel", + "bar", + "line", + "pie", + "stacked", + "user_paths", + "retention" + ], + "description": "Visualization type. Determines which other fields are required:\n\n| `chart_type` | Extra required fields |\n|---|---|\n| `table` | `query` |\n| `number` | `query` (must return 1 row × 1 column) |\n| `bar` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `line` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `pie` | `query`, `y_axis` (exactly 1) |\n| `stacked` | `query`, `x_axis`, `y_axis` (exactly 1), `group_by` |\n| `funnel` | `steps` (≥ 2), `query` placeholder `\"SELECT 1\"` |\n| `user_paths` | `query`, `settings.startStep` |\n| `retention` | none (`query` ignored) |" + }, + "title": { + "type": "string", + "minLength": 1, + "description": "Display name shown on the chart and board." + }, + "description": { + "type": "string", + "description": "Optional description." + }, + "x_axis": { + "type": "string", + "description": "Column name for the X axis. Required for `bar`, `line`, and `stacked`." + }, + "y_axis": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column name(s) used as Y axis metrics.\n\n- `bar` / `line` — at least 1 element required.\n- `pie` / `stacked` — exactly 1 element required." + }, + "group_by": { + "type": "string", + "description": "Column to group / stack series by. Required for `stacked`." + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FunnelStep" + }, + "minItems": 2, + "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep` — add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`)." + }, + "settings": { + "$ref": "#/components/schemas/ChartSettings" + } + }, + "required": [ + "chartId", + "projectId", + "chart_type", + "title" + ] + }, + "Chart": { + "type": "object", + "description": "A saved chart attached to a board.", + "properties": { + "id": { + "type": "string" + }, + "chart_type": { + "type": "string", + "enum": [ + "table", + "number", + "funnel", + "bar", + "line", + "pie", + "stacked", + "user_paths", + "retention" + ], + "description": "Visualization type." + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "query": { + "type": "string", + "description": "SQL query powering the chart. For `funnel` and `retention` charts this is a system-managed placeholder." + }, + "project_id": { + "type": "string" + }, + "board_id": { + "type": "string" + }, + "x_axis": { + "type": "string", + "nullable": true, + "description": "Column used as the X axis." + }, + "y_axis": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "Column(s) used as Y axis metric(s)." + }, + "group_by": { + "type": "string", + "nullable": true, + "description": "Column used to group/stack series." + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FunnelStep" + }, + "nullable": true, + "description": "Ordered list of funnel steps. Only present when `chart_type` is `funnel`." + }, + "settings": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChartSettings" + }, + { + "type": "null" + } + ], + "description": "Type-specific configuration. See `ChartSettings` for all fields." + } + }, + "required": [ + "id", + "chart_type", + "title", + "query", + "project_id", + "board_id" + ] + }, + "Contract": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "chain": { + "type": "integer" + }, + "address": { + "type": "string" + }, + "start_block": { + "type": "integer" + }, + "abi": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "anonymous": { + "type": "boolean" + }, + "inputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "anonymous", + "inputs", + "name", + "type" + ] + } + } + }, + "required": [ + "name", + "chain", + "address", + "abi", + "events" + ] + }, + "Segment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "filterSet": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "title" + ] + }, + "Profile": { + "type": "object", + "description": "Comprehensive wallet profile with onchain and offchain data", + "properties": { + "address": { + "type": "string" + }, + "net_worth_usd": { + "type": "number" + }, + "tx_count": { + "type": "integer" + }, + "first_onchain": { + "type": "string", + "description": "First onchain activity date" + }, + "last_onchain": { + "type": "string", + "description": "Last onchain activity date" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "ens": { + "type": "string", + "nullable": true + }, + "farcaster": { + "type": "string", + "nullable": true + }, + "lens": { + "type": "string", + "nullable": true + }, + "basenames": { + "type": "string", + "nullable": true + }, + "linea": { + "type": "string", + "nullable": true + }, + "avatar": { + "type": "string", + "nullable": true + }, + "display_name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "discord": { + "type": "string", + "nullable": true + }, + "telegram": { + "type": "string", + "nullable": true + }, + "twitter": { + "type": "string", + "nullable": true + }, + "github": { + "type": "string", + "nullable": true + }, + "linkedin": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + }, + "instagram": { + "type": "string", + "nullable": true + }, + "facebook": { + "type": "string", + "nullable": true + }, + "website": { + "type": "string", + "nullable": true + }, + "reddit": { + "type": "string", + "nullable": true + }, + "youtube": { + "type": "string", + "nullable": true + }, + "tiktok": { + "type": "string", + "nullable": true + }, + "first_seen": { + "type": "string", + "nullable": true, + "description": "First seen in project" + }, + "last_seen": { + "type": "string", + "nullable": true, + "description": "Last seen in project" + }, + "lifecycle": { + "type": "string", + "nullable": true, + "enum": [ + "New", + "Returning", + "Power user", + "Resurrected", + "Churned" + ] + }, + "num_sessions": { + "type": "integer", + "nullable": true + }, + "revenue": { + "type": "number", + "nullable": true + }, + "volume": { + "type": "number", + "nullable": true + }, + "points": { + "type": "number", + "nullable": true + }, + "device": { + "type": "string", + "nullable": true + }, + "browser": { + "type": "string", + "nullable": true + }, + "os": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "first_utm_source": { + "type": "string", + "nullable": true + }, + "first_utm_medium": { + "type": "string", + "nullable": true + }, + "first_utm_campaign": { + "type": "string", + "nullable": true + }, + "first_referrer": { + "type": "string", + "nullable": true + }, + "last_utm_source": { + "type": "string", + "nullable": true + }, + "last_utm_medium": { + "type": "string", + "nullable": true + }, + "last_utm_campaign": { + "type": "string", + "nullable": true + }, + "last_referrer": { + "type": "string", + "nullable": true + }, + "first_referrer_url": { + "type": "string", + "nullable": true, + "description": "First referrer full URL" + }, + "last_referrer_url": { + "type": "string", + "nullable": true, + "description": "Last referrer full URL" + }, + "first_ref": { + "type": "string", + "nullable": true, + "description": "First referral code" + }, + "last_ref": { + "type": "string", + "nullable": true, + "description": "Last referral code" + }, + "first_utm_content": { + "type": "string", + "nullable": true, + "description": "First UTM content" + }, + "last_utm_content": { + "type": "string", + "nullable": true, + "description": "Last UTM content" + }, + "first_utm_term": { + "type": "string", + "nullable": true, + "description": "First UTM term" + }, + "last_utm_term": { + "type": "string", + "nullable": true, + "description": "Last UTM term" + }, + "last_type": { + "type": "string", + "nullable": true, + "description": "Last event type" + }, + "last_event": { + "type": "string", + "nullable": true, + "description": "Last event name" + }, + "last_properties": { + "type": "string", + "nullable": true, + "description": "Last event properties (JSON string)" + }, + "activity_dates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + }, + "nullable": true, + "description": "Array of activity dates (YYYY-MM-DD format)" + }, + "chains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WalletChain" + }, + "description": "Requires expand=chains" + }, + "apps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WalletApp" + }, + "description": "Requires expand=apps" + }, + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WalletToken" + }, + "description": "Requires expand=tokens" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WalletLabel" + }, + "description": "Requires expand=labels" + } + } + }, + "EventContext": { + "type": "object", + "description": "Contextual information about the event environment", + "properties": { + "user_agent": { + "type": "string" + }, + "locale": { + "type": "string", + "description": "e.g. en-US" + }, + "timezone": { + "type": "string", + "description": "e.g. America/New_York" + }, + "page_url": { + "type": "string" + }, + "page_path": { + "type": "string" + }, + "page_title": { + "type": "string" + }, + "page_query": { + "type": "string" + }, + "page_hash": { + "type": "string" + }, + "referrer_url": { + "type": "string" + }, + "referrer": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "utm_source": { + "type": "string" + }, + "utm_medium": { + "type": "string" + }, + "utm_campaign": { + "type": "string" + }, + "utm_term": { + "type": "string" + }, + "utm_content": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "device": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet" + ] + }, + "os": { + "type": "string" + }, + "screen_width": { + "type": "integer" + }, + "screen_height": { + "type": "integer" + }, + "screen_density": { + "type": "number", + "description": "Pixel density of the device screen (devicePixelRatio)" + }, + "viewport_width": { + "type": "integer", + "description": "Width of the browser viewport in pixels" + }, + "viewport_height": { + "type": "integer", + "description": "Height of the browser viewport in pixels" + }, + "location": { + "type": "string", + "description": "Geographic location country code (e.g., US, NG)" + }, + "library_name": { + "type": "string" + }, + "library_version": { + "type": "string" + } + } + }, + "EventProperties": { + "type": "object", + "description": "Event-specific properties. Can contain any key-value pairs relevant to the event.", + "additionalProperties": true + }, + "Event": { + "type": "object", + "description": "A single analytics event", + "required": [ + "type", + "anonymous_id", + "version", + "channel", + "message_id" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "page", + "screen", + "connect", + "disconnect", + "chain", + "signature", + "transaction", + "track", + "decoded_log", + "detect", + "identify" + ] + }, + "channel": { + "type": "string", + "enum": [ + "web", + "mobile", + "server", + "api", + "import" + ], + "description": "Source of the event. The Formo Web SDK uses `web`; mobile SDK uses `mobile`; server SDK uses `server`. Use `api` for direct HTTP submissions and `import` for backfills." + }, + "version": { + "type": "string", + "description": "SDK schema version. Web SDK 1.x emits `1`; legacy clients emit `0`.", + "example": "1" + }, + "anonymous_id": { + "type": "string", + "description": "Anonymous visitor identifier" + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "Identified user ID" + }, + "address": { + "type": "string", + "nullable": true, + "description": "Wallet address" + }, + "event": { + "type": "string", + "nullable": true, + "description": "Event name (for track events)" + }, + "context": { + "$ref": "#/components/schemas/EventContext" + }, + "properties": { + "$ref": "#/components/schemas/EventProperties" + }, + "original_timestamp": { + "type": "string", + "format": "date-time" + }, + "sent_at": { + "type": "string", + "format": "date-time" + }, + "message_id": { + "type": "string", + "description": "Unique ID for deduplication" + } + } + }, + "WalletChain": { + "type": "object", + "properties": { + "chain_id": { + "type": "string" + }, + "net_worth_usd": { + "type": "number" + }, + "tx_count": { + "type": "integer" + }, + "first_onchain": { + "type": "string" + }, + "last_onchain": { + "type": "string" + } + } + }, + "WalletApp": { + "type": "object", + "properties": { + "chain_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "img": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "nullable": true + }, + "balance_usd": { + "type": "number" + } + } + }, + "WalletToken": { + "type": "object", + "properties": { + "chain_id": { + "type": "string" + }, + "token_address": { + "type": "string" + }, + "app_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "img": { + "type": "string", + "nullable": true + }, + "decimals": { + "type": "integer" + }, + "price": { + "type": "number" + }, + "balance": { + "type": "string" + }, + "balance_usd": { + "type": "number" + } + } + }, + "WalletLabel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "e.g. coinbase.verified_account" + }, + "value": { + "type": "string" + }, + "chain_id": { + "type": "string" + }, + "source": { + "type": "string" + } + } + }, + "UserLabelInput": { + "type": "object", + "required": [ + "tag_id" + ], + "properties": { + "tag_id": { + "type": "string", + "description": "Label identifier (lowercased on write). e.g. vip, airdrop_eligible, coinbase.verified_account" + }, + "value": { + "type": "string", + "description": "Optional label value (e.g. tier name, country code)" + }, + "chain_id": { + "type": "string", + "description": "Optional chain identifier the label applies to" + } + } + }, + "ProfileFilter": { + "type": "object", + "description": "Filter conditions for profile search", + "properties": { + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterCondition" + } + }, + "logic": { + "type": "string", + "enum": [ + "and", + "or" + ], + "default": "and" + } + } + }, + "FilterCondition": { + "type": "object", + "required": [ + "field", + "op", + "value" + ], + "properties": { + "field": { + "type": "string", + "description": "Field path. Profile: users.net_worth_usd, users.volume, users.revenue, users.points. Engagement: users.device, users.browser, users.os, users.location. Lifecycle: users.lifecycle. Socials: users.ens, users.farcaster, etc. Chains: chains.balance, chains.{chain_id}.balance. Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance. Labels: labels.{tag_id}" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq", + "gt", + "gte", + "lt", + "lte", + "in", + "nin" + ] + }, + "value": { + "description": "Filter value (string, number, boolean, or array)" + }, + "scope": { + "type": "string", + "enum": [ + "any", + "protocol" + ], + "description": "For token filters: any=wallet+protocol, protocol=specific app" + }, + "appId": { + "type": "string", + "description": "For token scope=protocol (e.g. aave-v3)" + } + } + }, + "RestApiError": { + "$ref": "#/components/schemas/Error", + "description": "Deprecated alias for `Error`. Existing endpoint specs reference this name; new specs should reference `Error` directly." + }, + "AnalyticsResponse": { + "type": "object", + "description": "Raw Tinybird pipe response. The `data` array contains the analytics rows; the exact row shape depends on the endpoint.", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "meta": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "rows": { + "type": "integer" + }, + "rows_before_limit_at_least": { + "type": "integer" + }, + "statistics": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "parameters": { + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string", + "maxLength": 255 + }, + "description": "Optional unique value (e.g. a UUID v4) that lets you safely retry POST/PUT/PATCH/DELETE requests. The first request runs normally; subsequent requests with the same key replay the stored response (status + body) for 24 hours, so retries can never double-create or double-charge. Two concurrent requests with the same key return `409 IDEMPOTENCY_IN_PROGRESS`. Generate a fresh key per logical operation." + }, + "AnalyticsDateFrom": { + "name": "date_from", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Inclusive start date (YYYY-MM-DD). Defaults to 7 days before date_to." + }, + "AnalyticsDateTo": { + "name": "date_to", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Inclusive end date (YYYY-MM-DD). Defaults to today." + }, + "AnalyticsFilters": { + "name": "filters", + "in": "query", + "description": "Array of filter conditions, JSON-encoded in the query string. Each entry is `{ field, op, value }`. Use `in` / `notIn` with a pipe-delimited `value` (e.g. `\"chrome|firefox\"`) for multi-value matching.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnalyticsFilterCondition" + } + }, + "example": [ + { + "field": "location", + "op": "equals", + "value": "US" + }, + { + "field": "device", + "op": "in", + "value": "desktop|mobile" + } + ] + } + } + }, + "AnalyticsLimit": { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 1000 + }, + "description": "Maximum results to return (default 50, max 1000)" + }, + "AnalyticsOffset": { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 100000 + }, + "description": "Number of results to skip for pagination (default 0)" + } + }, + "responses": { + "BadRequest": { + "description": "The request was rejected — typically Zod validation failure. `code` is `INVALID_VALIDATION_REQUEST` and `details` contains a `{ fieldPath: message }` map.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "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)" + } + } + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key", + "doc_url": "https://docs.formo.so/api/errors#unauthorized" + } + } + } + } + }, + "Forbidden": { + "description": "The API key is valid but lacks the required scope for this endpoint.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "API key missing required scope: alerts:write", + "doc_url": "https://docs.formo.so/api/errors#forbidden" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not visible to this API key's workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Alert not found", + "doc_url": "https://docs.formo.so/api/errors#not_found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state, or an `Idempotency-Key` request with the same key is currently in flight.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "IDEMPOTENCY_IN_PROGRESS", + "message": "A request with this Idempotency-Key is already in progress. Retry shortly.", + "doc_url": "https://docs.formo.so/api/errors#idempotency_in_progress" + } + } + } + } + }, + "TooManyRequests": { + "description": "Per-workspace rate limit exceeded. Inspect the `RateLimit-*` response headers and back off.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "TOO_MANY_REQUESTS", + "message": "Too many requests", + "doc_url": "https://docs.formo.so/api/errors#too_many_requests" + } + } + } + } + }, + "InternalServerError": { + "description": "An unexpected error occurred on the server. The error has been logged; retry with exponential backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal Server Error", + "doc_url": "https://docs.formo.so/api/errors#internal_server_error" + } + } + } + } + } + } + }, + "x-api-scopes": { + "description": "API key scopes control access to endpoints. Create keys with the required scopes in Team Settings > API Keys.", + "scopes": { + "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)", + "profiles:read": "Search and get wallet profiles", + "profiles:write": "Import wallet addresses (requires profiles:read)", + "query:read": "Execute SQL analytics queries and read pre-built analytics endpoints (KPIs, top pages, lifecycle, retention, revenue, etc.)", + "mcp:read": "MCP protocol access (analytics tools + management tools based on other scopes)" + } + }, + "paths": { + "/v0/alerts": { + "get": { + "operationId": "listAlerts", + "summary": "List alerts", + "description": "List all alerts for the project scoped to the API key.", + "tags": [ + "Alerts" + ], + "x-required-scope": "alerts:read", + "responses": { + "200": { + "description": "List of alerts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Alert" + } + }, + "example": { + "data": [ + { + "id": "alrt_4f8e2c1a9b3d4e5f", + "project_id": "proj_abc123", + "name": "Daily revenue drop", + "trigger_type": "event", + "status": "active", + "trigger_filters": [ + { + "name": "event", + "operator": "equals", + "value": "transaction" + }, + { + "name": "revenue", + "operator": "less_than", + "value": "1000", + "numericThreshold": "sum" + } + ], + "recipient": [ + { + "type": "email", + "value": [ + "alerts@myapp.com" + ] + }, + { + "type": "slack", + "value": [ + "C0123456789" + ] + } + ], + "has_secret": false, + "created_at": "2026-04-12T09:32:18.000Z", + "updated_at": "2026-04-25T14:01:55.000Z" + } + ], + "meta": { + "total": 1, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "post": { + "operationId": "createAlert", + "summary": "Create alert", + "description": "Create a new alert for the project.", + "tags": [ + "Alerts" + ], + "x-required-scope": "alerts:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "trigger_type": { + "type": "string", + "enum": [ + "event", + "user" + ] + }, + "trigger_filters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + }, + "numericThreshold": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + } + }, + "recipient": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "email", + "slack", + "webhook" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "secret": { + "type": "string", + "description": "Webhook signing secret" + } + }, + "required": [ + "name", + "trigger_type", + "trigger_filters" + ] + }, + "examples": { + "eventAlertWithWebhook": { + "summary": "Alert on swap events from mobile devices with webhook", + "value": { + "name": "Mobile swap alert", + "trigger_type": "event", + "trigger_filters": [ + { + "name": "type", + "value": "swap", + "operator": "equals" + }, + { + "name": "device", + "value": "mobile", + "operator": "equals" + }, + { + "name": "volume", + "value": "1000", + "operator": "greater", + "numericThreshold": "1000" + } + ], + "recipient": [ + { + "type": "webhook", + "value": [ + "https://hooks.myapp.com/formo-alerts" + ] + }, + { + "type": "email", + "value": [ + "alerts@myapp.com" + ] + } + ], + "secret": "whsec_mysigningsecret123" + } + }, + "eventAlertWithUtmFilter": { + "summary": "Alert on events from specific UTM campaigns", + "value": { + "name": "Paid campaign activity", + "trigger_type": "event", + "trigger_filters": [ + { + "name": "type", + "value": "purchase" + }, + { + "name": "utm_source", + "value": "google", + "operator": "equals" + }, + { + "name": "utm_medium", + "value": "cpc", + "operator": "equals" + } + ], + "recipient": [ + { + "type": "slack", + "value": [ + "#marketing-alerts|https://hooks.slack.com/services/T00/B00/xxx" + ] + } + ] + } + }, + "userAlertWithLocationFilter": { + "summary": "Alert when new users spike from a specific country", + "value": { + "name": "US user spike", + "trigger_type": "user", + "trigger_filters": [ + { + "name": "location", + "value": "US", + "operator": "equals" + }, + { + "name": "net_worth_usd", + "value": "10000", + "operator": "greaterOrEqual", + "numericThreshold": "10000" + } + ], + "recipient": [ + { + "type": "email", + "value": [ + "team@myapp.com" + ] + } + ] + } + }, + "eventAlertWithNumericThreshold": { + "summary": "Alert when users hold >5 tokens in a specific app", + "value": { + "name": "High token holder alert", + "trigger_type": "event", + "trigger_filters": [ + { + "name": "apps", + "value": "uniswap", + "operator": "greater", + "numericThreshold": "5" + }, + { + "name": "chains", + "value": "1", + "operator": "greaterOrEqual", + "numericThreshold": "3" + } + ], + "recipient": [ + { + "type": "webhook", + "value": [ + "https://hooks.myapp.com/token-alerts" + ] + } + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Alert created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Alert" + } + } + } + } + } + } + }, + "/v0/alerts/{alertId}": { + "get": { + "operationId": "getAlert", + "summary": "Get alert", + "tags": [ + "Alerts" + ], + "parameters": [ + { + "name": "alertId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Alert details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Alert" + }, + "example": { + "id": "alrt_4f8e2c1a9b3d4e5f", + "project_id": "proj_abc123", + "name": "Daily revenue drop", + "trigger_type": "event", + "status": "active", + "trigger_filters": [ + { + "name": "event", + "operator": "equals", + "value": "transaction" + }, + { + "name": "revenue", + "operator": "less_than", + "value": "1000", + "numericThreshold": "sum" + } + ], + "recipient": [ + { + "type": "email", + "value": [ + "alerts@myapp.com" + ] + }, + { + "type": "slack", + "value": [ + "C0123456789" + ] + } + ], + "has_secret": false, + "created_at": "2026-04-12T09:32:18.000Z", + "updated_at": "2026-04-25T14:01:55.000Z" + } + } + } + } + }, + "x-required-scope": "alerts:read" + }, + "put": { + "operationId": "updateAlert", + "summary": "Update alert", + "tags": [ + "Alerts" + ], + "parameters": [ + { + "name": "alertId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "trigger_type": { + "type": "string", + "enum": [ + "event", + "user" + ] + }, + "trigger_filters": { + "type": "array", + "items": { + "type": "object" + } + }, + "recipient": { + "type": "array", + "items": { + "type": "object" + } + }, + "secret": { + "type": "string" + } + }, + "required": [ + "name", + "trigger_type", + "trigger_filters" + ] + }, + "examples": { + "updateRecipients": { + "summary": "Update alert recipients", + "value": { + "name": "Mobile swap alert (updated)", + "trigger_type": "event", + "trigger_filters": [ + { + "name": "type", + "value": "swap" + }, + { + "name": "device", + "value": "mobile" + } + ], + "recipient": [ + { + "type": "webhook", + "value": [ + "https://hooks.myapp.com/v2/alerts" + ] + }, + { + "type": "slack", + "value": [ + "#alerts|https://hooks.slack.com/services/T00/B00/new" + ] + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Alert updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Alert" + } + } + } + } + }, + "x-required-scope": "alerts:write" + }, + "delete": { + "operationId": "deleteAlert", + "summary": "Delete alert", + "tags": [ + "Alerts" + ], + "parameters": [ + { + "name": "alertId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Alert deleted", + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + } + } + }, + "x-required-scope": "alerts:write" + }, + "patch": { + "operationId": "toggleAlertStatus", + "summary": "Toggle alert status", + "tags": [ + "Alerts" + ], + "parameters": [ + { + "name": "alertId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + }, + "required": [ + "status" + ] + }, + "examples": { + "activate": { + "summary": "Activate alert", + "value": { + "status": "active" + } + }, + "deactivate": { + "summary": "Deactivate alert", + "value": { + "status": "inactive" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Alert status updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Alert" + } + } + } + } + }, + "x-required-scope": "alerts:write" + } + }, + "/v0/boards": { + "get": { + "operationId": "listBoards", + "summary": "List boards", + "tags": [ + "Boards" + ], + "responses": { + "200": { + "description": "List of boards", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Board" + } + }, + "example": { + "data": [ + { + "id": "brd_a1b2c3d4e5f6", + "project_id": "proj_abc123", + "title": "Revenue Dashboard", + "description": "Weekly revenue, conversion, and retention metrics.", + "enabled": false, + "created_at": "2026-03-04T11:22:08.000Z", + "updated_at": "2026-04-22T08:14:31.000Z" + }, + { + "id": "brd_g7h8i9j0k1l2", + "project_id": "proj_abc123", + "title": "Marketing Funnel", + "description": "Acquisition channel performance.", + "enabled": true, + "created_at": "2026-02-18T16:09:44.000Z", + "updated_at": "2026-04-19T10:55:02.000Z" + } + ], + "meta": { + "total": 2, + "limit": 50, + "offset": 0 + } + } + } + } + } + }, + "x-required-scope": "boards:read" + }, + "post": { + "operationId": "createBoard", + "summary": "Create board", + "description": "Create a new empty board.", + "tags": [ + "Boards" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "responses": { + "201": { + "description": "Board created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Board" + } + } + } + } + }, + "x-required-scope": "boards:write" + } + }, + "/v0/boards/{boardId}": { + "get": { + "operationId": "getBoard", + "summary": "Get board", + "tags": [ + "Boards" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Board details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Board" + }, + "example": { + "id": "brd_a1b2c3d4e5f6", + "project_id": "proj_abc123", + "title": "Revenue Dashboard", + "description": "Weekly revenue, conversion, and retention metrics.", + "enabled": false, + "created_at": "2026-03-04T11:22:08.000Z", + "updated_at": "2026-04-22T08:14:31.000Z" + } + } + } + } + }, + "x-required-scope": "boards:read" + }, + "patch": { + "operationId": "updateBoard", + "summary": "Update board", + "tags": [ + "Boards" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the board is publicly accessible" + } + } + }, + "examples": { + "updateTitle": { + "summary": "Update board title", + "value": { + "title": "Weekly Dashboard" + } + }, + "makePublic": { + "summary": "Make board publicly accessible", + "value": { + "isPublic": true + } + }, + "fullUpdate": { + "summary": "Update all fields", + "value": { + "title": "Revenue Dashboard", + "description": "Weekly revenue metrics and KPIs", + "isPublic": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Board updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Board" + } + } + } + } + }, + "x-required-scope": "boards:write", + "description": "Update board title, description, and/or public visibility." + }, + "delete": { + "operationId": "deleteBoard", + "summary": "Delete board", + "description": "Delete a board and all its charts.", + "tags": [ + "Boards" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Board deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Board" + } + } + } + } + }, + "x-required-scope": "boards:write" + } + }, + "/v0/boards/{boardId}/charts": { + "get": { + "operationId": "listCharts", + "summary": "List charts for a board", + "tags": [ + "Charts" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Charts with executed query results", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "charts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chart" + } + }, + "board": { + "$ref": "#/components/schemas/Board" + } + } + }, + "example": { + "data": [ + { + "id": "cht_x1y2z3a4b5c6", + "project_id": "proj_abc123", + "board_id": "brd_a1b2c3d4e5f6", + "chart_type": "line", + "title": "Daily revenue (last 30 days)", + "description": null, + "query": "SELECT toDate(timestamp) AS day, sum(revenue) AS revenue FROM events WHERE event = 'transaction' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY day ORDER BY day", + "x_axis": "day", + "y_axis": [ + "revenue" + ], + "group_by": null, + "steps": null, + "settings": null + } + ], + "meta": { + "total": 1, + "limit": 50, + "offset": 0 + } + } + } + } + } + }, + "x-required-scope": "boards:read" + }, + "post": { + "operationId": "createChart", + "summary": "Create chart", + "tags": [ + "Charts" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChartRequest" + }, + "examples": { + "funnelBasic": { + "summary": "3-step closed funnel (7-day window)", + "value": { + "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" + } + } + } + }, + "funnelWithPropertyFilters": { + "summary": "Funnel with per-step property filters (MetaMask on mainnet)", + "value": { + "projectId": "proj_abc", + "query": "SELECT 1", + "chart_type": "funnel", + "title": "MetaMask Conversion Funnel", + "steps": [ + { + "type": "event", + "event": "page" + }, + { + "type": "event", + "event": "connect", + "rdns": { + "op": "equals", + "value": "io.metamask" + }, + "chain_id": { + "op": "equals", + "value": "1" + } + }, + { + "type": "event", + "event": "transaction" + } + ], + "settings": { + "funnelType": "closed", + "conversionWindow": { + "value": 7, + "unit": "day" + } + } + } + }, + "funnelWithBreakdown": { + "summary": "Funnel with device breakdown", + "value": { + "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" + } + } + }, + "funnelOpenMultiValue": { + "summary": "Open funnel with multi-value `in` filter and breakdown", + "value": { + "projectId": "proj_abc", + "query": "SELECT 1", + "chart_type": "funnel", + "title": "Mobile Onboarding Funnel", + "steps": [ + { + "type": "event", + "event": "page", + "device": { + "op": "equals", + "value": "mobile" + } + }, + { + "type": "event", + "event": "connect", + "provider_name": { + "op": "in", + "value": "metamask|rainbow|coinbase" + } + }, + { + "type": "event", + "event": "transaction" + } + ], + "settings": { + "funnelType": "open", + "conversionWindow": { + "value": 30, + "unit": "day" + }, + "breakdown": "device" + } + } + }, + "barChart": { + "summary": "Daily active users — bar chart", + "value": { + "projectId": "proj_abc", + "query": "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date", + "chart_type": "bar", + "title": "Daily Active Users", + "x_axis": "date", + "y_axis": [ + "users" + ] + } + }, + "lineChart": { + "summary": "DAU last 30 days — line chart", + "value": { + "projectId": "proj_abc", + "query": "SELECT toDate(timestamp) AS date, countDistinct(address) AS daily_active_users FROM events GROUP BY date ORDER BY date DESC LIMIT 30", + "chart_type": "line", + "title": "Daily Active Users", + "x_axis": "date", + "y_axis": [ + "daily_active_users" + ] + } + }, + "pieChart": { + "summary": "Sessions by device — pie chart", + "value": { + "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" + ] + } + }, + "stackedChart": { + "summary": "Sessions by device grouped by browser — stacked chart", + "value": { + "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" + } + }, + "numberChart": { + "summary": "Total connected wallets — number / KPI card", + "value": { + "projectId": "proj_abc", + "query": "SELECT COUNT(DISTINCT address) FROM events WHERE type = 'connect'", + "chart_type": "number", + "title": "Total Connected Wallets" + } + }, + "tableChart": { + "summary": "Recent events — table", + "value": { + "projectId": "proj_abc", + "query": "SELECT * FROM events ORDER BY timestamp DESC LIMIT 10", + "chart_type": "table", + "title": "Recent Events" + } + }, + "userPathsChart": { + "summary": "User flow from connect — max 5 steps", + "value": { + "projectId": "proj_abc", + "query": "SELECT anonymous_id, type AS event, timestamp FROM events WHERE timestamp >= today() - INTERVAL 30 DAY ORDER BY anonymous_id, timestamp", + "chart_type": "user_paths", + "title": "Post-Connect User Flow", + "settings": { + "startStep": { + "type": "event", + "event": "connect" + }, + "endStep": { + "type": "event", + "event": "transaction" + }, + "maxSteps": 5, + "conversionWindow": { + "value": 2, + "unit": "week" + } + } + } + }, + "userPathsOpenEnded": { + "summary": "Open-ended user flow from page view", + "value": { + "projectId": "proj_abc", + "query": "SELECT anonymous_id, type AS event, timestamp FROM events WHERE timestamp >= today() - INTERVAL 14 DAY ORDER BY anonymous_id, timestamp", + "chart_type": "user_paths", + "title": "User Discovery Paths", + "settings": { + "startStep": { + "type": "event", + "event": "page" + }, + "maxSteps": 8, + "nodesPerStep": 10 + } + } + }, + "retentionFiltered": { + "summary": "Weekly retention — desktop users, transaction event", + "value": { + "projectId": "proj_abc", + "query": "", + "chart_type": "retention", + "title": "Weekly Retention — Desktop", + "settings": { + "retentionFilter": { + "type": "event", + "event": "transaction" + }, + "retentionUserFilters": [ + { + "field": "device", + "op": "equals", + "value": "desktop" + } + ] + } + } + }, + "retentionUnfiltered": { + "summary": "Overall retention (no filters)", + "value": { + "projectId": "proj_abc", + "query": "", + "chart_type": "retention", + "title": "Overall Retention" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Chart created", + "content": { + "application/json": { + "schema": { + "type": "string", + "description": "Chart ID" + } + } + } + } + }, + "x-required-scope": "boards:write" + } + }, + "/v0/boards/{boardId}/charts/{chartId}": { + "get": { + "operationId": "getChart", + "summary": "Get chart", + "tags": [ + "Charts" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "chartId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Chart details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chart" + }, + "example": { + "id": "cht_x1y2z3a4b5c6", + "project_id": "proj_abc123", + "board_id": "brd_a1b2c3d4e5f6", + "chart_type": "line", + "title": "Daily revenue (last 30 days)", + "description": null, + "query": "SELECT toDate(timestamp) AS day, sum(revenue) AS revenue FROM events WHERE event = 'transaction' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY day ORDER BY day", + "x_axis": "day", + "y_axis": [ + "revenue" + ], + "group_by": null, + "steps": null, + "settings": null + } + } + } + } + }, + "x-required-scope": "boards:read" + }, + "put": { + "operationId": "editChart", + "summary": "Edit chart", + "tags": [ + "Charts" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "chartId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateChartRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Chart updated", + "content": { + "application/json": { + "schema": { + "type": "string", + "description": "Chart ID" + } + } + } + } + }, + "x-required-scope": "boards:write" + }, + "delete": { + "operationId": "deleteChart", + "summary": "Delete chart", + "tags": [ + "Charts" + ], + "parameters": [ + { + "name": "boardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "chartId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Chart deleted" + } + }, + "x-required-scope": "boards:write" + } + }, + "/v0/contracts": { + "get": { + "operationId": "listContracts", + "summary": "List contracts", + "tags": [ + "Contracts" + ], + "responses": { + "200": { + "description": "Contracts with deployment diff info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "contracts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contract" + } + }, + "deployAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deployDiff": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "example": { + "data": [ + { + "name": "USD Coin", + "chain": 1, + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "start_block": 6082465, + "abi": "[{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"}]", + "events": [ + { + "anonymous": false, + "name": "Transfer", + "type": "event", + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ] + } + ] + }, + { + "name": "WETH (Base)", + "chain": 8453, + "address": "0x4200000000000000000000000000000000000006", + "start_block": 1, + "abi": "[{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"}]", + "events": [ + { + "anonymous": false, + "name": "Transfer", + "type": "event", + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ] + } + ] + } + ], + "meta": { + "total": 2, + "limit": 50, + "offset": 0 + } + } + } + } + } + }, + "x-required-scope": "contracts:read" + }, + "post": { + "operationId": "createContract", + "summary": "Create contract", + "description": "Add a blockchain contract to monitor.", + "tags": [ + "Contracts" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "EVM contract address" + }, + "chain": { + "type": "integer", + "description": "Chain ID" + }, + "name": { + "type": "string" + }, + "abi": { + "type": "string", + "description": "JSON-stringified ABI" + }, + "events": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object", + "properties": { + "anonymous": { + "type": "boolean" + }, + "inputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "anonymous", + "inputs", + "name", + "type" + ] + } + }, + "start_block": { + "type": "integer" + } + }, + "required": [ + "address", + "chain", + "name", + "abi", + "events" + ] + }, + "examples": { + "erc20Token": { + "summary": "Monitor ERC-20 Transfer events", + "value": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chain": 1, + "name": "USDC", + "abi": "[{\"anonymous\":false,\"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\"}],\"name\":\"Transfer\",\"type\":\"event\"}]", + "events": [ + { + "anonymous": false, + "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" + } + ], + "name": "Transfer", + "type": "event" + } + ], + "start_block": 18000000 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Contract created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contract" + } + } + } + } + }, + "x-required-scope": "contracts:write" + } + }, + "/v0/contracts/{chain}/{address}": { + "put": { + "operationId": "updateContract", + "summary": "Update contract", + "tags": [ + "Contracts" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "chain": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "abi": { + "type": "string" + }, + "events": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object" + } + }, + "start_block": { + "type": "integer" + } + }, + "required": [ + "address", + "chain", + "name", + "abi", + "events" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Contract updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contract" + } + } + } + } + }, + "x-required-scope": "contracts:write" + }, + "delete": { + "operationId": "deleteContract", + "summary": "Delete contract", + "tags": [ + "Contracts" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Contract deleted", + "content": { + "application/json": { + "schema": { + "type": "null" + } + } + } + } + }, + "x-required-scope": "contracts:write" + } + }, + "/v0/segments": { + "get": { + "operationId": "listSegments", + "summary": "List segments", + "tags": [ + "Segments" + ], + "responses": { + "200": { + "description": "List of segments", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Segment" + } + }, + "example": { + "data": [ + { + "id": "seg_e3f4g5h6i7j8", + "projectId": "proj_abc123", + "title": "High net worth desktop users", + "filterSet": [ + "{\"field\":\"device\",\"op\":\"equals\",\"value\":\"desktop\"}", + "{\"field\":\"net_worth_usd\",\"op\":\"greaterOrEqual\",\"value\":100000}" + ] + } + ], + "meta": { + "total": 1, + "limit": 50, + "offset": 0 + } + } + } + } + } + }, + "x-required-scope": "segments:read" + }, + "post": { + "operationId": "createSegment", + "summary": "Create segment", + "tags": [ + "Segments" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "filterSets": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": [ + "title", + "filterSets" + ] + }, + "examples": { + "highValueMobileUsers": { + "summary": "High-value mobile users from paid campaigns", + "value": { + "title": "High-value mobile users", + "filterSets": [ + "device::equals::mobile", + "net_worth_usd::greaterOrEqual::10000", + "utm_source::equals::paid_ads" + ] + } + }, + "multiDeviceChromeSafari": { + "summary": "Chrome or Safari users (multi-value filter)", + "value": { + "title": "Chrome/Safari users", + "filterSets": [ + "browser::in::Chrome|Safari" + ] + } + }, + "excludeUSUsers": { + "summary": "All users except from the US", + "value": { + "title": "Non-US users", + "filterSets": [ + "location::notEquals::US" + ] + } + }, + "powerUsers": { + "summary": "Power users with high net worth from organic traffic", + "value": { + "title": "Organic power users", + "filterSets": [ + "net_worth_usd::greater::1000", + "utm_source::notIn::google_ads|facebook_ads", + "lifecycle::equals::power" + ] + } + }, + "multiChainWhaleUsers": { + "summary": "Users active on 3+ chains with high net worth", + "value": { + "title": "Multi-chain whales", + "filterSets": [ + "chains::greaterOrEqual::3", + "net_worth_usd::greater::100000", + "apps::greater::5" + ] + } + }, + "behavioralFilterSimple": { + "summary": "Users who performed 'signature' event at least once in last 30 days", + "description": "Behavioural filters use the 'events' filter key with a base64-encoded JSON array as the value. The JSON contains event name, frequency operator (greater/greaterOrEqual/equals/lessOrEqual/less), times count, and date range.", + "value": { + "title": "Recent signers", + "filterSets": [ + "events::equals::W3siZXZlbnQiOiAic2lnbmF0dXJlIiwgIm9wZXJhdG9yIjogImdyZWF0ZXJPckVxdWFsIiwgInRpbWVzIjogMSwgInByZXNldCI6ICJsYXN0XzMwZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0wOS0xOSIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifV0=" + ] + } + }, + "behavioralFilterWithProperties": { + "summary": "Users who connected via MetaMask on Ethereum mainnet + from India", + "description": "Combines a behavioural filter (type=connect event with property filters) and a demographic filter (location). The 'event' field uses the event type key (e.g. 'connect', 'signature', 'transaction', 'page'), not the display label. Event property filters use {op, value} objects as additional keys.", + "value": { + "title": "Indian MetaMask users", + "filterSets": [ + "events::equals::W3siZXZlbnQiOiAiY29ubmVjdCIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5IiwgInJkbnMiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICJpby5tZXRhbWFzayJ9LCAiY2hhaW5faWQiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICIxIn19XQ==", + "location::equals::IN" + ] + } + }, + "behavioralFilterMultipleEvents": { + "summary": "Users with 5+ page views last week AND at least 1 transaction last month", + "description": "Multiple behavioural events in a single filter. All conditions in the array must be met (AND logic). Valid event types: page, screen, connect, disconnect, chain, signature, transaction, track, decoded_log, detect, identify.", + "value": { + "title": "Active transactors", + "filterSets": [ + "events::equals::W3siZXZlbnQiOiAicGFnZSIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDUsICJwcmVzZXQiOiAibGFzdF83ZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0xMC0xMiIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifSwgeyJldmVudCI6ICJ0cmFuc2FjdGlvbiIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5In1d", + "device::equals::desktop" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Segment created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Segment" + } + } + } + } + }, + "x-required-scope": "segments:write" + } + }, + "/v0/segments/{segmentId}": { + "delete": { + "operationId": "deleteSegment", + "summary": "Delete segment", + "tags": [ + "Segments" + ], + "parameters": [ + { + "name": "segmentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Segment deleted", + "content": { + "application/json": { + "schema": { + "type": "null" + } + } + } + } + }, + "x-required-scope": "segments:write" + } + }, + "/v0/profiles": { + "get": { + "operationId": "searchProfiles", + "summary": "Search wallet profiles", + "tags": [ + "Profiles" + ], + "responses": { + "200": { + "description": "Paginated wallet profile search results.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "total", + "page", + "size", + "has_more" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Profile" + } + }, + "total": { + "type": "integer", + "description": "Total number of profiles across all pages." + }, + "page": { + "type": "integer", + "description": "1-indexed page number echoed from the request." + }, + "size": { + "type": "integer", + "description": "Page size echoed from the request." + }, + "has_more": { + "type": "boolean", + "description": "True if there is at least one more page after this one." + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + }, + "x-required-scope": "profiles:read", + "parameters": [ + { + "name": "address", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by wallet address" + }, + { + "name": "expand", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Comma-separated: apps, chains, tokens, labels" + }, + { + "name": "order_by", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "net_worth_usd", + "tx_count", + "first_onchain", + "last_onchain", + "updated_at", + "first_seen", + "last_seen", + "num_sessions", + "revenue", + "volume", + "points" + ] + }, + "description": "Sort field" + }, + { + "name": "order_dir", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "description": "Sort direction" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "description": "1-indexed page number (default 1)." + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "description": "Page size (default 100, max 1000)." + } + ], + "requestBody": { + "required": false, + "description": "Optional filter conditions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileFilter" + }, + "examples": { + "highNetWorth": { + "summary": "Users with >$10k net worth", + "value": { + "conditions": [ + { + "field": "users.net_worth_usd", + "op": "gt", + "value": 10000 + } + ], + "logic": "and" + } + }, + "chainFilter": { + "summary": "Users active on Ethereum with >$1k balance", + "value": { + "conditions": [ + { + "field": "chains.1.balance", + "op": "gt", + "value": 1000 + } + ], + "logic": "and" + } + }, + "labelFilter": { + "summary": "Coinbase verified users", + "value": { + "conditions": [ + { + "field": "labels.coinbase.verified_account", + "op": "eq", + "value": "true" + } + ], + "logic": "and" + } + }, + "tokenFilter": { + "summary": "Users holding USDC in any protocol", + "value": { + "conditions": [ + { + "field": "tokens.0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.balance", + "op": "gt", + "value": 0, + "scope": "any" + } + ], + "logic": "and" + } + } + } + } + } + } + } + }, + "/v0/profiles/{address}": { + "get": { + "operationId": "getProfile", + "summary": "Get wallet profile", + "tags": [ + "Profiles" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Wallet address" + }, + { + "name": "expand", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Comma-separated: apps, chains, tokens, labels" + } + ], + "responses": { + "200": { + "description": "Profile details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Profile" + }, + "example": { + "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "ens": "vitalik.eth", + "display_name": "vitalik.eth", + "avatar": "https://euc.li/vitalik.eth", + "description": "mi pinxe lo crino tcati", + "location": "Earth", + "net_worth_usd": 1581720.97, + "tx_count": 18743, + "first_onchain": "2015-09-28T08:24:43.000Z", + "last_onchain": "2026-04-01T03:34:47.000Z", + "twitter": "vitalikbuterin", + "farcaster": "vitalik.eth", + "lens": "vitalik.lens", + "basenames": "vb62831.base.eth", + "linea": null, + "github": "vbuterin", + "reddit": null, + "linkedin": null, + "telegram": null, + "discord": null, + "email": null, + "website": "vitalik.ca", + "youtube": null, + "tiktok": null, + "instagram": null, + "facebook": null, + "first_seen": "2025-09-14T03:11:42.000Z", + "last_seen": "2026-04-26T18:09:53.000Z", + "lifecycle": "Returning", + "num_sessions": 12, + "revenue": 0, + "volume": 482.55, + "points": 0, + "device": "desktop", + "browser": "brave", + "os": "macOS", + "first_utm_source": "twitter", + "first_utm_medium": "social", + "first_utm_campaign": "devcon-launch", + "first_utm_content": null, + "first_utm_term": null, + "first_referrer": "twitter.com", + "first_referrer_url": "https://twitter.com/vitalikbuterin/status/1234567890", + "first_ref": null, + "last_utm_source": "direct", + "last_utm_medium": null, + "last_utm_campaign": null, + "last_utm_content": null, + "last_utm_term": null, + "last_referrer": null, + "last_referrer_url": null, + "last_ref": null, + "last_type": "track", + "last_event": "Swap Confirmed", + "last_properties": "{\"chain_id\":1,\"volume\":482.55}", + "activity_dates": [ + "2026-04-22", + "2026-04-23", + "2026-04-25", + "2026-04-26" + ], + "chains": [ + { + "chain_id": "1", + "net_worth_usd": 1491971.89, + "tx_count": 1655, + "first_onchain": "2015-09-28T08:24:43.000Z", + "last_onchain": "2026-04-01T03:34:47.000Z" + }, + { + "chain_id": "8453", + "net_worth_usd": 41871.88, + "tx_count": 16, + "first_onchain": "2023-07-30T12:40:39.000Z", + "last_onchain": "2026-02-10T22:48:51.000Z" + }, + { + "chain_id": "56", + "net_worth_usd": 21108.72, + "tx_count": 8, + "first_onchain": "2022-10-21T13:52:11.000Z", + "last_onchain": "2025-12-20T16:00:26.000Z" + }, + { + "chain_id": "10", + "net_worth_usd": 16668.39, + "tx_count": 40, + "first_onchain": "2021-12-17T15:15:45.000Z", + "last_onchain": "2026-01-13T07:42:51.000Z" + } + ], + "apps": [ + { + "chain_id": "1", + "id": "uniswap-v3", + "name": "Uniswap V3", + "img": "https://cdn.formo.so/apps/uniswap.png", + "url": "https://app.uniswap.org", + "balance_usd": 124820.41 + }, + { + "chain_id": "1", + "id": "aave-v3", + "name": "Aave V3", + "img": "https://cdn.formo.so/apps/aave.png", + "url": "https://app.aave.com", + "balance_usd": 88421.07 + } + ], + "tokens": [ + { + "chain_id": "1", + "token_address": "0x0000000000000000000000000000000000000000", + "app_id": "", + "name": "Ethereum", + "symbol": "ETH", + "img": "https://cdn.formo.so/tokens/eth.png", + "decimals": 18, + "price": 3290.42, + "balance": "184.732910421054811234", + "balance_usd": 607823.55 + }, + { + "chain_id": "1", + "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "app_id": "", + "name": "USD Coin", + "symbol": "USDC", + "img": "https://cdn.formo.so/tokens/usdc.png", + "decimals": 6, + "price": 1, + "balance": "125804.520000", + "balance_usd": 125804.52 + } + ], + "labels": [ + { + "id": "ethereum.founder", + "value": "", + "chain_id": "-", + "source": "system" + }, + { + "id": "whale", + "value": "", + "chain_id": "-", + "source": "formo" + }, + { + "id": "coinbase.verified_account", + "value": "true", + "chain_id": "1", + "source": "coinbase" + } + ], + "updated_at": "2026-04-27T01:00:00.000Z" + } + } + } + }, + "404": { + "description": "Profile not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + }, + "x-required-scope": "profiles:read" + } + }, + "/v0/profiles/{address}/properties": { + "put": { + "operationId": "updateUserProperties", + "summary": "Update user properties", + "description": "Set first-party properties for a wallet profile. Override display name, email, socials, avatar, location, and other identity fields.", + "tags": [ + "Profiles" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Wallet address (EVM 0x... or Solana base58)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Merge-update of profile properties. Only the listed keys are accepted; unknown keys are rejected. At least one key must be provided.", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "farcaster": { + "type": "string" + }, + "discord": { + "type": "string" + }, + "twitter": { + "type": "string" + }, + "telegram": { + "type": "string" + }, + "instagram": { + "type": "string" + }, + "website": { + "type": "string" + }, + "github": { + "type": "string" + }, + "linkedin": { + "type": "string" + }, + "facebook": { + "type": "string" + }, + "tiktok": { + "type": "string" + }, + "youtube": { + "type": "string" + }, + "reddit": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "string" + }, + "ens": { + "type": "string" + }, + "lens": { + "type": "string" + }, + "basenames": { + "type": "string" + }, + "linea": { + "type": "string" + } + } + }, + "examples": { + "updateProperties": { + "summary": "Set display name and email", + "value": { + "display_name": "alice.eth", + "email": "alice@example.com", + "twitter": "alice" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Properties updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Invalid request (bad address, empty body, or no allowed keys)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "API key lacks the required scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "404": { + "description": "Project not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + }, + "x-required-scope": "profiles:write" + } + }, + "/v0/profiles/{address}/labels": { + "post": { + "operationId": "upsertUserLabel", + "summary": "Add or update user labels", + "description": "Upsert one or more labels for a wallet. Accepts either a single label object or an array of labels. Labels with the same tag_id (and chain_id, if provided) are overwritten. Requires profiles:write scope.", + "tags": [ + "Profiles" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Wallet address (EVM 0x... or Solana base58)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserLabelInput" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserLabelInput" + }, + "minItems": 1 + } + ] + }, + "examples": { + "singleLabel": { + "summary": "Upsert a single label", + "value": { + "tag_id": "vip", + "value": "tier-1" + } + }, + "multipleLabels": { + "summary": "Upsert multiple labels", + "value": [ + { + "tag_id": "vip", + "value": "tier-1" + }, + { + "tag_id": "airdrop_eligible", + "value": "season-2", + "chain_id": "1" + } + ] + } + } + } + } + }, + "responses": { + "200": { + "description": "Labels upserted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Invalid request (bad address, missing tag_id, etc.)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "API key lacks the required scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "404": { + "description": "Project not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + }, + "x-required-scope": "profiles:write" + }, + "delete": { + "operationId": "deleteUserLabel", + "summary": "Delete a user label", + "description": "Delete a label from a wallet. Pass chain_id to scope the deletion to a specific chain; omit it to match labels without a chain scope. Requires profiles:write scope.", + "tags": [ + "Profiles" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Wallet address (EVM 0x... or Solana base58)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "tag_id" + ], + "properties": { + "tag_id": { + "type": "string", + "description": "Label identifier to delete" + }, + "chain_id": { + "type": "string", + "description": "Optional chain identifier to scope the deletion" + } + } + }, + "examples": { + "deleteLabel": { + "summary": "Delete a label", + "value": { + "tag_id": "vip" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Label deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Invalid request (bad address or missing tag_id)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "API key lacks the required scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "404": { + "description": "Project not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + }, + "x-required-scope": "profiles:write" + } + }, + "/v0/import": { + "post": { + "operationId": "importWallets", + "summary": "Import wallet addresses", + "description": "Import wallet addresses into the project. Requires profiles:write scope and Scale/Enterprise plan.", + "tags": [ + "Profiles" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Wallet addresses to import" + }, + "writeKey": { + "type": "string", + "description": "SDK write key" + } + }, + "required": [ + "addresses", + "writeKey" + ] + }, + "examples": { + "importWallets": { + "summary": "Import wallet addresses", + "value": { + "addresses": [ + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B" + ], + "writeKey": "sk_write_xxxxx" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Wallets imported successfully" + } + }, + "x-required-scope": "profiles:write" + } + }, + "/v0/query": { + "post": { + "operationId": "executeQuery", + "summary": "Execute SQL query", + "description": "Execute a SQL query against the project's analytics data. Only SELECT and WITH statements are allowed. LIMIT is capped at 1,000,000. Forbidden keywords: INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, etc.", + "tags": [ + "Query" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "SQL query to execute" + } + }, + "required": [ + "query" + ] + }, + "examples": { + "dailyActiveUsers": { + "summary": "Daily active users over last 30 days", + "value": { + "query": "SELECT toDate(timestamp) as date, uniq(anonymous_id) as dau FROM events WHERE timestamp >= now() - interval 30 day GROUP BY date ORDER BY date" + } + }, + "topEvents": { + "summary": "Top 10 events by count", + "value": { + "query": "SELECT event, count() as total FROM events WHERE timestamp >= now() - interval 7 day GROUP BY event ORDER BY total DESC LIMIT 10" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Offset-paginated query results. The server doesn't own pagination here — `LIMIT` and `OFFSET` come from your SQL string and are echoed back. `total` is the row count before LIMIT was applied (Tinybird's `rows_before_limit_at_least`); `has_more` is true when there are additional rows beyond the current window.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "total", + "limit", + "offset", + "has_more" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Result rows." + }, + "total": { + "type": "integer", + "description": "Total rows before LIMIT was applied." + }, + "limit": { + "type": "integer", + "description": "Applied LIMIT (parsed from your SQL; defaults to the server cap if absent)." + }, + "offset": { + "type": "integer", + "description": "Applied OFFSET (parsed from your SQL; 0 if absent)." + }, + "has_more": { + "type": "boolean", + "description": "True when `offset + data.length < total` — i.e. there's another page to fetch by re-running with a higher OFFSET." + } + } + }, + "example": { + "data": [ + { + "day": "2026-04-21", + "users": 1284 + }, + { + "day": "2026-04-22", + "users": 1352 + }, + { + "day": "2026-04-23", + "users": 1411 + } + ], + "total": 7, + "limit": 100, + "offset": 0, + "has_more": false + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + }, + "x-required-scope": "query:read" + } + }, + "/v0/kpis": { + "get": { + "operationId": "getAnalyticsKpis", + "summary": "Get KPIs (visitors, pageviews, bounce rate, session duration)", + "description": "Time-series traffic KPIs with optional dimension breakdown. Returns visitors, pageviews, bounce rate and average session length.", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "name": "group_by", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "referrer", + "location", + "device", + "browser", + "os", + "utm_source", + "utm_medium", + "utm_campaign" + ] + }, + "description": "Dimension to break down by" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "include_previous_period", + "in": "query", + "schema": { + "type": "boolean" + }, + "description": "Return current and previous period for WoW comparison" + } + ], + "responses": { + "200": { + "description": "KPI time-series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "date", + "type": "Date" + }, + { + "name": "project_id", + "type": "String" + }, + { + "name": "visitors", + "type": "UInt64" + }, + { + "name": "pageviews", + "type": "UInt64" + }, + { + "name": "bounce_rate", + "type": "Float64" + }, + { + "name": "avg_session_sec", + "type": "Float64" + } + ], + "data": [ + { + "date": "2025-10-15", + "project_id": "proj_abc", + "visitors": 412, + "pageviews": 1187, + "bounce_rate": 0.31, + "avg_session_sec": 142.6 + }, + { + "date": "2025-10-16", + "project_id": "proj_abc", + "visitors": 487, + "pageviews": 1402, + "bounce_rate": 0.28, + "avg_session_sec": 156.2 + }, + { + "date": "2025-10-17", + "project_id": "proj_abc", + "visitors": 533, + "pageviews": 1610, + "bounce_rate": 0.26, + "avg_session_sec": 168.4 + } + ], + "rows": 3, + "rows_before_limit_at_least": 3, + "statistics": { + "elapsed": 0.041, + "rows_read": 12340, + "bytes_read": 482310 + } + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/user_lifecycle": { + "get": { + "operationId": "getAnalyticsUserLifecycle", + "summary": "Get user lifecycle stages (New / Returning / Power / Resurrected / Churned)", + "description": "Counts wallet users by lifecycle stage based on activity within the date range. The reference date is `date_to`.", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "name": "lifecycle_filter", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "New", + "Returning", + "Power user", + "Resurrected", + "Churned" + ] + } + }, + { + "name": "include_previous_period", + "in": "query", + "schema": { + "type": "boolean" + }, + "description": "Return current and previous period for WoW comparison" + } + ], + "responses": { + "200": { + "description": "Lifecycle counts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "user_type", + "type": "String" + }, + { + "name": "user_count", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "user_type": "New", + "user_count": 184 + }, + { + "project_id": "proj_abc", + "user_type": "Returning", + "user_count": 76 + }, + { + "project_id": "proj_abc", + "user_type": "Power user", + "user_count": 23 + }, + { + "project_id": "proj_abc", + "user_type": "Resurrected", + "user_count": 11 + }, + { + "project_id": "proj_abc", + "user_type": "Churned", + "user_count": 142 + } + ], + "rows": 5, + "rows_before_limit_at_least": 5 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/revenue_overview": { + "get": { + "operationId": "getAnalyticsRevenueOverview", + "summary": "Get revenue and transaction volume time-series", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "name": "group_by", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "rank_by", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "include_previous_period", + "in": "query", + "schema": { + "type": "boolean" + }, + "description": "Return current and previous period for WoW comparison" + } + ], + "responses": { + "200": { + "description": "Revenue and volume time-series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "date", + "type": "Date" + }, + { + "name": "project_id", + "type": "String" + }, + { + "name": "revenue", + "type": "Float32" + }, + { + "name": "volume", + "type": "Float32" + } + ], + "data": [ + { + "date": "2025-10-15", + "project_id": "proj_abc", + "revenue": 1284.55, + "volume": 412800 + }, + { + "date": "2025-10-16", + "project_id": "proj_abc", + "revenue": 1567.2, + "volume": 506100 + }, + { + "date": "2025-10-17", + "project_id": "proj_abc", + "revenue": 1893.75, + "volume": 612400 + } + ], + "rows": 3, + "rows_before_limit_at_least": 3 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/revenue_timeseries": { + "get": { + "operationId": "getAnalyticsRevenueTimeseries", + "summary": "Get revenue trend over time", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + } + ], + "responses": { + "200": { + "description": "Revenue time-series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "date", + "type": "Date" + }, + { + "name": "event", + "type": "String" + }, + { + "name": "page_path", + "type": "String" + }, + { + "name": "referrer", + "type": "String" + }, + { + "name": "utm_source", + "type": "String" + }, + { + "name": "revenue", + "type": "Float32" + }, + { + "name": "volume", + "type": "Float32" + } + ], + "data": [ + { + "date": "2025-10-15", + "event": "swap", + "page_path": "/trade", + "referrer": "google.com", + "utm_source": "organic", + "revenue": 215.3, + "volume": 68000 + }, + { + "date": "2025-10-15", + "event": "stake", + "page_path": "/earn", + "referrer": "", + "utm_source": "", + "revenue": 42.1, + "volume": 12500 + }, + { + "date": "2025-10-16", + "event": "swap", + "page_path": "/trade", + "referrer": "twitter.com", + "utm_source": "twitter", + "revenue": 318.75, + "volume": 102400 + } + ], + "rows": 3, + "rows_before_limit_at_least": 3 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/event_timeseries": { + "get": { + "operationId": "getAnalyticsEventTimeseries", + "summary": "Get event count time-series", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + } + ], + "responses": { + "200": { + "description": "Event time-series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "date", + "type": "Date" + }, + { + "name": "event_key", + "type": "String" + }, + { + "name": "count", + "type": "UInt64" + } + ], + "data": [ + { + "date": "2025-10-15", + "event_key": "page", + "count": 1187 + }, + { + "date": "2025-10-15", + "event_key": "wallet_connect", + "count": 124 + }, + { + "date": "2025-10-15", + "event_key": "swap", + "count": 38 + }, + { + "date": "2025-10-15", + "event_key": "identify", + "count": 412 + } + ], + "rows": 4, + "rows_before_limit_at_least": 4 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/retention": { + "get": { + "operationId": "getAnalyticsRetention", + "summary": "Get user retention cohorts", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + } + ], + "responses": { + "200": { + "description": "Retention cohorts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "metric", + "type": "Tuple(project_id String, user_count UInt64, day7_retained UInt64, day30_retained UInt64, day90_retained UInt64, day7_retention_rate Float64, day30_retention_rate Float64, day90_retention_rate Float64)" + }, + { + "name": "user", + "type": "Array(Tuple(project_id String, cohort_week Date, num_users UInt64, week_0 Float64, week_1 Float64, week_2 Float64, week_3 Float64, week_4 Float64, week_5 Float64, week_6 Float64, week_7 Float64, week_8 Float64, week_9 Float64, week_10 Float64, week_11 Float64, week_12 Float64))" + } + ], + "data": [ + { + "metric": [ + "proj_abc", + 1284, + 412, + 198, + 76, + 0.32, + 0.154, + 0.059 + ], + "user": [ + [ + "proj_abc", + "2025-07-28", + 142, + 100, + 48, + 32, + 22, + 18, + 14, + 12, + 11, + 9, + 8, + 7, + 6, + null, + null + ], + [ + "proj_abc", + "2025-08-04", + 178, + 100, + 52, + 36, + 26, + 20, + 16, + 14, + 12, + 10, + 9, + 8, + null, + null, + null + ], + [ + "proj_abc", + "2025-08-11", + 165, + 100, + 50, + 34, + 24, + 19, + 15, + 13, + 11, + null, + null, + null, + null, + null, + null + ] + ] + } + ], + "rows": 1, + "rows_before_limit_at_least": 1 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/user_frequency": { + "get": { + "operationId": "getAnalyticsUserFrequency", + "summary": "Get visit-frequency distribution", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + } + ], + "responses": { + "200": { + "description": "Visit frequency distribution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "session_bucket", + "type": "String" + }, + { + "name": "user_count", + "type": "UInt64" + }, + { + "name": "avg_session_per_user", + "type": "Float64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "session_bucket": "1 session", + "user_count": 412, + "avg_session_per_user": 1 + }, + { + "project_id": "proj_abc", + "session_bucket": "2 - 10 sessions", + "user_count": 287, + "avg_session_per_user": 4.6 + }, + { + "project_id": "proj_abc", + "session_bucket": "10 - 30 sessions", + "user_count": 76, + "avg_session_per_user": 16.2 + }, + { + "project_id": "proj_abc", + "session_bucket": "30 - 50 sessions", + "user_count": 18, + "avg_session_per_user": 38.4 + }, + { + "project_id": "proj_abc", + "session_bucket": "> 50 sessions", + "user_count": 5, + "avg_session_per_user": 84 + } + ], + "rows": 5, + "rows_before_limit_at_least": 5 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/top_pages": { + "get": { + "operationId": "getAnalyticsTopPages", + "summary": "Get top pages by visits or users", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Top pages", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "origin", + "type": "String" + }, + { + "name": "pathname", + "type": "String" + }, + { + "name": "visits", + "type": "UInt64" + }, + { + "name": "users", + "type": "UInt64" + }, + { + "name": "hits", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "origin": "app.example.com", + "pathname": "/", + "visits": 1284, + "users": 982, + "hits": 1456 + }, + { + "project_id": "proj_abc", + "origin": "app.example.com", + "pathname": "/trade", + "visits": 612, + "users": 487, + "hits": 1024 + }, + { + "project_id": "proj_abc", + "origin": "app.example.com", + "pathname": "/earn", + "visits": 412, + "users": 318, + "hits": 587 + } + ], + "rows": 3, + "rows_before_limit_at_least": 24 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/top_sources": { + "get": { + "operationId": "getAnalyticsTopSources", + "summary": "Get top traffic sources (referrers / utm)", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "name": "metric_column", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "referrer", + "referrer_url", + "ref", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "origin", + "device", + "browser", + "os", + "channel" + ] + }, + "description": "Column to break down sources by. Use `channel` for the 12-channel acquisition classifier (see docs/channels.md)." + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Top sources", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "referrer", + "type": "String" + }, + { + "name": "visits", + "type": "UInt64" + }, + { + "name": "users", + "type": "UInt64" + }, + { + "name": "hits", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "referrer": "Direct", + "visits": 612, + "users": 487, + "hits": 1024 + }, + { + "project_id": "proj_abc", + "referrer": "google.com", + "visits": 312, + "users": 268, + "hits": 542 + }, + { + "project_id": "proj_abc", + "referrer": "twitter.com", + "visits": 184, + "users": 154, + "hits": 312 + }, + { + "project_id": "proj_abc", + "referrer": "farcaster.xyz", + "visits": 98, + "users": 86, + "hits": 167 + } + ], + "rows": 4, + "rows_before_limit_at_least": 18 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/top_locations": { + "get": { + "operationId": "getAnalyticsTopLocations", + "summary": "Get top countries / regions / cities", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Top locations", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "location", + "type": "String" + }, + { + "name": "visits", + "type": "UInt64" + }, + { + "name": "users", + "type": "UInt64" + }, + { + "name": "hits", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "location": "US", + "visits": 482, + "users": 387, + "hits": 824 + }, + { + "project_id": "proj_abc", + "location": "GB", + "visits": 184, + "users": 156, + "hits": 312 + }, + { + "project_id": "proj_abc", + "location": "DE", + "visits": 142, + "users": 124, + "hits": 268 + }, + { + "project_id": "proj_abc", + "location": "JP", + "visits": 98, + "users": 86, + "hits": 187 + } + ], + "rows": 4, + "rows_before_limit_at_least": 32 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/top_wallets": { + "get": { + "operationId": "getAnalyticsTopWallets", + "summary": "Get top wallet types", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Top wallets", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "rdns", + "type": "String" + }, + { + "name": "visits", + "type": "UInt64" + }, + { + "name": "users", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "rdns": "io.metamask", + "visits": 412, + "users": 318 + }, + { + "project_id": "proj_abc", + "rdns": "com.coinbase.wallet", + "visits": 184, + "users": 142 + }, + { + "project_id": "proj_abc", + "rdns": "me.rainbow", + "visits": 98, + "users": 76 + }, + { + "project_id": "proj_abc", + "rdns": "app.phantom", + "visits": 87, + "users": 64 + } + ], + "rows": 4, + "rows_before_limit_at_least": 12 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/top_chains": { + "get": { + "operationId": "getAnalyticsTopChains", + "summary": "Get top blockchain chains", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Top chains", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "chain_id", + "type": "String" + }, + { + "name": "visits", + "type": "UInt64" + }, + { + "name": "users", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "chain_id": "1", + "visits": 482, + "users": 387 + }, + { + "project_id": "proj_abc", + "chain_id": "8453", + "visits": 312, + "users": 268 + }, + { + "project_id": "proj_abc", + "chain_id": "42161", + "visits": 184, + "users": 154 + }, + { + "project_id": "proj_abc", + "chain_id": "137", + "visits": 142, + "users": 118 + } + ], + "rows": 4, + "rows_before_limit_at_least": 9 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/top_events": { + "get": { + "operationId": "getAnalyticsTopEvents", + "summary": "Get top events by frequency", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "custom" + ] + }, + "description": "Pass 'custom' to filter to custom track events only (events with type='track' and a non-empty event name). Omit for all events." + } + ], + "responses": { + "200": { + "description": "Top events", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "type", + "type": "String" + }, + { + "name": "event", + "type": "String" + }, + { + "name": "hits", + "type": "UInt64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "type": "page", + "event": "", + "hits": 4128 + }, + { + "project_id": "proj_abc", + "type": "identify", + "event": "", + "hits": 1284 + }, + { + "project_id": "proj_abc", + "type": "track", + "event": "wallet_connect", + "hits": 612 + }, + { + "project_id": "proj_abc", + "type": "track", + "event": "swap", + "hits": 248 + }, + { + "project_id": "proj_abc", + "type": "track", + "event": "stake", + "hits": 86 + } + ], + "rows": 5, + "rows_before_limit_at_least": 14 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + }, + "description": "Most frequent events in the project. By default returns all event categories (page / track / identify / decoded_log / etc.). Pass `type=custom` to filter to custom track events only." + } + }, + "/v0/revenue_by_metric": { + "get": { + "operationId": "getAnalyticsRevenueByMetric", + "summary": "Get revenue grouped by a chosen column", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "name": "metric_column", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "pathname", + "origin", + "channel", + "referrer", + "referrer_url", + "ref", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", + "builder_codes", + "location", + "device", + "browser", + "os", + "rdns", + "provider_name", + "chain_id", + "event" + ] + }, + "description": "Column to group revenue by. Use `channel` for the 12-channel acquisition classifier (see docs/channels.md). Unknown values return zero rows." + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Revenue by metric", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "pathname", + "type": "String" + }, + { + "name": "sum_revenue", + "type": "Float64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "pathname": "app.example.com/trade", + "sum_revenue": 8412.55 + }, + { + "project_id": "proj_abc", + "pathname": "app.example.com/earn", + "sum_revenue": 2184.3 + }, + { + "project_id": "proj_abc", + "pathname": "app.example.com/swap", + "sum_revenue": 1287.2 + } + ], + "rows": 3, + "rows_before_limit_at_least": 8 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/volume_by_metric": { + "get": { + "operationId": "getAnalyticsVolumeByMetric", + "summary": "Get transaction volume grouped by a chosen column", + "tags": [ + "Query" + ], + "x-required-scope": "query:read", + "parameters": [ + { + "$ref": "#/components/parameters/AnalyticsDateFrom" + }, + { + "$ref": "#/components/parameters/AnalyticsDateTo" + }, + { + "$ref": "#/components/parameters/AnalyticsFilters" + }, + { + "name": "metric_column", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "pathname", + "origin", + "channel", + "referrer", + "referrer_url", + "ref", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", + "builder_codes", + "location", + "device", + "browser", + "os", + "rdns", + "provider_name", + "chain_id", + "event" + ] + }, + "description": "Column to group volume by. Use `channel` for the 12-channel acquisition classifier (see docs/channels.md). Unknown values return zero rows." + }, + { + "$ref": "#/components/parameters/AnalyticsLimit" + }, + { + "$ref": "#/components/parameters/AnalyticsOffset" + } + ], + "responses": { + "200": { + "description": "Volume by metric", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + }, + "example": { + "meta": [ + { + "name": "project_id", + "type": "String" + }, + { + "name": "pathname", + "type": "String" + }, + { + "name": "sum_volume", + "type": "Float64" + } + ], + "data": [ + { + "project_id": "proj_abc", + "pathname": "app.example.com/trade", + "sum_volume": 2812400 + }, + { + "project_id": "proj_abc", + "pathname": "app.example.com/earn", + "sum_volume": 612300 + }, + { + "project_id": "proj_abc", + "pathname": "app.example.com/swap", + "sum_volume": 412800 + } + ], + "rows": 3, + "rows_before_limit_at_least": 8 + } + } + } + }, + "401": { + "description": "Invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + }, + "403": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestApiError" + } + } + } + } + } + } + }, + "/v0/raw_events": { + "post": { + "operationId": "ingestEvents", + "summary": "Ingest events", + "description": "Send analytics events to Formo. This endpoint runs on events.formo.so (not api.formo.so). Authenticate with your project's SDK write key.", + "tags": [ + "Events" + ], + "servers": [ + { + "url": "https://events.formo.so" + } + ], + "security": [ + { + "WorkspaceApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Event" + } + }, + "examples": { + "pageView": { + "summary": "Track a page view", + "value": [ + { + "type": "page", + "channel": "web", + "version": "1", + "anonymous_id": "e397c4e7-5f0a-45d6-a06c-f34a809d8b82", + "user_id": "", + "address": "", + "event": "", + "context": { + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + "locale": "en-US", + "timezone": "America/Los_Angeles", + "location": "US", + "ref": "", + "referrer": "", + "utm_campaign": "", + "utm_content": "", + "utm_medium": "", + "utm_source": "", + "utm_term": "", + "page_title": "Dashboard | MyApp", + "page_url": "https://myapp.com/swap/ethereum#/swap", + "page_path": "/swap/ethereum", + "library_name": "Formo Web SDK", + "library_version": "1.27.0", + "browser": "chrome", + "device": "desktop", + "os": "Windows", + "screen_width": 1280, + "screen_height": 720, + "screen_density": 1.5, + "viewport_width": 1280, + "viewport_height": 604 + }, + "properties": { + "url": "https://myapp.com/swap/ethereum#/swap", + "path": "/swap/ethereum", + "hash": "#/swap", + "query": "" + }, + "original_timestamp": "2026-04-28T02:08:30.000Z", + "sent_at": "2026-04-28T02:09:00.000Z", + "message_id": "263434374239d12b797bf571c6045d6f9f9000a70f19f94d75ca485536606b27" + } + ] + }, + "walletConnect": { + "summary": "Track a wallet connection", + "value": [ + { + "type": "connect", + "channel": "web", + "version": "1", + "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", + "user_id": "", + "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", + "event": "", + "context": { + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "locale": "en-US", + "timezone": "Europe/London", + "location": "GB", + "page_title": "MyApp - Swap & Bridge", + "page_url": "https://myapp.com/earn/positions", + "library_name": "Formo Web SDK", + "library_version": "1.27.0", + "browser": "chrome", + "device": "desktop", + "os": "Windows" + }, + "properties": { + "rdns": "io.metamask", + "chain_id": 43114, + "provider_name": "MetaMask" + }, + "original_timestamp": "2026-04-27T20:04:54.000Z", + "sent_at": "2026-04-27T20:05:00.000Z", + "message_id": "b0a1dc19c494df191e2cb0c56460f7472113e5858440b3d4f3798a070265f631" + } + ] + }, + "trackEvent": { + "summary": "Track a custom event", + "value": [ + { + "type": "track", + "channel": "web", + "version": "1", + "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", + "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", + "event": "Chain Switched", + "context": { + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "locale": "en-US", + "timezone": "Europe/London", + "location": "GB", + "page_url": "https://myapp.com/swap", + "library_name": "Formo Web SDK", + "library_version": "1.27.0", + "browser": "chrome", + "device": "desktop", + "os": "Windows" + }, + "properties": { + "old_network": "Arbitrum", + "new_network": "BNB Chain" + }, + "original_timestamp": "2026-04-27T23:05:38.000Z", + "sent_at": "2026-04-27T23:05:42.000Z", + "message_id": "f4b2e8c1a59d3e7f6c8b9a02d5e4f1c3b8a7e6d5c4b3a291807e6d5c4b3a2918" + } + ] + }, + "identify": { + "summary": "Identify a wallet", + "value": [ + { + "type": "identify", + "channel": "web", + "version": "1", + "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", + "user_id": "usr_42a91c2b", + "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", + "event": null, + "context": { + "library_name": "Formo Web SDK", + "library_version": "1.27.0", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "locale": "en-US", + "timezone": "Europe/London", + "location": "GB", + "page_url": "https://myapp.com/dashboard", + "browser": "chrome", + "device": "desktop", + "os": "Windows" + }, + "properties": { + "rdns": "io.metamask", + "provider_name": "MetaMask", + "email": "alice@example.com", + "display_name": "alice.eth" + }, + "original_timestamp": "2026-04-27T20:05:10.000Z", + "sent_at": "2026-04-27T20:05:11.000Z", + "message_id": "7e2a4b1c8d3f6e9a0b5c2d1e4f7a8b6c9d0e3f2a1b4c5d6e7f8a9b0c1d2e3f4a" + } + ] + }, + "transaction": { + "summary": "Track an onchain transaction", + "value": [ + { + "type": "transaction", + "channel": "web", + "version": "1", + "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", + "user_id": "", + "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", + "event": "", + "context": { + "library_name": "Formo Web SDK", + "library_version": "1.27.0", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "locale": "en-US", + "timezone": "Europe/London", + "location": "GB", + "page_url": "https://myapp.com/swap", + "browser": "chrome", + "device": "desktop", + "os": "Windows" + }, + "properties": { + "status": "confirmed", + "chain_id": 1, + "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "value": "0", + "transaction_hash": "0x6f4c1f2c3a8b9e0d1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e", + "function_name": "transfer", + "function_args": { + "to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "amount": "1000000000" + }, + "revenue": 250.5, + "currency": "usd" + }, + "original_timestamp": "2026-04-27T22:14:03.000Z", + "sent_at": "2026-04-27T22:14:04.000Z", + "message_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + } + ] + } + } + } + } + }, + "responses": { + "200": { + "description": "Events ingested", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "successful_rows": { + "type": "integer" + }, + "quarantined_rows": { + "type": "integer" + } + } + }, + "example": { + "successful_rows": 1, + "quarantined_rows": 0 + } + } + } + } + } + } + } + }, + "tags": [ + { + "name": "Alerts", + "description": "Manage project alerts and notifications" + }, + { + "name": "Boards", + "description": "Manage dashboard boards" + }, + { + "name": "Charts", + "description": "Manage charts within boards" + }, + { + "name": "Contracts", + "description": "Manage blockchain contract monitoring" + }, + { + "name": "Segments", + "description": "Manage user segments" + }, + { + "name": "Profiles", + "description": "Wallet profiles and import" + }, + { + "name": "Query", + "description": "Execute SQL queries and call pre-built analytics endpoints (KPIs, top pages, lifecycle, retention, revenue). Requires the query:read scope." + }, + { + "name": "Events", + "description": "Event ingestion API (events.formo.so)" + } + ] +} diff --git a/test/commands/alerts.test.ts b/test/commands/alerts.test.ts index 9ed0f18..e365768 100644 --- a/test/commands/alerts.test.ts +++ b/test/commands/alerts.test.ts @@ -1,27 +1,24 @@ import { expect } from 'chai'; import { listAlertsRun, getAlertRun, createAlertRun } from '../../src/commands/alerts'; -// Response shape: { isSuccess: true, data: Alert[] } for list -// { isSuccess: true, data: Alert } for get +// Response shape: Alert[] for list, Alert for get (bare resource — no envelope). describe('commands/alerts', function () { let firstAlertId: string | undefined; describe('listAlertsRun()', function () { it('returns an array of alerts', async function () { - const res = await listAlertsRun() as { isSuccess: boolean; data: { id: string }[] }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.be.an('array'); - if (res.data.length > 0) firstAlertId = res.data[0].id; + const res = await listAlertsRun() as { id: string }[]; + expect(res).to.be.an('array'); + if (res.length > 0) firstAlertId = res[0].id; }); }); describe('getAlertRun()', function () { it('returns an alert by ID', async function () { if (!firstAlertId) return this.skip(); - const res = await getAlertRun(firstAlertId) as { isSuccess: boolean; data: { id: string } }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.have.property('id', firstAlertId); + const res = await getAlertRun(firstAlertId) as { id: string }; + expect(res).to.have.property('id', firstAlertId); }); }); diff --git a/test/commands/boards.test.ts b/test/commands/boards.test.ts index 0327311..1bdd18b 100644 --- a/test/commands/boards.test.ts +++ b/test/commands/boards.test.ts @@ -1,27 +1,24 @@ import { expect } from 'chai'; import { listBoardsRun, getBoardRun } from '../../src/commands/boards'; -// Response shape: { isSuccess: true, data: Board[] } for list -// { isSuccess: true, data: Board } for get +// Response shape: Board[] for list, Board for get (bare resource — no envelope). describe('commands/boards', function () { let firstBoardId: string | undefined; describe('listBoardsRun()', function () { it('returns an array of boards', async function () { - const res = await listBoardsRun() as { isSuccess: boolean; data: { id: string }[] }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.be.an('array'); - if (res.data.length > 0) firstBoardId = res.data[0].id; + const res = await listBoardsRun() as { id: string }[]; + expect(res).to.be.an('array'); + if (res.length > 0) firstBoardId = res[0].id; }); }); describe('getBoardRun()', function () { it('returns a board by ID', async function () { if (!firstBoardId) return this.skip(); - const res = await getBoardRun(firstBoardId) as { isSuccess: boolean; data: { id: string } }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.have.property('id', firstBoardId); + const res = await getBoardRun(firstBoardId) as { id: string }; + expect(res).to.have.property('id', firstBoardId); }); }); }); diff --git a/test/commands/charts.test.ts b/test/commands/charts.test.ts index 1986eaa..39d488c 100644 --- a/test/commands/charts.test.ts +++ b/test/commands/charts.test.ts @@ -2,34 +2,32 @@ import { expect } from 'chai'; import { listBoardsRun } from '../../src/commands/boards'; import { listChartsRun, getChartRun, createChartRun, updateChartRun } from '../../src/commands/charts'; -// Response shape: { isSuccess: true, data: { charts: Chart[], board: ... } } for list -// { isSuccess: true, data: Chart } for get +// Response shape: { charts: Chart[], board: Board } for list, Chart for get +// (bare resource — no envelope). describe('commands/charts', function () { let boardId: string | undefined; let firstChartId: string | undefined; before(async function () { - const res = await listBoardsRun() as { isSuccess: boolean; data: { id: string }[] }; - if (res.data.length > 0) boardId = res.data[0].id; + const res = await listBoardsRun() as { id: string }[]; + if (res.length > 0) boardId = res[0].id; }); describe('listChartsRun()', function () { it('returns charts for a board', async function () { if (!boardId) return this.skip(); - const res = await listChartsRun(boardId) as { isSuccess: boolean; data: { charts: { id: string }[] } }; - expect(res.isSuccess).to.equal(true); - expect(res.data.charts).to.be.an('array'); - if (res.data.charts.length > 0) firstChartId = res.data.charts[0].id; + const res = await listChartsRun(boardId) as { charts: { id: string }[] }; + expect(res.charts).to.be.an('array'); + if (res.charts.length > 0) firstChartId = res.charts[0].id; }); }); describe('getChartRun()', function () { it('returns a chart by ID', async function () { if (!boardId || !firstChartId) return this.skip(); - const res = await getChartRun(boardId, firstChartId) as { isSuccess: boolean; data: { id: string } }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.have.property('id', firstChartId); + const res = await getChartRun(boardId, firstChartId) as { id: string }; + expect(res).to.have.property('id', firstChartId); }); }); diff --git a/test/commands/contracts.test.ts b/test/commands/contracts.test.ts index afe9122..8c53619 100644 --- a/test/commands/contracts.test.ts +++ b/test/commands/contracts.test.ts @@ -1,7 +1,8 @@ import { expect } from 'chai'; import { listContractsRun, createContractRun, updateContractRun } from '../../src/commands/contracts'; -// Response shape: { isSuccess: true, data: { contracts: Contract[], deployAt, deployDiff } } +// Response shape: { contracts: Contract[], deployAt?, deployDiff? } for list +// (bare resource — no envelope). const TEST_ABI = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]); const TEST_EVENTS = JSON.stringify({ Transfer: true }); @@ -9,9 +10,8 @@ const TEST_EVENTS = JSON.stringify({ Transfer: true }); describe('commands/contracts', function () { describe('listContractsRun()', function () { it('returns an array of contracts', async function () { - const res = await listContractsRun() as { isSuccess: boolean; data: { contracts: unknown[] } }; - expect(res.isSuccess).to.equal(true); - expect(res.data.contracts).to.be.an('array'); + const res = await listContractsRun() as { contracts: unknown[] }; + expect(res.contracts).to.be.an('array'); }); }); diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index 3940043..258b4cd 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -18,15 +18,15 @@ describe('commands/profiles', function () { }); describe('searchProfilesRun()', function () { - it('returns a list of profiles with a limit', async function () { - const result = await searchProfilesRun({ limit: 3 }) as unknown; - // API may return array or { data: [...] } — either way it's defined + it('returns a paginated list of profiles', async function () { + const result = await searchProfilesRun({ size: 3 }) as unknown; + // PaginatedResponse: { data, total, page, size, has_more } expect(result).to.exist; }); it('accepts orderBy and orderDir params', async function () { const result = await searchProfilesRun({ - limit: 2, + size: 2, orderBy: 'net_worth_usd', orderDir: 'desc', }) as unknown; diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index 9a40dac..a433e18 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -1,14 +1,13 @@ import { expect } from 'chai'; import { listSegmentsRun, createSegmentRun } from '../../src/commands/segments'; -// Response shape: { isSuccess: true, data: Segment[] } +// Response shape: Segment[] (bare resource — no envelope). describe('commands/segments', function () { describe('listSegmentsRun()', function () { it('returns an array of segments', async function () { - const res = await listSegmentsRun() as { isSuccess: boolean; data: unknown[] }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.be.an('array'); + const res = await listSegmentsRun() as unknown[]; + expect(res).to.be.an('array'); }); }); From 973553dda4a65d1fc43ac8759f223b7abdc03b3e Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 16:32:24 +0700 Subject: [PATCH 2/7] Sync to latest API: PaginatedResponse on /v0 list endpoints Re-vendor openapi.json from formono and update list-endpoint tests to consume the new PaginatedResponse envelope ({ data, total, page, size, has_more }). Charts list now reads res.data (was res.charts); contracts list now reads res.data + res.deploy (was res.contracts + top-level deployAt/deployDiff). New getContract op surfaces automatically via `formo api getContract`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/openapi.json | 413 +++++++++++++++++++++++--------- test/commands/alerts.test.ts | 13 +- test/commands/boards.test.ts | 13 +- test/commands/charts.test.ts | 24 +- test/commands/contracts.test.ts | 16 +- test/commands/segments.test.ts | 10 +- 6 files changed, 350 insertions(+), 139 deletions(-) diff --git a/src/lib/openapi.json b/src/lib/openapi.json index b84b83c..695f15b 100644 --- a/src/lib/openapi.json +++ b/src/lib/openapi.json @@ -33,6 +33,29 @@ } }, "schemas": { + "PaginatedListMeta": { + "type": "object", + "description": "Pagination cursor returned alongside `data` on every paginated list endpoint. Use these to walk pages: `has_more` is true while `page * size < total`. Combine with the matching `Page` and `Size` query parameters to request the next page.", + "required": ["page", "size", "total", "has_more"], + "properties": { + "page": { + "type": "integer", + "description": "1-indexed page number echoed from the request." + }, + "size": { + "type": "integer", + "description": "Page size echoed from the request." + }, + "total": { + "type": "integer", + "description": "Total row count across all pages." + }, + "has_more": { + "type": "boolean", + "description": "True when more pages remain (`page * size < total`)." + } + } + }, "Error": { "type": "object", "description": "Standard error envelope returned by every public API endpoint for any non-2xx response. The HTTP status code carries success/failure; the body provides a machine-readable `code`, a human-readable `message`, and a `doc_url` pointing at the matching section of the docs so agents can fetch context on the fly.", @@ -1502,6 +1525,29 @@ }, "description": "Optional unique value (e.g. a UUID v4) that lets you safely retry POST/PUT/PATCH/DELETE requests. The first request runs normally; subsequent requests with the same key replay the stored response (status + body) for 24 hours, so retries can never double-create or double-charge. Two concurrent requests with the same key return `409 IDEMPOTENCY_IN_PROGRESS`. Generate a fresh key per logical operation." }, + "Page": { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "description": "1-indexed page number. Defaults to 1." + }, + "Size": { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 100 + }, + "description": "Page size. Defaults to 100, capped at 200." + }, "AnalyticsDateFrom": { "name": "date_from", "in": "query", @@ -1717,21 +1763,34 @@ "get": { "operationId": "listAlerts", "summary": "List alerts", - "description": "List all alerts for the project scoped to the API key.", + "description": "List alerts for the project scoped to the API key. Paginated: see `page` and `size` query params; the response carries `total` and `has_more` so callers can walk pages.", "tags": [ "Alerts" ], "x-required-scope": "alerts:read", + "parameters": [ + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/Size" } + ], "responses": { "200": { - "description": "List of alerts", + "description": "Paginated list of alerts", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Alert" - } + "allOf": [ + { "$ref": "#/components/schemas/PaginatedListMeta" }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Alert" } + } + } + } + ] }, "example": { "data": [ @@ -1757,15 +1816,11 @@ "recipient": [ { "type": "email", - "value": [ - "alerts@myapp.com" - ] + "value": ["alerts@myapp.com"] }, { "type": "slack", - "value": [ - "C0123456789" - ] + "value": ["C0123456789"] } ], "has_secret": false, @@ -1773,11 +1828,10 @@ "updated_at": "2026-04-25T14:01:55.000Z" } ], - "meta": { - "total": 1, - "limit": 50, - "offset": 0 - } + "page": 1, + "size": 100, + "total": 1, + "has_more": false } } } @@ -2289,19 +2343,33 @@ "get": { "operationId": "listBoards", "summary": "List boards", + "description": "List boards for the project scoped to the API key. Paginated.", "tags": [ "Boards" ], + "parameters": [ + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/Size" } + ], "responses": { "200": { - "description": "List of boards", + "description": "Paginated list of boards", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Board" - } + "allOf": [ + { "$ref": "#/components/schemas/PaginatedListMeta" }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Board" } + } + } + } + ] }, "example": { "data": [ @@ -2324,11 +2392,10 @@ "updated_at": "2026-04-19T10:55:02.000Z" } ], - "meta": { - "total": 2, - "limit": 50, - "offset": 0 - } + "page": 1, + "size": 100, + "total": 2, + "has_more": false } } } @@ -2339,7 +2406,7 @@ "post": { "operationId": "createBoard", "summary": "Create board", - "description": "Create a new empty board.", + "description": "Create a new board with the given title.", "tags": [ "Boards" ], @@ -2349,7 +2416,28 @@ "application/json": { "schema": { "type": "object", - "properties": {} + "required": ["title"], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Human-readable board name. Required." + }, + "description": { + "type": "string", + "description": "Optional longer description." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the board is publicly viewable via its share URL. Defaults to false.", + "default": false + } + } + }, + "example": { + "title": "Weekly KPIs", + "description": "Headline metrics for the team standup.", + "isPublic": false } } } @@ -2520,6 +2608,7 @@ "get": { "operationId": "listCharts", "summary": "List charts for a board", + "description": "List charts in a board with their executed query results. Paginated. Includes the parent `board` for caller convenience and an optional `warnings` sidecar carrying per-chart query failures (the failing charts are excluded from `data` so the page renders cleanly).", "tags": [ "Charts" ], @@ -2531,26 +2620,47 @@ "schema": { "type": "string" } - } + }, + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/Size" } ], "responses": { "200": { - "description": "Charts with executed query results", + "description": "Paginated charts plus parent board and any partial-failure warnings", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "charts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Chart" + "allOf": [ + { "$ref": "#/components/schemas/PaginatedListMeta" }, + { + "type": "object", + "required": ["data", "board"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Chart" } + }, + "board": { "$ref": "#/components/schemas/Board" }, + "warnings": { + "type": "object", + "description": "Present only if some charts failed to execute. Failures are excluded from `data`.", + "properties": { + "failedCharts": { "type": "integer" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "chartId": { "type": "string" }, + "error": { "type": "string" } + } + } + } + } + } } - }, - "board": { - "$ref": "#/components/schemas/Board" } - } + ] }, "example": { "data": [ @@ -2563,18 +2673,22 @@ "description": null, "query": "SELECT toDate(timestamp) AS day, sum(revenue) AS revenue FROM events WHERE event = 'transaction' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY day ORDER BY day", "x_axis": "day", - "y_axis": [ - "revenue" - ], + "y_axis": ["revenue"], "group_by": null, "steps": null, "settings": null } ], - "meta": { - "total": 1, - "limit": 50, - "offset": 0 + "page": 1, + "size": 100, + "total": 1, + "has_more": false, + "board": { + "id": "brd_a1b2c3d4e5f6", + "project_id": "proj_abc123", + "title": "Revenue Dashboard", + "description": "Weekly revenue, conversion, and retention metrics.", + "enabled": false } } } @@ -2898,8 +3012,7 @@ "content": { "application/json": { "schema": { - "type": "string", - "description": "Chart ID" + "$ref": "#/components/schemas/Chart" } } } @@ -3048,35 +3161,52 @@ "get": { "operationId": "listContracts", "summary": "List contracts", + "description": "List monitored contracts. Paginated; the canonical `data` array carries the contracts for the current page. The `deploy` sidecar reports the project-wide deploy state (always reflects ALL contracts, not just this page) so callers can render \"X contracts pending deploy\" without a second request.", "tags": [ "Contracts" ], + "parameters": [ + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/Size" } + ], "responses": { "200": { - "description": "Contracts with deployment diff info", + "description": "Paginated list of contracts plus deploy-state sidecar", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "contracts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Contract" - } - }, - "deployAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "deployDiff": { - "type": "array", - "items": { - "type": "object" + "allOf": [ + { "$ref": "#/components/schemas/PaginatedListMeta" }, + { + "type": "object", + "required": ["data", "deploy"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Contract" } + }, + "deploy": { + "type": "object", + "required": ["last_deployed_at", "diff"], + "properties": { + "last_deployed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of the project's last successful deploy, or null if no deploy has run yet." + }, + "diff": { + "type": "array", + "description": "Difference between the contracts currently registered for the project and what's actually deployed. Drives the \"contracts pending deploy\" UI.", + "items": { + "type": "object" + } + } + } + } } } - } + ] }, "example": { "data": [ @@ -3143,10 +3273,13 @@ ] } ], - "meta": { - "total": 2, - "limit": 50, - "offset": 0 + "page": 1, + "size": 100, + "total": 2, + "has_more": false, + "deploy": { + "last_deployed_at": "2026-04-22T08:14:31.000Z", + "diff": [] } } } @@ -3284,6 +3417,54 @@ } }, "/v0/contracts/{chain}/{address}": { + "get": { + "operationId": "getContract", + "summary": "Get contract", + "description": "Fetch a single monitored contract by chain ID and address. Returns the bare `Contract` resource (no envelope).", + "tags": [ + "Contracts" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + }, + "description": "EVM chain ID (e.g. `1` for Ethereum mainnet, `8453` for Base)." + }, + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "description": "Contract address (0x-prefixed, 40 hex chars)." + } + ], + "responses": { + "200": { + "description": "Contract found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contract" + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "500": { "$ref": "#/components/responses/InternalServerError" } + }, + "x-required-scope": "contracts:read" + }, "put": { "operationId": "updateContract", "summary": "Update contract", @@ -3406,19 +3587,33 @@ "get": { "operationId": "listSegments", "summary": "List segments", + "description": "List user segments for the project scoped to the API key. Paginated.", "tags": [ "Segments" ], + "parameters": [ + { "$ref": "#/components/parameters/Page" }, + { "$ref": "#/components/parameters/Size" } + ], "responses": { "200": { - "description": "List of segments", + "description": "Paginated list of segments", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Segment" - } + "allOf": [ + { "$ref": "#/components/schemas/PaginatedListMeta" }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Segment" } + } + } + } + ] }, "example": { "data": [ @@ -3432,11 +3627,10 @@ ] } ], - "meta": { - "total": 1, - "limit": 50, - "offset": 0 - } + "page": 1, + "size": 100, + "total": 1, + "has_more": false } } } @@ -3622,38 +3816,31 @@ "content": { "application/json": { "schema": { - "type": "object", - "required": [ - "data", - "total", - "page", - "size", - "has_more" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Profile" + "allOf": [ + { "$ref": "#/components/schemas/PaginatedListMeta" }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Profile" } + } } - }, - "total": { - "type": "integer", - "description": "Total number of profiles across all pages." - }, - "page": { - "type": "integer", - "description": "1-indexed page number echoed from the request." - }, - "size": { - "type": "integer", - "description": "Page size echoed from the request." - }, - "has_more": { - "type": "boolean", - "description": "True if there is at least one more page after this one." } - } + ] + }, + "example": { + "data": [ + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "net_worth_usd": 12345.67 + } + ], + "page": 1, + "size": 100, + "total": 1, + "has_more": false } } } diff --git a/test/commands/alerts.test.ts b/test/commands/alerts.test.ts index e365768..daff66e 100644 --- a/test/commands/alerts.test.ts +++ b/test/commands/alerts.test.ts @@ -1,16 +1,19 @@ import { expect } from 'chai'; import { listAlertsRun, getAlertRun, createAlertRun } from '../../src/commands/alerts'; -// Response shape: Alert[] for list, Alert for get (bare resource — no envelope). +// Response shape: PaginatedResponse { data, total, page, size, has_more } for list, +// Alert for get (bare resource — no envelope). describe('commands/alerts', function () { let firstAlertId: string | undefined; describe('listAlertsRun()', function () { - it('returns an array of alerts', async function () { - const res = await listAlertsRun() as { id: string }[]; - expect(res).to.be.an('array'); - if (res.length > 0) firstAlertId = res[0].id; + it('returns a paginated list of alerts', async function () { + const res = await listAlertsRun() as { data: { id: string }[]; total: number; has_more: boolean }; + expect(res.data).to.be.an('array'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); + if (res.data.length > 0) firstAlertId = res.data[0].id; }); }); diff --git a/test/commands/boards.test.ts b/test/commands/boards.test.ts index 1bdd18b..60aefcd 100644 --- a/test/commands/boards.test.ts +++ b/test/commands/boards.test.ts @@ -1,16 +1,19 @@ import { expect } from 'chai'; import { listBoardsRun, getBoardRun } from '../../src/commands/boards'; -// Response shape: Board[] for list, Board for get (bare resource — no envelope). +// Response shape: PaginatedResponse { data, total, page, size, has_more } for list, +// Board for get (bare resource — no envelope). describe('commands/boards', function () { let firstBoardId: string | undefined; describe('listBoardsRun()', function () { - it('returns an array of boards', async function () { - const res = await listBoardsRun() as { id: string }[]; - expect(res).to.be.an('array'); - if (res.length > 0) firstBoardId = res[0].id; + it('returns a paginated list of boards', async function () { + const res = await listBoardsRun() as { data: { id: string }[]; total: number; has_more: boolean }; + expect(res.data).to.be.an('array'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); + if (res.data.length > 0) firstBoardId = res.data[0].id; }); }); diff --git a/test/commands/charts.test.ts b/test/commands/charts.test.ts index 39d488c..90cc5a1 100644 --- a/test/commands/charts.test.ts +++ b/test/commands/charts.test.ts @@ -2,24 +2,32 @@ import { expect } from 'chai'; import { listBoardsRun } from '../../src/commands/boards'; import { listChartsRun, getChartRun, createChartRun, updateChartRun } from '../../src/commands/charts'; -// Response shape: { charts: Chart[], board: Board } for list, Chart for get -// (bare resource — no envelope). +// Response shape: PaginatedResponse + { board, warnings? } for list, +// Chart for get (bare resource — no envelope). describe('commands/charts', function () { let boardId: string | undefined; let firstChartId: string | undefined; before(async function () { - const res = await listBoardsRun() as { id: string }[]; - if (res.length > 0) boardId = res[0].id; + const res = await listBoardsRun() as { data: { id: string }[] }; + if (res.data.length > 0) boardId = res.data[0].id; }); describe('listChartsRun()', function () { - it('returns charts for a board', async function () { + it('returns paginated charts for a board with the parent board', async function () { if (!boardId) return this.skip(); - const res = await listChartsRun(boardId) as { charts: { id: string }[] }; - expect(res.charts).to.be.an('array'); - if (res.charts.length > 0) firstChartId = res.charts[0].id; + const res = await listChartsRun(boardId) as { + data: { id: string }[]; + board: { id: string }; + total: number; + has_more: boolean; + }; + expect(res.data).to.be.an('array'); + expect(res.board).to.have.property('id'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); + if (res.data.length > 0) firstChartId = res.data[0].id; }); }); diff --git a/test/commands/contracts.test.ts b/test/commands/contracts.test.ts index 8c53619..c0e664c 100644 --- a/test/commands/contracts.test.ts +++ b/test/commands/contracts.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { listContractsRun, createContractRun, updateContractRun } from '../../src/commands/contracts'; -// Response shape: { contracts: Contract[], deployAt?, deployDiff? } for list +// Response shape: PaginatedResponse + { deploy: { last_deployed_at, diff } } for list // (bare resource — no envelope). const TEST_ABI = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]); @@ -9,9 +9,17 @@ const TEST_EVENTS = JSON.stringify({ Transfer: true }); describe('commands/contracts', function () { describe('listContractsRun()', function () { - it('returns an array of contracts', async function () { - const res = await listContractsRun() as { contracts: unknown[] }; - expect(res.contracts).to.be.an('array'); + it('returns paginated contracts with deploy status', async function () { + const res = await listContractsRun() as { + data: unknown[]; + deploy: { last_deployed_at: string | null; diff: unknown[] }; + total: number; + has_more: boolean; + }; + expect(res.data).to.be.an('array'); + expect(res.deploy).to.have.property('diff'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); }); }); diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index a433e18..869ce18 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -1,13 +1,15 @@ import { expect } from 'chai'; import { listSegmentsRun, createSegmentRun } from '../../src/commands/segments'; -// Response shape: Segment[] (bare resource — no envelope). +// Response shape: PaginatedResponse { data, total, page, size, has_more } (no envelope). describe('commands/segments', function () { describe('listSegmentsRun()', function () { - it('returns an array of segments', async function () { - const res = await listSegmentsRun() as unknown[]; - expect(res).to.be.an('array'); + it('returns a paginated list of segments', async function () { + const res = await listSegmentsRun() as { data: unknown[]; total: number; has_more: boolean }; + expect(res.data).to.be.an('array'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); }); }); From 102f53980d20a62b583c5b4013a9c0550e988854 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 17:08:55 +0700 Subject: [PATCH 3/7] Remove OpenAPI-driven raw `formo api` command Drop the auto-generated passthrough subcommands and the vendored openapi.json (~215KB). The hand-written commands (alerts, boards, charts, contracts, segments, profiles, query, import) cover the human-facing surface; raw access wasn't paying its weight in bundle size and discoverability cost. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 9 - src/lib/forward.ts | 28 - src/lib/openapi.json | 6887 ------------------------------------------ 3 files changed, 6924 deletions(-) delete mode 100644 src/lib/forward.ts delete mode 100644 src/lib/openapi.json diff --git a/src/index.ts b/src/index.ts index 129b878..b3ae255 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,6 @@ import { profiles } from './commands/profiles'; import { query } from './commands/query'; import { segments } from './commands/segments'; import { clearConfig, getApiKey, readConfig, saveConfig } from './lib/config'; -import { forwardToFormo } from './lib/forward'; -import openapiSpec from './lib/openapi.json'; import { banner, color, error, info, success, warn } from './lib/ui'; const DASHBOARD_URL = 'https://app.formo.so'; @@ -294,13 +292,6 @@ cli.command(contracts); cli.command(segments); cli.command(importCmd); -// ── api: OpenAPI-driven raw access (auto-generated subcommands from openapi.json) ── - -cli.command('api', { - fetch: forwardToFormo, - openapi: openapiSpec, -}); - // Show banner when run with no args (root help) const args = process.argv.slice(2); const isRootHelp = diff --git a/src/lib/forward.ts b/src/lib/forward.ts deleted file mode 100644 index 5e419b4..0000000 --- a/src/lib/forward.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getApiKey } from './config' - -const API_BASE_URL = 'https://api.formo.so' - -export async function forwardToFormo(req: Request): Promise { - const apiKey = getApiKey() - const incoming = new URL(req.url) - const upstream = new URL(incoming.pathname + incoming.search, API_BASE_URL) - - const headers = new Headers(req.headers) - headers.delete('host') - headers.delete('content-length') - if (apiKey) headers.set('authorization', `Bearer ${apiKey}`) - if (!headers.has('content-type') && req.method !== 'GET' && req.method !== 'HEAD') { - headers.set('content-type', 'application/json') - } - - const init: RequestInit = { - method: req.method, - headers, - redirect: 'follow', - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - init.body = await req.text() - } - - return fetch(upstream, init) -} diff --git a/src/lib/openapi.json b/src/lib/openapi.json deleted file mode 100644 index 695f15b..0000000 --- a/src/lib/openapi.json +++ /dev/null @@ -1,6887 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Formo Public API", - "description": "REST API for managing Formo projects, analytics, alerts, boards, charts, contracts, segments, and AI chat.\n\n**Auth.** All endpoints require a workspace API key with the appropriate scopes — see `x-api-scopes`.\n\n**Response shape.** Successful responses return the resource directly (or `{ data: [...], total, page, size, has_more }` for paginated lists). HTTP status carries success/failure — there is no envelope wrapping success bodies.\n\n**Errors.** Every non-2xx response uses the `Error` envelope: `{ error: { code, message, doc_url, param?, details? } }`. Branch on the machine-readable `code` (see `ErrorCode` enum) and follow `doc_url` to the matching section of the [errors reference](https://docs.formo.so/api/errors).\n\n**Idempotency.** Pass an `Idempotency-Key` header on POST/PUT/PATCH/DELETE to make retries safe; the response is cached for 24 h and replayed on duplicate keys.", - "version": "0.1.0", - "contact": { - "name": "Formo", - "url": "https://formo.so" - } - }, - "servers": [ - { - "url": "https://api.formo.so", - "description": "API Server (boards, alerts, contracts, segments, profiles, query, import)" - }, - { - "url": "https://events.formo.so", - "description": "Events Server (event ingestion)" - } - ], - "security": [ - { - "WorkspaceApiKey": [] - } - ], - "components": { - "securitySchemes": { - "WorkspaceApiKey": { - "type": "http", - "scheme": "bearer", - "description": "Workspace API key (e.g. `formo_xxx`). Create one in the Formo dashboard under Team Settings > API Keys." - } - }, - "schemas": { - "PaginatedListMeta": { - "type": "object", - "description": "Pagination cursor returned alongside `data` on every paginated list endpoint. Use these to walk pages: `has_more` is true while `page * size < total`. Combine with the matching `Page` and `Size` query parameters to request the next page.", - "required": ["page", "size", "total", "has_more"], - "properties": { - "page": { - "type": "integer", - "description": "1-indexed page number echoed from the request." - }, - "size": { - "type": "integer", - "description": "Page size echoed from the request." - }, - "total": { - "type": "integer", - "description": "Total row count across all pages." - }, - "has_more": { - "type": "boolean", - "description": "True when more pages remain (`page * size < total`)." - } - } - }, - "Error": { - "type": "object", - "description": "Standard error envelope returned by every public API endpoint for any non-2xx response. The HTTP status code carries success/failure; the body provides a machine-readable `code`, a human-readable `message`, and a `doc_url` pointing at the matching section of the docs so agents can fetch context on the fly.", - "properties": { - "error": { - "type": "object", - "required": [ - "code", - "message", - "doc_url" - ], - "properties": { - "code": { - "$ref": "#/components/schemas/ErrorCode" - }, - "message": { - "type": "string", - "description": "Human-readable error description. Wording may change between releases — branch on `code`, not `message`." - }, - "doc_url": { - "type": "string", - "format": "uri", - "description": "Link to the matching section of the errors reference at https://docs.formo.so/api/errors." - }, - "param": { - "type": "string", - "description": "When the error pertains to a specific request field, the dotted path to that field (e.g. `body.trigger_filters.0.value`)." - }, - "details": { - "type": "object", - "additionalProperties": true, - "description": "Code-specific extra context. For `INVALID_VALIDATION_REQUEST` this is a `{ fieldPath: message }` map of every Zod validation failure." - } - } - } - }, - "required": [ - "error" - ] - }, - "ErrorCode": { - "type": "string", - "description": "Stable, enumerated error codes. New codes may be added in any release; clients should treat unknown codes as the closest matching HTTP status family.", - "enum": [ - "INTERNAL_SERVER_ERROR", - "INVALID_VALIDATION_REQUEST", - "UNAUTHORIZED", - "BAD_REQUEST", - "FORBIDDEN", - "NOT_FOUND", - "CONFLICT", - "INVALID_CHAIN_ID", - "CONTEXT_LIMIT_EXCEEDED", - "SERVICE_UNAVAILABLE", - "TOO_MANY_REQUESTS", - "IDEMPOTENCY_IN_PROGRESS", - "INVALID_IDEMPOTENCY_KEY" - ] - }, - "Alert": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "trigger_type": { - "type": "string", - "enum": [ - "event", - "user" - ] - }, - "status": { - "type": "string", - "enum": [ - "active", - "inactive" - ] - }, - "trigger_filters": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "operator": { - "type": "string" - }, - "value": { - "type": "string" - }, - "numericThreshold": { - "type": "string" - } - }, - "required": [ - "name", - "value" - ] - } - }, - "recipient": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "email", - "slack", - "webhook" - ] - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "type", - "value" - ] - } - }, - "project_id": { - "type": "string" - }, - "has_secret": { - "type": "boolean" - } - }, - "required": [ - "id", - "name", - "trigger_type", - "status", - "project_id" - ] - }, - "Board": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "project_id": { - "type": "string" - }, - "enabled": { - "type": "boolean", - "description": "Whether the board is publicly accessible" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "project_id", - "enabled" - ] - }, - "StepFilterCondition": { - "type": "object", - "description": "A filter condition for an event property on a funnel step. The `op` field specifies the comparison operator and `value` is the value to compare against. For `in` and `notIn` operators the value is a pipe-delimited string (e.g. `\"metamask|rainbow|coinbase\"`).", - "properties": { - "op": { - "type": "string", - "enum": [ - "equals", - "notEquals", - "in", - "notIn", - "gt", - "gte", - "lt", - "lte", - "startsWith", - "endsWith", - "includes" - ], - "description": "Comparison operator. Use `in` / `notIn` for multi-value matching (pipe-delimited value string)." - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ], - "description": "The comparison value. For `in` / `notIn`, use a pipe-delimited string: `\"metamask|rainbow|coinbase\"`." - } - }, - "required": [ - "op", - "value" - ] - }, - "FunnelStep": { - "type": "object", - "description": "A single step in a funnel or user-path flow.\n\n`type` and `event` are required. Any additional property key becomes a filter condition using the `StepFilterCondition` schema (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`).\n\nStandard filterable columns: `origin`, `device`, `browser`, `os`, `location`, `referrer`, `ref`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`. Any other key is treated as a JSON event property and extracted via `JSONExtractString(properties, '')`.", - "properties": { - "type": { - "type": "string", - "enum": [ - "event", - "track", - "decoded_log" - ], - "description": "`event` — built-in page/connect/transaction events; `track` — custom tracked events; `decoded_log` — decoded smart-contract events." - }, - "event": { - "type": "string", - "minLength": 1, - "description": "Event name (e.g. `page`, `connect`, `transaction`, or a custom track event name)." - } - }, - "required": [ - "type", - "event" - ], - "additionalProperties": { - "$ref": "#/components/schemas/StepFilterCondition" - } - }, - "ConversionWindow": { - "type": "object", - "description": "Time window within which a user must complete all funnel steps (measured from Step 1). Defaults to 1 day if omitted.", - "properties": { - "value": { - "type": "integer", - "minimum": 1, - "description": "Number of time units." - }, - "unit": { - "type": "string", - "enum": [ - "minute", - "hour", - "day", - "week", - "month" - ], - "description": "Time unit. `month` = 30 days, `week` = 7 days." - } - }, - "required": [ - "value", - "unit" - ] - }, - "AnalyticsFilterCondition": { - "type": "object", - "description": "A single analytics filter condition. Use `in` / `notIn` with a pipe-delimited string value (e.g. `\"a|b|c\"`) for multi-value matches.", - "properties": { - "field": { - "type": "string", - "description": "Column to filter on. Standard session columns: `device`, `browser`, `os`, `location`, `referrer`, `ref`, `origin`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`. Track-event columns: `event`, `type`. Numeric event properties: `volume`, `revenue`, `points`." - }, - "op": { - "type": "string", - "enum": [ - "equals", - "notEquals", - "greater", - "less", - "greaterOrEqual", - "lessOrEqual", - "in", - "notIn" - ], - "description": "Comparison operator." - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "Value to compare against. For `in` / `notIn`, pass a pipe-delimited string (e.g. `\"chrome|firefox\"`)." - } - }, - "required": [ - "field", - "op", - "value" - ] - }, - "RetentionUserFilter": { - "type": "object", - "description": "A user-level segment filter applied to the retention chart cohort.", - "properties": { - "field": { - "type": "string", - "description": "The user property to filter on (e.g. `device`, `browser`, `os`, `location`, `utm_source`, `utm_medium`, `utm_campaign`)." - }, - "op": { - "type": "string", - "enum": [ - "equals", - "notEquals", - "in", - "notIn", - "gt", - "gte", - "lt", - "lte", - "startsWith", - "endsWith", - "includes" - ], - "description": "Comparison operator." - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ], - "description": "The value to compare against." - } - }, - "required": [ - "field", - "op", - "value" - ] - }, - "ChartSettings": { - "type": "object", - "description": "Chart-type-specific configuration. The fields that apply depend on `chart_type`:\n\n- **funnel**: `funnelType`, `conversionWindow`, `breakdown`\n- **user_paths**: `startStep`, `endStep`, `maxSteps`, `nodesPerStep`, `conversionWindow`, `filters`\n- **retention**: `retentionFilter`, `retentionUserFilters`\n\nAll fields are optional at the schema level; see per-type validation rules for which are functionally required.", - "properties": { - "funnelType": { - "type": "string", - "enum": [ - "closed", - "open" - ], - "default": "closed", - "description": "**Funnel only.** `closed` — users must complete steps in strict order with no intervening events. `open` — users may complete steps in order but other events may occur between steps." - }, - "conversionWindow": { - "$ref": "#/components/schemas/ConversionWindow", - "description": "**Funnel & user_paths.** Maximum time from Step 1 for a user to complete all steps." - }, - "breakdown": { - "type": "string", - "enum": [ - "device", - "browser", - "os", - "referrer", - "ref", - "utm_source", - "utm_medium", - "utm_campaign", - "utm_term", - "utm_content" - ], - "description": "**Funnel only.** Split each funnel bar by this dimension. The top categories are shown individually; the rest are collapsed into 'Others'." - }, - "startStep": { - "$ref": "#/components/schemas/FunnelStep", - "description": "**user_paths — required.** The event where the user flow begins." - }, - "endStep": { - "oneOf": [ - { - "$ref": "#/components/schemas/FunnelStep" - }, - { - "type": "null" - } - ], - "description": "**user_paths — optional.** The event where the user flow ends. If `null` the flow is open-ended." - }, - "maxSteps": { - "type": "integer", - "minimum": 2, - "maximum": 10, - "default": 3, - "description": "**user_paths.** Maximum number of steps to show in the flow (2–10). Values above 10 are clamped to 10." - }, - "nodesPerStep": { - "type": "integer", - "minimum": 2, - "maximum": 10, - "default": 5, - "description": "**user_paths.** Maximum number of unique event nodes visible per step (2–10). Values above 10 are clamped to 10." - }, - "filters": { - "type": "string", - "description": "**user_paths.** JSON-encoded string of additional filters applied to the path query." - }, - "retentionFilter": { - "oneOf": [ - { - "$ref": "#/components/schemas/FunnelStep" - }, - { - "type": "null" - } - ], - "description": "**retention.** Event that qualifies a returning visit as 'retained'. If `null`, any event counts as a return." - }, - "retentionUserFilters": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RetentionUserFilter" - }, - "description": "**retention.** Zero or more user-segment filters that narrow the cohort (e.g. only desktop users, only users from a specific UTM source)." - } - } - }, - "CreateChartRequest": { - "type": "object", - "description": "Request body for creating a chart.", - "properties": { - "projectId": { - "type": "string", - "description": "Project the chart belongs to." - }, - "query": { - "type": "string", - "minLength": 1, - "description": "SQL query that powers the chart.\n\n- **`funnel`** — pass `\"SELECT 1\"`; the actual query is auto-generated from `steps`.\n- **`retention`** — can be omitted or pass `\"\"`; data is fetched from the retention pipe directly.\n- **All other types** — required; must be a valid SQL string." - }, - "chart_type": { - "type": "string", - "enum": [ - "table", - "number", - "funnel", - "bar", - "line", - "pie", - "stacked", - "user_paths", - "retention" - ], - "description": "Visualization type. Determines which other fields are required:\n\n| `chart_type` | Extra required fields |\n|---|---|\n| `table` | `query` |\n| `number` | `query` (must return 1 row × 1 column) |\n| `bar` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `line` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `pie` | `query`, `y_axis` (exactly 1) |\n| `stacked` | `query`, `x_axis`, `y_axis` (exactly 1), `group_by` |\n| `funnel` | `steps` (≥ 2), `query` placeholder `\"SELECT 1\"` |\n| `user_paths` | `query`, `settings.startStep` |\n| `retention` | none (`query` ignored) |" - }, - "title": { - "type": "string", - "minLength": 1, - "description": "Display name shown on the chart and board." - }, - "description": { - "type": "string", - "description": "Optional description." - }, - "x_axis": { - "type": "string", - "description": "Column name for the X axis. Required for `bar`, `line`, and `stacked`." - }, - "y_axis": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Column name(s) used as Y axis metrics.\n\n- `bar` / `line` — at least 1 element required.\n- `pie` / `stacked` — exactly 1 element required." - }, - "group_by": { - "type": "string", - "description": "Column to group / stack series by. Required for `stacked`." - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FunnelStep" - }, - "minItems": 2, - "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep` — add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`)." - }, - "settings": { - "$ref": "#/components/schemas/ChartSettings" - } - }, - "required": [ - "projectId", - "chart_type", - "title" - ] - }, - "UpdateChartRequest": { - "type": "object", - "description": "Request body for updating an existing chart.", - "properties": { - "chartId": { - "type": "string", - "description": "ID of the chart to update." - }, - "projectId": { - "type": "string", - "description": "Project the chart belongs to." - }, - "query": { - "type": "string", - "minLength": 1, - "description": "SQL query that powers the chart.\n\n- **`funnel`** — pass `\"SELECT 1\"`; the actual query is auto-generated from `steps`.\n- **`retention`** — can be omitted or pass `\"\"`; data is fetched from the retention pipe directly.\n- **All other types** — required; must be a valid SQL string." - }, - "chart_type": { - "type": "string", - "enum": [ - "table", - "number", - "funnel", - "bar", - "line", - "pie", - "stacked", - "user_paths", - "retention" - ], - "description": "Visualization type. Determines which other fields are required:\n\n| `chart_type` | Extra required fields |\n|---|---|\n| `table` | `query` |\n| `number` | `query` (must return 1 row × 1 column) |\n| `bar` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `line` | `query`, `x_axis`, `y_axis` (≥ 1) |\n| `pie` | `query`, `y_axis` (exactly 1) |\n| `stacked` | `query`, `x_axis`, `y_axis` (exactly 1), `group_by` |\n| `funnel` | `steps` (≥ 2), `query` placeholder `\"SELECT 1\"` |\n| `user_paths` | `query`, `settings.startStep` |\n| `retention` | none (`query` ignored) |" - }, - "title": { - "type": "string", - "minLength": 1, - "description": "Display name shown on the chart and board." - }, - "description": { - "type": "string", - "description": "Optional description." - }, - "x_axis": { - "type": "string", - "description": "Column name for the X axis. Required for `bar`, `line`, and `stacked`." - }, - "y_axis": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Column name(s) used as Y axis metrics.\n\n- `bar` / `line` — at least 1 element required.\n- `pie` / `stacked` — exactly 1 element required." - }, - "group_by": { - "type": "string", - "description": "Column to group / stack series by. Required for `stacked`." - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FunnelStep" - }, - "minItems": 2, - "description": "Ordered list of funnel steps. Required for `funnel` (minimum 2 steps).\n\nEach element is a `FunnelStep` — add property filters as extra keys on the step object (e.g. `\"rdns\": { \"op\": \"equals\", \"value\": \"io.metamask\" }`)." - }, - "settings": { - "$ref": "#/components/schemas/ChartSettings" - } - }, - "required": [ - "chartId", - "projectId", - "chart_type", - "title" - ] - }, - "Chart": { - "type": "object", - "description": "A saved chart attached to a board.", - "properties": { - "id": { - "type": "string" - }, - "chart_type": { - "type": "string", - "enum": [ - "table", - "number", - "funnel", - "bar", - "line", - "pie", - "stacked", - "user_paths", - "retention" - ], - "description": "Visualization type." - }, - "title": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "query": { - "type": "string", - "description": "SQL query powering the chart. For `funnel` and `retention` charts this is a system-managed placeholder." - }, - "project_id": { - "type": "string" - }, - "board_id": { - "type": "string" - }, - "x_axis": { - "type": "string", - "nullable": true, - "description": "Column used as the X axis." - }, - "y_axis": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "Column(s) used as Y axis metric(s)." - }, - "group_by": { - "type": "string", - "nullable": true, - "description": "Column used to group/stack series." - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FunnelStep" - }, - "nullable": true, - "description": "Ordered list of funnel steps. Only present when `chart_type` is `funnel`." - }, - "settings": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChartSettings" - }, - { - "type": "null" - } - ], - "description": "Type-specific configuration. See `ChartSettings` for all fields." - } - }, - "required": [ - "id", - "chart_type", - "title", - "query", - "project_id", - "board_id" - ] - }, - "Contract": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "chain": { - "type": "integer" - }, - "address": { - "type": "string" - }, - "start_block": { - "type": "integer" - }, - "abi": { - "type": "string" - }, - "events": { - "type": "array", - "items": { - "type": "object", - "properties": { - "anonymous": { - "type": "boolean" - }, - "inputs": { - "type": "array", - "items": { - "type": "object" - } - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "anonymous", - "inputs", - "name", - "type" - ] - } - } - }, - "required": [ - "name", - "chain", - "address", - "abi", - "events" - ] - }, - "Segment": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - }, - "projectId": { - "type": "string" - }, - "filterSet": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "id", - "title" - ] - }, - "Profile": { - "type": "object", - "description": "Comprehensive wallet profile with onchain and offchain data", - "properties": { - "address": { - "type": "string" - }, - "net_worth_usd": { - "type": "number" - }, - "tx_count": { - "type": "integer" - }, - "first_onchain": { - "type": "string", - "description": "First onchain activity date" - }, - "last_onchain": { - "type": "string", - "description": "Last onchain activity date" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "ens": { - "type": "string", - "nullable": true - }, - "farcaster": { - "type": "string", - "nullable": true - }, - "lens": { - "type": "string", - "nullable": true - }, - "basenames": { - "type": "string", - "nullable": true - }, - "linea": { - "type": "string", - "nullable": true - }, - "avatar": { - "type": "string", - "nullable": true - }, - "display_name": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "discord": { - "type": "string", - "nullable": true - }, - "telegram": { - "type": "string", - "nullable": true - }, - "twitter": { - "type": "string", - "nullable": true - }, - "github": { - "type": "string", - "nullable": true - }, - "linkedin": { - "type": "string", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - }, - "instagram": { - "type": "string", - "nullable": true - }, - "facebook": { - "type": "string", - "nullable": true - }, - "website": { - "type": "string", - "nullable": true - }, - "reddit": { - "type": "string", - "nullable": true - }, - "youtube": { - "type": "string", - "nullable": true - }, - "tiktok": { - "type": "string", - "nullable": true - }, - "first_seen": { - "type": "string", - "nullable": true, - "description": "First seen in project" - }, - "last_seen": { - "type": "string", - "nullable": true, - "description": "Last seen in project" - }, - "lifecycle": { - "type": "string", - "nullable": true, - "enum": [ - "New", - "Returning", - "Power user", - "Resurrected", - "Churned" - ] - }, - "num_sessions": { - "type": "integer", - "nullable": true - }, - "revenue": { - "type": "number", - "nullable": true - }, - "volume": { - "type": "number", - "nullable": true - }, - "points": { - "type": "number", - "nullable": true - }, - "device": { - "type": "string", - "nullable": true - }, - "browser": { - "type": "string", - "nullable": true - }, - "os": { - "type": "string", - "nullable": true - }, - "location": { - "type": "string", - "nullable": true - }, - "first_utm_source": { - "type": "string", - "nullable": true - }, - "first_utm_medium": { - "type": "string", - "nullable": true - }, - "first_utm_campaign": { - "type": "string", - "nullable": true - }, - "first_referrer": { - "type": "string", - "nullable": true - }, - "last_utm_source": { - "type": "string", - "nullable": true - }, - "last_utm_medium": { - "type": "string", - "nullable": true - }, - "last_utm_campaign": { - "type": "string", - "nullable": true - }, - "last_referrer": { - "type": "string", - "nullable": true - }, - "first_referrer_url": { - "type": "string", - "nullable": true, - "description": "First referrer full URL" - }, - "last_referrer_url": { - "type": "string", - "nullable": true, - "description": "Last referrer full URL" - }, - "first_ref": { - "type": "string", - "nullable": true, - "description": "First referral code" - }, - "last_ref": { - "type": "string", - "nullable": true, - "description": "Last referral code" - }, - "first_utm_content": { - "type": "string", - "nullable": true, - "description": "First UTM content" - }, - "last_utm_content": { - "type": "string", - "nullable": true, - "description": "Last UTM content" - }, - "first_utm_term": { - "type": "string", - "nullable": true, - "description": "First UTM term" - }, - "last_utm_term": { - "type": "string", - "nullable": true, - "description": "Last UTM term" - }, - "last_type": { - "type": "string", - "nullable": true, - "description": "Last event type" - }, - "last_event": { - "type": "string", - "nullable": true, - "description": "Last event name" - }, - "last_properties": { - "type": "string", - "nullable": true, - "description": "Last event properties (JSON string)" - }, - "activity_dates": { - "type": "array", - "items": { - "type": "string", - "format": "date" - }, - "nullable": true, - "description": "Array of activity dates (YYYY-MM-DD format)" - }, - "chains": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WalletChain" - }, - "description": "Requires expand=chains" - }, - "apps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WalletApp" - }, - "description": "Requires expand=apps" - }, - "tokens": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WalletToken" - }, - "description": "Requires expand=tokens" - }, - "labels": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WalletLabel" - }, - "description": "Requires expand=labels" - } - } - }, - "EventContext": { - "type": "object", - "description": "Contextual information about the event environment", - "properties": { - "user_agent": { - "type": "string" - }, - "locale": { - "type": "string", - "description": "e.g. en-US" - }, - "timezone": { - "type": "string", - "description": "e.g. America/New_York" - }, - "page_url": { - "type": "string" - }, - "page_path": { - "type": "string" - }, - "page_title": { - "type": "string" - }, - "page_query": { - "type": "string" - }, - "page_hash": { - "type": "string" - }, - "referrer_url": { - "type": "string" - }, - "referrer": { - "type": "string" - }, - "ref": { - "type": "string" - }, - "utm_source": { - "type": "string" - }, - "utm_medium": { - "type": "string" - }, - "utm_campaign": { - "type": "string" - }, - "utm_term": { - "type": "string" - }, - "utm_content": { - "type": "string" - }, - "browser": { - "type": "string" - }, - "device": { - "type": "string", - "enum": [ - "desktop", - "mobile", - "tablet" - ] - }, - "os": { - "type": "string" - }, - "screen_width": { - "type": "integer" - }, - "screen_height": { - "type": "integer" - }, - "screen_density": { - "type": "number", - "description": "Pixel density of the device screen (devicePixelRatio)" - }, - "viewport_width": { - "type": "integer", - "description": "Width of the browser viewport in pixels" - }, - "viewport_height": { - "type": "integer", - "description": "Height of the browser viewport in pixels" - }, - "location": { - "type": "string", - "description": "Geographic location country code (e.g., US, NG)" - }, - "library_name": { - "type": "string" - }, - "library_version": { - "type": "string" - } - } - }, - "EventProperties": { - "type": "object", - "description": "Event-specific properties. Can contain any key-value pairs relevant to the event.", - "additionalProperties": true - }, - "Event": { - "type": "object", - "description": "A single analytics event", - "required": [ - "type", - "anonymous_id", - "version", - "channel", - "message_id" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "page", - "screen", - "connect", - "disconnect", - "chain", - "signature", - "transaction", - "track", - "decoded_log", - "detect", - "identify" - ] - }, - "channel": { - "type": "string", - "enum": [ - "web", - "mobile", - "server", - "api", - "import" - ], - "description": "Source of the event. The Formo Web SDK uses `web`; mobile SDK uses `mobile`; server SDK uses `server`. Use `api` for direct HTTP submissions and `import` for backfills." - }, - "version": { - "type": "string", - "description": "SDK schema version. Web SDK 1.x emits `1`; legacy clients emit `0`.", - "example": "1" - }, - "anonymous_id": { - "type": "string", - "description": "Anonymous visitor identifier" - }, - "user_id": { - "type": "string", - "nullable": true, - "description": "Identified user ID" - }, - "address": { - "type": "string", - "nullable": true, - "description": "Wallet address" - }, - "event": { - "type": "string", - "nullable": true, - "description": "Event name (for track events)" - }, - "context": { - "$ref": "#/components/schemas/EventContext" - }, - "properties": { - "$ref": "#/components/schemas/EventProperties" - }, - "original_timestamp": { - "type": "string", - "format": "date-time" - }, - "sent_at": { - "type": "string", - "format": "date-time" - }, - "message_id": { - "type": "string", - "description": "Unique ID for deduplication" - } - } - }, - "WalletChain": { - "type": "object", - "properties": { - "chain_id": { - "type": "string" - }, - "net_worth_usd": { - "type": "number" - }, - "tx_count": { - "type": "integer" - }, - "first_onchain": { - "type": "string" - }, - "last_onchain": { - "type": "string" - } - } - }, - "WalletApp": { - "type": "object", - "properties": { - "chain_id": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "img": { - "type": "string", - "nullable": true - }, - "url": { - "type": "string", - "nullable": true - }, - "balance_usd": { - "type": "number" - } - } - }, - "WalletToken": { - "type": "object", - "properties": { - "chain_id": { - "type": "string" - }, - "token_address": { - "type": "string" - }, - "app_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "symbol": { - "type": "string" - }, - "img": { - "type": "string", - "nullable": true - }, - "decimals": { - "type": "integer" - }, - "price": { - "type": "number" - }, - "balance": { - "type": "string" - }, - "balance_usd": { - "type": "number" - } - } - }, - "WalletLabel": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "e.g. coinbase.verified_account" - }, - "value": { - "type": "string" - }, - "chain_id": { - "type": "string" - }, - "source": { - "type": "string" - } - } - }, - "UserLabelInput": { - "type": "object", - "required": [ - "tag_id" - ], - "properties": { - "tag_id": { - "type": "string", - "description": "Label identifier (lowercased on write). e.g. vip, airdrop_eligible, coinbase.verified_account" - }, - "value": { - "type": "string", - "description": "Optional label value (e.g. tier name, country code)" - }, - "chain_id": { - "type": "string", - "description": "Optional chain identifier the label applies to" - } - } - }, - "ProfileFilter": { - "type": "object", - "description": "Filter conditions for profile search", - "properties": { - "conditions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilterCondition" - } - }, - "logic": { - "type": "string", - "enum": [ - "and", - "or" - ], - "default": "and" - } - } - }, - "FilterCondition": { - "type": "object", - "required": [ - "field", - "op", - "value" - ], - "properties": { - "field": { - "type": "string", - "description": "Field path. Profile: users.net_worth_usd, users.volume, users.revenue, users.points. Engagement: users.device, users.browser, users.os, users.location. Lifecycle: users.lifecycle. Socials: users.ens, users.farcaster, etc. Chains: chains.balance, chains.{chain_id}.balance. Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance. Labels: labels.{tag_id}" - }, - "op": { - "type": "string", - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "in", - "nin" - ] - }, - "value": { - "description": "Filter value (string, number, boolean, or array)" - }, - "scope": { - "type": "string", - "enum": [ - "any", - "protocol" - ], - "description": "For token filters: any=wallet+protocol, protocol=specific app" - }, - "appId": { - "type": "string", - "description": "For token scope=protocol (e.g. aave-v3)" - } - } - }, - "RestApiError": { - "$ref": "#/components/schemas/Error", - "description": "Deprecated alias for `Error`. Existing endpoint specs reference this name; new specs should reference `Error` directly." - }, - "AnalyticsResponse": { - "type": "object", - "description": "Raw Tinybird pipe response. The `data` array contains the analytics rows; the exact row shape depends on the endpoint.", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "meta": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - } - }, - "rows": { - "type": "integer" - }, - "rows_before_limit_at_least": { - "type": "integer" - }, - "statistics": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "parameters": { - "IdempotencyKey": { - "name": "Idempotency-Key", - "in": "header", - "required": false, - "schema": { - "type": "string", - "maxLength": 255 - }, - "description": "Optional unique value (e.g. a UUID v4) that lets you safely retry POST/PUT/PATCH/DELETE requests. The first request runs normally; subsequent requests with the same key replay the stored response (status + body) for 24 hours, so retries can never double-create or double-charge. Two concurrent requests with the same key return `409 IDEMPOTENCY_IN_PROGRESS`. Generate a fresh key per logical operation." - }, - "Page": { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "description": "1-indexed page number. Defaults to 1." - }, - "Size": { - "name": "size", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 100 - }, - "description": "Page size. Defaults to 100, capped at 200." - }, - "AnalyticsDateFrom": { - "name": "date_from", - "in": "query", - "schema": { - "type": "string", - "format": "date" - }, - "description": "Inclusive start date (YYYY-MM-DD). Defaults to 7 days before date_to." - }, - "AnalyticsDateTo": { - "name": "date_to", - "in": "query", - "schema": { - "type": "string", - "format": "date" - }, - "description": "Inclusive end date (YYYY-MM-DD). Defaults to today." - }, - "AnalyticsFilters": { - "name": "filters", - "in": "query", - "description": "Array of filter conditions, JSON-encoded in the query string. Each entry is `{ field, op, value }`. Use `in` / `notIn` with a pipe-delimited `value` (e.g. `\"chrome|firefox\"`) for multi-value matching.", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AnalyticsFilterCondition" - } - }, - "example": [ - { - "field": "location", - "op": "equals", - "value": "US" - }, - { - "field": "device", - "op": "in", - "value": "desktop|mobile" - } - ] - } - } - }, - "AnalyticsLimit": { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 50, - "minimum": 1, - "maximum": 1000 - }, - "description": "Maximum results to return (default 50, max 1000)" - }, - "AnalyticsOffset": { - "name": "offset", - "in": "query", - "schema": { - "type": "integer", - "default": 0, - "minimum": 0, - "maximum": 100000 - }, - "description": "Number of results to skip for pagination (default 0)" - } - }, - "responses": { - "BadRequest": { - "description": "The request was rejected — typically Zod validation failure. `code` is `INVALID_VALIDATION_REQUEST` and `details` contains a `{ fieldPath: message }` map.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "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)" - } - } - } - } - } - }, - "Unauthorized": { - "description": "Missing or invalid API key.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Invalid API key", - "doc_url": "https://docs.formo.so/api/errors#unauthorized" - } - } - } - } - }, - "Forbidden": { - "description": "The API key is valid but lacks the required scope for this endpoint.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "API key missing required scope: alerts:write", - "doc_url": "https://docs.formo.so/api/errors#forbidden" - } - } - } - } - }, - "NotFound": { - "description": "The requested resource does not exist or is not visible to this API key's workspace.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Alert not found", - "doc_url": "https://docs.formo.so/api/errors#not_found" - } - } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state, or an `Idempotency-Key` request with the same key is currently in flight.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "code": "IDEMPOTENCY_IN_PROGRESS", - "message": "A request with this Idempotency-Key is already in progress. Retry shortly.", - "doc_url": "https://docs.formo.so/api/errors#idempotency_in_progress" - } - } - } - } - }, - "TooManyRequests": { - "description": "Per-workspace rate limit exceeded. Inspect the `RateLimit-*` response headers and back off.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "code": "TOO_MANY_REQUESTS", - "message": "Too many requests", - "doc_url": "https://docs.formo.so/api/errors#too_many_requests" - } - } - } - } - }, - "InternalServerError": { - "description": "An unexpected error occurred on the server. The error has been logged; retry with exponential backoff.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "code": "INTERNAL_SERVER_ERROR", - "message": "Internal Server Error", - "doc_url": "https://docs.formo.so/api/errors#internal_server_error" - } - } - } - } - } - } - }, - "x-api-scopes": { - "description": "API key scopes control access to endpoints. Create keys with the required scopes in Team Settings > API Keys.", - "scopes": { - "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)", - "profiles:read": "Search and get wallet profiles", - "profiles:write": "Import wallet addresses (requires profiles:read)", - "query:read": "Execute SQL analytics queries and read pre-built analytics endpoints (KPIs, top pages, lifecycle, retention, revenue, etc.)", - "mcp:read": "MCP protocol access (analytics tools + management tools based on other scopes)" - } - }, - "paths": { - "/v0/alerts": { - "get": { - "operationId": "listAlerts", - "summary": "List alerts", - "description": "List alerts for the project scoped to the API key. Paginated: see `page` and `size` query params; the response carries `total` and `has_more` so callers can walk pages.", - "tags": [ - "Alerts" - ], - "x-required-scope": "alerts:read", - "parameters": [ - { "$ref": "#/components/parameters/Page" }, - { "$ref": "#/components/parameters/Size" } - ], - "responses": { - "200": { - "description": "Paginated list of alerts", - "content": { - "application/json": { - "schema": { - "allOf": [ - { "$ref": "#/components/schemas/PaginatedListMeta" }, - { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Alert" } - } - } - } - ] - }, - "example": { - "data": [ - { - "id": "alrt_4f8e2c1a9b3d4e5f", - "project_id": "proj_abc123", - "name": "Daily revenue drop", - "trigger_type": "event", - "status": "active", - "trigger_filters": [ - { - "name": "event", - "operator": "equals", - "value": "transaction" - }, - { - "name": "revenue", - "operator": "less_than", - "value": "1000", - "numericThreshold": "sum" - } - ], - "recipient": [ - { - "type": "email", - "value": ["alerts@myapp.com"] - }, - { - "type": "slack", - "value": ["C0123456789"] - } - ], - "has_secret": false, - "created_at": "2026-04-12T09:32:18.000Z", - "updated_at": "2026-04-25T14:01:55.000Z" - } - ], - "page": 1, - "size": 100, - "total": 1, - "has_more": false - } - } - } - } - } - }, - "post": { - "operationId": "createAlert", - "summary": "Create alert", - "description": "Create a new alert for the project.", - "tags": [ - "Alerts" - ], - "x-required-scope": "alerts:write", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "trigger_type": { - "type": "string", - "enum": [ - "event", - "user" - ] - }, - "trigger_filters": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "operator": { - "type": "string" - }, - "value": { - "type": "string" - }, - "numericThreshold": { - "type": "string" - } - }, - "required": [ - "name", - "value" - ] - } - }, - "recipient": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "email", - "slack", - "webhook" - ] - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "secret": { - "type": "string", - "description": "Webhook signing secret" - } - }, - "required": [ - "name", - "trigger_type", - "trigger_filters" - ] - }, - "examples": { - "eventAlertWithWebhook": { - "summary": "Alert on swap events from mobile devices with webhook", - "value": { - "name": "Mobile swap alert", - "trigger_type": "event", - "trigger_filters": [ - { - "name": "type", - "value": "swap", - "operator": "equals" - }, - { - "name": "device", - "value": "mobile", - "operator": "equals" - }, - { - "name": "volume", - "value": "1000", - "operator": "greater", - "numericThreshold": "1000" - } - ], - "recipient": [ - { - "type": "webhook", - "value": [ - "https://hooks.myapp.com/formo-alerts" - ] - }, - { - "type": "email", - "value": [ - "alerts@myapp.com" - ] - } - ], - "secret": "whsec_mysigningsecret123" - } - }, - "eventAlertWithUtmFilter": { - "summary": "Alert on events from specific UTM campaigns", - "value": { - "name": "Paid campaign activity", - "trigger_type": "event", - "trigger_filters": [ - { - "name": "type", - "value": "purchase" - }, - { - "name": "utm_source", - "value": "google", - "operator": "equals" - }, - { - "name": "utm_medium", - "value": "cpc", - "operator": "equals" - } - ], - "recipient": [ - { - "type": "slack", - "value": [ - "#marketing-alerts|https://hooks.slack.com/services/T00/B00/xxx" - ] - } - ] - } - }, - "userAlertWithLocationFilter": { - "summary": "Alert when new users spike from a specific country", - "value": { - "name": "US user spike", - "trigger_type": "user", - "trigger_filters": [ - { - "name": "location", - "value": "US", - "operator": "equals" - }, - { - "name": "net_worth_usd", - "value": "10000", - "operator": "greaterOrEqual", - "numericThreshold": "10000" - } - ], - "recipient": [ - { - "type": "email", - "value": [ - "team@myapp.com" - ] - } - ] - } - }, - "eventAlertWithNumericThreshold": { - "summary": "Alert when users hold >5 tokens in a specific app", - "value": { - "name": "High token holder alert", - "trigger_type": "event", - "trigger_filters": [ - { - "name": "apps", - "value": "uniswap", - "operator": "greater", - "numericThreshold": "5" - }, - { - "name": "chains", - "value": "1", - "operator": "greaterOrEqual", - "numericThreshold": "3" - } - ], - "recipient": [ - { - "type": "webhook", - "value": [ - "https://hooks.myapp.com/token-alerts" - ] - } - ] - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Alert created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Alert" - } - } - } - } - } - } - }, - "/v0/alerts/{alertId}": { - "get": { - "operationId": "getAlert", - "summary": "Get alert", - "tags": [ - "Alerts" - ], - "parameters": [ - { - "name": "alertId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Alert details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Alert" - }, - "example": { - "id": "alrt_4f8e2c1a9b3d4e5f", - "project_id": "proj_abc123", - "name": "Daily revenue drop", - "trigger_type": "event", - "status": "active", - "trigger_filters": [ - { - "name": "event", - "operator": "equals", - "value": "transaction" - }, - { - "name": "revenue", - "operator": "less_than", - "value": "1000", - "numericThreshold": "sum" - } - ], - "recipient": [ - { - "type": "email", - "value": [ - "alerts@myapp.com" - ] - }, - { - "type": "slack", - "value": [ - "C0123456789" - ] - } - ], - "has_secret": false, - "created_at": "2026-04-12T09:32:18.000Z", - "updated_at": "2026-04-25T14:01:55.000Z" - } - } - } - } - }, - "x-required-scope": "alerts:read" - }, - "put": { - "operationId": "updateAlert", - "summary": "Update alert", - "tags": [ - "Alerts" - ], - "parameters": [ - { - "name": "alertId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "trigger_type": { - "type": "string", - "enum": [ - "event", - "user" - ] - }, - "trigger_filters": { - "type": "array", - "items": { - "type": "object" - } - }, - "recipient": { - "type": "array", - "items": { - "type": "object" - } - }, - "secret": { - "type": "string" - } - }, - "required": [ - "name", - "trigger_type", - "trigger_filters" - ] - }, - "examples": { - "updateRecipients": { - "summary": "Update alert recipients", - "value": { - "name": "Mobile swap alert (updated)", - "trigger_type": "event", - "trigger_filters": [ - { - "name": "type", - "value": "swap" - }, - { - "name": "device", - "value": "mobile" - } - ], - "recipient": [ - { - "type": "webhook", - "value": [ - "https://hooks.myapp.com/v2/alerts" - ] - }, - { - "type": "slack", - "value": [ - "#alerts|https://hooks.slack.com/services/T00/B00/new" - ] - } - ] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Alert updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Alert" - } - } - } - } - }, - "x-required-scope": "alerts:write" - }, - "delete": { - "operationId": "deleteAlert", - "summary": "Delete alert", - "tags": [ - "Alerts" - ], - "parameters": [ - { - "name": "alertId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Alert deleted", - "content": { - "application/json": { - "schema": { - "type": "number" - } - } - } - } - }, - "x-required-scope": "alerts:write" - }, - "patch": { - "operationId": "toggleAlertStatus", - "summary": "Toggle alert status", - "tags": [ - "Alerts" - ], - "parameters": [ - { - "name": "alertId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "active", - "inactive" - ] - } - }, - "required": [ - "status" - ] - }, - "examples": { - "activate": { - "summary": "Activate alert", - "value": { - "status": "active" - } - }, - "deactivate": { - "summary": "Deactivate alert", - "value": { - "status": "inactive" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Alert status updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Alert" - } - } - } - } - }, - "x-required-scope": "alerts:write" - } - }, - "/v0/boards": { - "get": { - "operationId": "listBoards", - "summary": "List boards", - "description": "List boards for the project scoped to the API key. Paginated.", - "tags": [ - "Boards" - ], - "parameters": [ - { "$ref": "#/components/parameters/Page" }, - { "$ref": "#/components/parameters/Size" } - ], - "responses": { - "200": { - "description": "Paginated list of boards", - "content": { - "application/json": { - "schema": { - "allOf": [ - { "$ref": "#/components/schemas/PaginatedListMeta" }, - { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Board" } - } - } - } - ] - }, - "example": { - "data": [ - { - "id": "brd_a1b2c3d4e5f6", - "project_id": "proj_abc123", - "title": "Revenue Dashboard", - "description": "Weekly revenue, conversion, and retention metrics.", - "enabled": false, - "created_at": "2026-03-04T11:22:08.000Z", - "updated_at": "2026-04-22T08:14:31.000Z" - }, - { - "id": "brd_g7h8i9j0k1l2", - "project_id": "proj_abc123", - "title": "Marketing Funnel", - "description": "Acquisition channel performance.", - "enabled": true, - "created_at": "2026-02-18T16:09:44.000Z", - "updated_at": "2026-04-19T10:55:02.000Z" - } - ], - "page": 1, - "size": 100, - "total": 2, - "has_more": false - } - } - } - } - }, - "x-required-scope": "boards:read" - }, - "post": { - "operationId": "createBoard", - "summary": "Create board", - "description": "Create a new board with the given title.", - "tags": [ - "Boards" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["title"], - "properties": { - "title": { - "type": "string", - "minLength": 1, - "description": "Human-readable board name. Required." - }, - "description": { - "type": "string", - "description": "Optional longer description." - }, - "isPublic": { - "type": "boolean", - "description": "Whether the board is publicly viewable via its share URL. Defaults to false.", - "default": false - } - } - }, - "example": { - "title": "Weekly KPIs", - "description": "Headline metrics for the team standup.", - "isPublic": false - } - } - } - }, - "responses": { - "201": { - "description": "Board created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Board" - } - } - } - } - }, - "x-required-scope": "boards:write" - } - }, - "/v0/boards/{boardId}": { - "get": { - "operationId": "getBoard", - "summary": "Get board", - "tags": [ - "Boards" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Board details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Board" - }, - "example": { - "id": "brd_a1b2c3d4e5f6", - "project_id": "proj_abc123", - "title": "Revenue Dashboard", - "description": "Weekly revenue, conversion, and retention metrics.", - "enabled": false, - "created_at": "2026-03-04T11:22:08.000Z", - "updated_at": "2026-04-22T08:14:31.000Z" - } - } - } - } - }, - "x-required-scope": "boards:read" - }, - "patch": { - "operationId": "updateBoard", - "summary": "Update board", - "tags": [ - "Boards" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "isPublic": { - "type": "boolean", - "description": "Whether the board is publicly accessible" - } - } - }, - "examples": { - "updateTitle": { - "summary": "Update board title", - "value": { - "title": "Weekly Dashboard" - } - }, - "makePublic": { - "summary": "Make board publicly accessible", - "value": { - "isPublic": true - } - }, - "fullUpdate": { - "summary": "Update all fields", - "value": { - "title": "Revenue Dashboard", - "description": "Weekly revenue metrics and KPIs", - "isPublic": true - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Board updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Board" - } - } - } - } - }, - "x-required-scope": "boards:write", - "description": "Update board title, description, and/or public visibility." - }, - "delete": { - "operationId": "deleteBoard", - "summary": "Delete board", - "description": "Delete a board and all its charts.", - "tags": [ - "Boards" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Board deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Board" - } - } - } - } - }, - "x-required-scope": "boards:write" - } - }, - "/v0/boards/{boardId}/charts": { - "get": { - "operationId": "listCharts", - "summary": "List charts for a board", - "description": "List charts in a board with their executed query results. Paginated. Includes the parent `board` for caller convenience and an optional `warnings` sidecar carrying per-chart query failures (the failing charts are excluded from `data` so the page renders cleanly).", - "tags": [ - "Charts" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { "$ref": "#/components/parameters/Page" }, - { "$ref": "#/components/parameters/Size" } - ], - "responses": { - "200": { - "description": "Paginated charts plus parent board and any partial-failure warnings", - "content": { - "application/json": { - "schema": { - "allOf": [ - { "$ref": "#/components/schemas/PaginatedListMeta" }, - { - "type": "object", - "required": ["data", "board"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Chart" } - }, - "board": { "$ref": "#/components/schemas/Board" }, - "warnings": { - "type": "object", - "description": "Present only if some charts failed to execute. Failures are excluded from `data`.", - "properties": { - "failedCharts": { "type": "integer" }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "chartId": { "type": "string" }, - "error": { "type": "string" } - } - } - } - } - } - } - } - ] - }, - "example": { - "data": [ - { - "id": "cht_x1y2z3a4b5c6", - "project_id": "proj_abc123", - "board_id": "brd_a1b2c3d4e5f6", - "chart_type": "line", - "title": "Daily revenue (last 30 days)", - "description": null, - "query": "SELECT toDate(timestamp) AS day, sum(revenue) AS revenue FROM events WHERE event = 'transaction' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY day ORDER BY day", - "x_axis": "day", - "y_axis": ["revenue"], - "group_by": null, - "steps": null, - "settings": null - } - ], - "page": 1, - "size": 100, - "total": 1, - "has_more": false, - "board": { - "id": "brd_a1b2c3d4e5f6", - "project_id": "proj_abc123", - "title": "Revenue Dashboard", - "description": "Weekly revenue, conversion, and retention metrics.", - "enabled": false - } - } - } - } - } - }, - "x-required-scope": "boards:read" - }, - "post": { - "operationId": "createChart", - "summary": "Create chart", - "tags": [ - "Charts" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateChartRequest" - }, - "examples": { - "funnelBasic": { - "summary": "3-step closed funnel (7-day window)", - "value": { - "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" - } - } - } - }, - "funnelWithPropertyFilters": { - "summary": "Funnel with per-step property filters (MetaMask on mainnet)", - "value": { - "projectId": "proj_abc", - "query": "SELECT 1", - "chart_type": "funnel", - "title": "MetaMask Conversion Funnel", - "steps": [ - { - "type": "event", - "event": "page" - }, - { - "type": "event", - "event": "connect", - "rdns": { - "op": "equals", - "value": "io.metamask" - }, - "chain_id": { - "op": "equals", - "value": "1" - } - }, - { - "type": "event", - "event": "transaction" - } - ], - "settings": { - "funnelType": "closed", - "conversionWindow": { - "value": 7, - "unit": "day" - } - } - } - }, - "funnelWithBreakdown": { - "summary": "Funnel with device breakdown", - "value": { - "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" - } - } - }, - "funnelOpenMultiValue": { - "summary": "Open funnel with multi-value `in` filter and breakdown", - "value": { - "projectId": "proj_abc", - "query": "SELECT 1", - "chart_type": "funnel", - "title": "Mobile Onboarding Funnel", - "steps": [ - { - "type": "event", - "event": "page", - "device": { - "op": "equals", - "value": "mobile" - } - }, - { - "type": "event", - "event": "connect", - "provider_name": { - "op": "in", - "value": "metamask|rainbow|coinbase" - } - }, - { - "type": "event", - "event": "transaction" - } - ], - "settings": { - "funnelType": "open", - "conversionWindow": { - "value": 30, - "unit": "day" - }, - "breakdown": "device" - } - } - }, - "barChart": { - "summary": "Daily active users — bar chart", - "value": { - "projectId": "proj_abc", - "query": "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date", - "chart_type": "bar", - "title": "Daily Active Users", - "x_axis": "date", - "y_axis": [ - "users" - ] - } - }, - "lineChart": { - "summary": "DAU last 30 days — line chart", - "value": { - "projectId": "proj_abc", - "query": "SELECT toDate(timestamp) AS date, countDistinct(address) AS daily_active_users FROM events GROUP BY date ORDER BY date DESC LIMIT 30", - "chart_type": "line", - "title": "Daily Active Users", - "x_axis": "date", - "y_axis": [ - "daily_active_users" - ] - } - }, - "pieChart": { - "summary": "Sessions by device — pie chart", - "value": { - "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" - ] - } - }, - "stackedChart": { - "summary": "Sessions by device grouped by browser — stacked chart", - "value": { - "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" - } - }, - "numberChart": { - "summary": "Total connected wallets — number / KPI card", - "value": { - "projectId": "proj_abc", - "query": "SELECT COUNT(DISTINCT address) FROM events WHERE type = 'connect'", - "chart_type": "number", - "title": "Total Connected Wallets" - } - }, - "tableChart": { - "summary": "Recent events — table", - "value": { - "projectId": "proj_abc", - "query": "SELECT * FROM events ORDER BY timestamp DESC LIMIT 10", - "chart_type": "table", - "title": "Recent Events" - } - }, - "userPathsChart": { - "summary": "User flow from connect — max 5 steps", - "value": { - "projectId": "proj_abc", - "query": "SELECT anonymous_id, type AS event, timestamp FROM events WHERE timestamp >= today() - INTERVAL 30 DAY ORDER BY anonymous_id, timestamp", - "chart_type": "user_paths", - "title": "Post-Connect User Flow", - "settings": { - "startStep": { - "type": "event", - "event": "connect" - }, - "endStep": { - "type": "event", - "event": "transaction" - }, - "maxSteps": 5, - "conversionWindow": { - "value": 2, - "unit": "week" - } - } - } - }, - "userPathsOpenEnded": { - "summary": "Open-ended user flow from page view", - "value": { - "projectId": "proj_abc", - "query": "SELECT anonymous_id, type AS event, timestamp FROM events WHERE timestamp >= today() - INTERVAL 14 DAY ORDER BY anonymous_id, timestamp", - "chart_type": "user_paths", - "title": "User Discovery Paths", - "settings": { - "startStep": { - "type": "event", - "event": "page" - }, - "maxSteps": 8, - "nodesPerStep": 10 - } - } - }, - "retentionFiltered": { - "summary": "Weekly retention — desktop users, transaction event", - "value": { - "projectId": "proj_abc", - "query": "", - "chart_type": "retention", - "title": "Weekly Retention — Desktop", - "settings": { - "retentionFilter": { - "type": "event", - "event": "transaction" - }, - "retentionUserFilters": [ - { - "field": "device", - "op": "equals", - "value": "desktop" - } - ] - } - } - }, - "retentionUnfiltered": { - "summary": "Overall retention (no filters)", - "value": { - "projectId": "proj_abc", - "query": "", - "chart_type": "retention", - "title": "Overall Retention" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Chart created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Chart" - } - } - } - } - }, - "x-required-scope": "boards:write" - } - }, - "/v0/boards/{boardId}/charts/{chartId}": { - "get": { - "operationId": "getChart", - "summary": "Get chart", - "tags": [ - "Charts" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "chartId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Chart details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Chart" - }, - "example": { - "id": "cht_x1y2z3a4b5c6", - "project_id": "proj_abc123", - "board_id": "brd_a1b2c3d4e5f6", - "chart_type": "line", - "title": "Daily revenue (last 30 days)", - "description": null, - "query": "SELECT toDate(timestamp) AS day, sum(revenue) AS revenue FROM events WHERE event = 'transaction' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY day ORDER BY day", - "x_axis": "day", - "y_axis": [ - "revenue" - ], - "group_by": null, - "steps": null, - "settings": null - } - } - } - } - }, - "x-required-scope": "boards:read" - }, - "put": { - "operationId": "editChart", - "summary": "Edit chart", - "tags": [ - "Charts" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "chartId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateChartRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Chart updated", - "content": { - "application/json": { - "schema": { - "type": "string", - "description": "Chart ID" - } - } - } - } - }, - "x-required-scope": "boards:write" - }, - "delete": { - "operationId": "deleteChart", - "summary": "Delete chart", - "tags": [ - "Charts" - ], - "parameters": [ - { - "name": "boardId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "chartId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Chart deleted" - } - }, - "x-required-scope": "boards:write" - } - }, - "/v0/contracts": { - "get": { - "operationId": "listContracts", - "summary": "List contracts", - "description": "List monitored contracts. Paginated; the canonical `data` array carries the contracts for the current page. The `deploy` sidecar reports the project-wide deploy state (always reflects ALL contracts, not just this page) so callers can render \"X contracts pending deploy\" without a second request.", - "tags": [ - "Contracts" - ], - "parameters": [ - { "$ref": "#/components/parameters/Page" }, - { "$ref": "#/components/parameters/Size" } - ], - "responses": { - "200": { - "description": "Paginated list of contracts plus deploy-state sidecar", - "content": { - "application/json": { - "schema": { - "allOf": [ - { "$ref": "#/components/schemas/PaginatedListMeta" }, - { - "type": "object", - "required": ["data", "deploy"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Contract" } - }, - "deploy": { - "type": "object", - "required": ["last_deployed_at", "diff"], - "properties": { - "last_deployed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Timestamp of the project's last successful deploy, or null if no deploy has run yet." - }, - "diff": { - "type": "array", - "description": "Difference between the contracts currently registered for the project and what's actually deployed. Drives the \"contracts pending deploy\" UI.", - "items": { - "type": "object" - } - } - } - } - } - } - ] - }, - "example": { - "data": [ - { - "name": "USD Coin", - "chain": 1, - "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "start_block": 6082465, - "abi": "[{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"}]", - "events": [ - { - "anonymous": false, - "name": "Transfer", - "type": "event", - "inputs": [ - { - "indexed": true, - "name": "from", - "type": "address" - }, - { - "indexed": true, - "name": "to", - "type": "address" - }, - { - "indexed": false, - "name": "value", - "type": "uint256" - } - ] - } - ] - }, - { - "name": "WETH (Base)", - "chain": 8453, - "address": "0x4200000000000000000000000000000000000006", - "start_block": 1, - "abi": "[{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"}]", - "events": [ - { - "anonymous": false, - "name": "Transfer", - "type": "event", - "inputs": [ - { - "indexed": true, - "name": "from", - "type": "address" - }, - { - "indexed": true, - "name": "to", - "type": "address" - }, - { - "indexed": false, - "name": "value", - "type": "uint256" - } - ] - } - ] - } - ], - "page": 1, - "size": 100, - "total": 2, - "has_more": false, - "deploy": { - "last_deployed_at": "2026-04-22T08:14:31.000Z", - "diff": [] - } - } - } - } - } - }, - "x-required-scope": "contracts:read" - }, - "post": { - "operationId": "createContract", - "summary": "Create contract", - "description": "Add a blockchain contract to monitor.", - "tags": [ - "Contracts" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string", - "description": "EVM contract address" - }, - "chain": { - "type": "integer", - "description": "Chain ID" - }, - "name": { - "type": "string" - }, - "abi": { - "type": "string", - "description": "JSON-stringified ABI" - }, - "events": { - "type": "array", - "maxItems": 10, - "items": { - "type": "object", - "properties": { - "anonymous": { - "type": "boolean" - }, - "inputs": { - "type": "array", - "items": { - "type": "object" - } - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "anonymous", - "inputs", - "name", - "type" - ] - } - }, - "start_block": { - "type": "integer" - } - }, - "required": [ - "address", - "chain", - "name", - "abi", - "events" - ] - }, - "examples": { - "erc20Token": { - "summary": "Monitor ERC-20 Transfer events", - "value": { - "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "chain": 1, - "name": "USDC", - "abi": "[{\"anonymous\":false,\"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\"}],\"name\":\"Transfer\",\"type\":\"event\"}]", - "events": [ - { - "anonymous": false, - "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" - } - ], - "name": "Transfer", - "type": "event" - } - ], - "start_block": 18000000 - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Contract created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Contract" - } - } - } - } - }, - "x-required-scope": "contracts:write" - } - }, - "/v0/contracts/{chain}/{address}": { - "get": { - "operationId": "getContract", - "summary": "Get contract", - "description": "Fetch a single monitored contract by chain ID and address. Returns the bare `Contract` resource (no envelope).", - "tags": [ - "Contracts" - ], - "parameters": [ - { - "name": "chain", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - }, - "description": "EVM chain ID (e.g. `1` for Ethereum mainnet, `8453` for Base)." - }, - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^0x[0-9a-fA-F]{40}$" - }, - "description": "Contract address (0x-prefixed, 40 hex chars)." - } - ], - "responses": { - "200": { - "description": "Contract found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Contract" - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "500": { "$ref": "#/components/responses/InternalServerError" } - }, - "x-required-scope": "contracts:read" - }, - "put": { - "operationId": "updateContract", - "summary": "Update contract", - "tags": [ - "Contracts" - ], - "parameters": [ - { - "name": "chain", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "chain": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "abi": { - "type": "string" - }, - "events": { - "type": "array", - "maxItems": 10, - "items": { - "type": "object" - } - }, - "start_block": { - "type": "integer" - } - }, - "required": [ - "address", - "chain", - "name", - "abi", - "events" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Contract updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Contract" - } - } - } - } - }, - "x-required-scope": "contracts:write" - }, - "delete": { - "operationId": "deleteContract", - "summary": "Delete contract", - "tags": [ - "Contracts" - ], - "parameters": [ - { - "name": "chain", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Contract deleted", - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - } - }, - "x-required-scope": "contracts:write" - } - }, - "/v0/segments": { - "get": { - "operationId": "listSegments", - "summary": "List segments", - "description": "List user segments for the project scoped to the API key. Paginated.", - "tags": [ - "Segments" - ], - "parameters": [ - { "$ref": "#/components/parameters/Page" }, - { "$ref": "#/components/parameters/Size" } - ], - "responses": { - "200": { - "description": "Paginated list of segments", - "content": { - "application/json": { - "schema": { - "allOf": [ - { "$ref": "#/components/schemas/PaginatedListMeta" }, - { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Segment" } - } - } - } - ] - }, - "example": { - "data": [ - { - "id": "seg_e3f4g5h6i7j8", - "projectId": "proj_abc123", - "title": "High net worth desktop users", - "filterSet": [ - "{\"field\":\"device\",\"op\":\"equals\",\"value\":\"desktop\"}", - "{\"field\":\"net_worth_usd\",\"op\":\"greaterOrEqual\",\"value\":100000}" - ] - } - ], - "page": 1, - "size": 100, - "total": 1, - "has_more": false - } - } - } - } - }, - "x-required-scope": "segments:read" - }, - "post": { - "operationId": "createSegment", - "summary": "Create segment", - "tags": [ - "Segments" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string", - "minLength": 1 - }, - "filterSets": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "required": [ - "title", - "filterSets" - ] - }, - "examples": { - "highValueMobileUsers": { - "summary": "High-value mobile users from paid campaigns", - "value": { - "title": "High-value mobile users", - "filterSets": [ - "device::equals::mobile", - "net_worth_usd::greaterOrEqual::10000", - "utm_source::equals::paid_ads" - ] - } - }, - "multiDeviceChromeSafari": { - "summary": "Chrome or Safari users (multi-value filter)", - "value": { - "title": "Chrome/Safari users", - "filterSets": [ - "browser::in::Chrome|Safari" - ] - } - }, - "excludeUSUsers": { - "summary": "All users except from the US", - "value": { - "title": "Non-US users", - "filterSets": [ - "location::notEquals::US" - ] - } - }, - "powerUsers": { - "summary": "Power users with high net worth from organic traffic", - "value": { - "title": "Organic power users", - "filterSets": [ - "net_worth_usd::greater::1000", - "utm_source::notIn::google_ads|facebook_ads", - "lifecycle::equals::power" - ] - } - }, - "multiChainWhaleUsers": { - "summary": "Users active on 3+ chains with high net worth", - "value": { - "title": "Multi-chain whales", - "filterSets": [ - "chains::greaterOrEqual::3", - "net_worth_usd::greater::100000", - "apps::greater::5" - ] - } - }, - "behavioralFilterSimple": { - "summary": "Users who performed 'signature' event at least once in last 30 days", - "description": "Behavioural filters use the 'events' filter key with a base64-encoded JSON array as the value. The JSON contains event name, frequency operator (greater/greaterOrEqual/equals/lessOrEqual/less), times count, and date range.", - "value": { - "title": "Recent signers", - "filterSets": [ - "events::equals::W3siZXZlbnQiOiAic2lnbmF0dXJlIiwgIm9wZXJhdG9yIjogImdyZWF0ZXJPckVxdWFsIiwgInRpbWVzIjogMSwgInByZXNldCI6ICJsYXN0XzMwZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0wOS0xOSIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifV0=" - ] - } - }, - "behavioralFilterWithProperties": { - "summary": "Users who connected via MetaMask on Ethereum mainnet + from India", - "description": "Combines a behavioural filter (type=connect event with property filters) and a demographic filter (location). The 'event' field uses the event type key (e.g. 'connect', 'signature', 'transaction', 'page'), not the display label. Event property filters use {op, value} objects as additional keys.", - "value": { - "title": "Indian MetaMask users", - "filterSets": [ - "events::equals::W3siZXZlbnQiOiAiY29ubmVjdCIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5IiwgInJkbnMiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICJpby5tZXRhbWFzayJ9LCAiY2hhaW5faWQiOiB7Im9wIjogImVxdWFscyIsICJ2YWx1ZSI6ICIxIn19XQ==", - "location::equals::IN" - ] - } - }, - "behavioralFilterMultipleEvents": { - "summary": "Users with 5+ page views last week AND at least 1 transaction last month", - "description": "Multiple behavioural events in a single filter. All conditions in the array must be met (AND logic). Valid event types: page, screen, connect, disconnect, chain, signature, transaction, track, decoded_log, detect, identify.", - "value": { - "title": "Active transactors", - "filterSets": [ - "events::equals::W3siZXZlbnQiOiAicGFnZSIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDUsICJwcmVzZXQiOiAibGFzdF83ZCIsICJkYXRlX2Zyb20iOiAiMjAyNS0xMC0xMiIsICJkYXRlX3RvIjogIjIwMjUtMTAtMTkifSwgeyJldmVudCI6ICJ0cmFuc2FjdGlvbiIsICJvcGVyYXRvciI6ICJncmVhdGVyT3JFcXVhbCIsICJ0aW1lcyI6IDEsICJwcmVzZXQiOiAibGFzdF8zMGQiLCAiZGF0ZV9mcm9tIjogIjIwMjUtMDktMTkiLCAiZGF0ZV90byI6ICIyMDI1LTEwLTE5In1d", - "device::equals::desktop" - ] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Segment created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Segment" - } - } - } - } - }, - "x-required-scope": "segments:write" - } - }, - "/v0/segments/{segmentId}": { - "delete": { - "operationId": "deleteSegment", - "summary": "Delete segment", - "tags": [ - "Segments" - ], - "parameters": [ - { - "name": "segmentId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Segment deleted", - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - } - }, - "x-required-scope": "segments:write" - } - }, - "/v0/profiles": { - "get": { - "operationId": "searchProfiles", - "summary": "Search wallet profiles", - "tags": [ - "Profiles" - ], - "responses": { - "200": { - "description": "Paginated wallet profile search results.", - "content": { - "application/json": { - "schema": { - "allOf": [ - { "$ref": "#/components/schemas/PaginatedListMeta" }, - { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Profile" } - } - } - } - ] - }, - "example": { - "data": [ - { - "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "net_worth_usd": 12345.67 - } - ], - "page": 1, - "size": 100, - "total": 1, - "has_more": false - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "x-required-scope": "profiles:read", - "parameters": [ - { - "name": "address", - "in": "query", - "schema": { - "type": "string" - }, - "description": "Filter by wallet address" - }, - { - "name": "expand", - "in": "query", - "schema": { - "type": "string" - }, - "description": "Comma-separated: apps, chains, tokens, labels" - }, - { - "name": "order_by", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "net_worth_usd", - "tx_count", - "first_onchain", - "last_onchain", - "updated_at", - "first_seen", - "last_seen", - "num_sessions", - "revenue", - "volume", - "points" - ] - }, - "description": "Sort field" - }, - { - "name": "order_dir", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ] - }, - "description": "Sort direction" - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "default": 1, - "minimum": 1 - }, - "description": "1-indexed page number (default 1)." - }, - { - "name": "size", - "in": "query", - "schema": { - "type": "integer", - "default": 100, - "minimum": 1, - "maximum": 1000 - }, - "description": "Page size (default 100, max 1000)." - } - ], - "requestBody": { - "required": false, - "description": "Optional filter conditions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProfileFilter" - }, - "examples": { - "highNetWorth": { - "summary": "Users with >$10k net worth", - "value": { - "conditions": [ - { - "field": "users.net_worth_usd", - "op": "gt", - "value": 10000 - } - ], - "logic": "and" - } - }, - "chainFilter": { - "summary": "Users active on Ethereum with >$1k balance", - "value": { - "conditions": [ - { - "field": "chains.1.balance", - "op": "gt", - "value": 1000 - } - ], - "logic": "and" - } - }, - "labelFilter": { - "summary": "Coinbase verified users", - "value": { - "conditions": [ - { - "field": "labels.coinbase.verified_account", - "op": "eq", - "value": "true" - } - ], - "logic": "and" - } - }, - "tokenFilter": { - "summary": "Users holding USDC in any protocol", - "value": { - "conditions": [ - { - "field": "tokens.0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.balance", - "op": "gt", - "value": 0, - "scope": "any" - } - ], - "logic": "and" - } - } - } - } - } - } - } - }, - "/v0/profiles/{address}": { - "get": { - "operationId": "getProfile", - "summary": "Get wallet profile", - "tags": [ - "Profiles" - ], - "parameters": [ - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Wallet address" - }, - { - "name": "expand", - "in": "query", - "schema": { - "type": "string" - }, - "description": "Comma-separated: apps, chains, tokens, labels" - } - ], - "responses": { - "200": { - "description": "Profile details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Profile" - }, - "example": { - "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", - "ens": "vitalik.eth", - "display_name": "vitalik.eth", - "avatar": "https://euc.li/vitalik.eth", - "description": "mi pinxe lo crino tcati", - "location": "Earth", - "net_worth_usd": 1581720.97, - "tx_count": 18743, - "first_onchain": "2015-09-28T08:24:43.000Z", - "last_onchain": "2026-04-01T03:34:47.000Z", - "twitter": "vitalikbuterin", - "farcaster": "vitalik.eth", - "lens": "vitalik.lens", - "basenames": "vb62831.base.eth", - "linea": null, - "github": "vbuterin", - "reddit": null, - "linkedin": null, - "telegram": null, - "discord": null, - "email": null, - "website": "vitalik.ca", - "youtube": null, - "tiktok": null, - "instagram": null, - "facebook": null, - "first_seen": "2025-09-14T03:11:42.000Z", - "last_seen": "2026-04-26T18:09:53.000Z", - "lifecycle": "Returning", - "num_sessions": 12, - "revenue": 0, - "volume": 482.55, - "points": 0, - "device": "desktop", - "browser": "brave", - "os": "macOS", - "first_utm_source": "twitter", - "first_utm_medium": "social", - "first_utm_campaign": "devcon-launch", - "first_utm_content": null, - "first_utm_term": null, - "first_referrer": "twitter.com", - "first_referrer_url": "https://twitter.com/vitalikbuterin/status/1234567890", - "first_ref": null, - "last_utm_source": "direct", - "last_utm_medium": null, - "last_utm_campaign": null, - "last_utm_content": null, - "last_utm_term": null, - "last_referrer": null, - "last_referrer_url": null, - "last_ref": null, - "last_type": "track", - "last_event": "Swap Confirmed", - "last_properties": "{\"chain_id\":1,\"volume\":482.55}", - "activity_dates": [ - "2026-04-22", - "2026-04-23", - "2026-04-25", - "2026-04-26" - ], - "chains": [ - { - "chain_id": "1", - "net_worth_usd": 1491971.89, - "tx_count": 1655, - "first_onchain": "2015-09-28T08:24:43.000Z", - "last_onchain": "2026-04-01T03:34:47.000Z" - }, - { - "chain_id": "8453", - "net_worth_usd": 41871.88, - "tx_count": 16, - "first_onchain": "2023-07-30T12:40:39.000Z", - "last_onchain": "2026-02-10T22:48:51.000Z" - }, - { - "chain_id": "56", - "net_worth_usd": 21108.72, - "tx_count": 8, - "first_onchain": "2022-10-21T13:52:11.000Z", - "last_onchain": "2025-12-20T16:00:26.000Z" - }, - { - "chain_id": "10", - "net_worth_usd": 16668.39, - "tx_count": 40, - "first_onchain": "2021-12-17T15:15:45.000Z", - "last_onchain": "2026-01-13T07:42:51.000Z" - } - ], - "apps": [ - { - "chain_id": "1", - "id": "uniswap-v3", - "name": "Uniswap V3", - "img": "https://cdn.formo.so/apps/uniswap.png", - "url": "https://app.uniswap.org", - "balance_usd": 124820.41 - }, - { - "chain_id": "1", - "id": "aave-v3", - "name": "Aave V3", - "img": "https://cdn.formo.so/apps/aave.png", - "url": "https://app.aave.com", - "balance_usd": 88421.07 - } - ], - "tokens": [ - { - "chain_id": "1", - "token_address": "0x0000000000000000000000000000000000000000", - "app_id": "", - "name": "Ethereum", - "symbol": "ETH", - "img": "https://cdn.formo.so/tokens/eth.png", - "decimals": 18, - "price": 3290.42, - "balance": "184.732910421054811234", - "balance_usd": 607823.55 - }, - { - "chain_id": "1", - "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "app_id": "", - "name": "USD Coin", - "symbol": "USDC", - "img": "https://cdn.formo.so/tokens/usdc.png", - "decimals": 6, - "price": 1, - "balance": "125804.520000", - "balance_usd": 125804.52 - } - ], - "labels": [ - { - "id": "ethereum.founder", - "value": "", - "chain_id": "-", - "source": "system" - }, - { - "id": "whale", - "value": "", - "chain_id": "-", - "source": "formo" - }, - { - "id": "coinbase.verified_account", - "value": "true", - "chain_id": "1", - "source": "coinbase" - } - ], - "updated_at": "2026-04-27T01:00:00.000Z" - } - } - } - }, - "404": { - "description": "Profile not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - }, - "x-required-scope": "profiles:read" - } - }, - "/v0/profiles/{address}/properties": { - "put": { - "operationId": "updateUserProperties", - "summary": "Update user properties", - "description": "Set first-party properties for a wallet profile. Override display name, email, socials, avatar, location, and other identity fields.", - "tags": [ - "Profiles" - ], - "parameters": [ - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Wallet address (EVM 0x... or Solana base58)" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Merge-update of profile properties. Only the listed keys are accepted; unknown keys are rejected. At least one key must be provided.", - "minProperties": 1, - "additionalProperties": false, - "properties": { - "user_id": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "farcaster": { - "type": "string" - }, - "discord": { - "type": "string" - }, - "twitter": { - "type": "string" - }, - "telegram": { - "type": "string" - }, - "instagram": { - "type": "string" - }, - "website": { - "type": "string" - }, - "github": { - "type": "string" - }, - "linkedin": { - "type": "string" - }, - "facebook": { - "type": "string" - }, - "tiktok": { - "type": "string" - }, - "youtube": { - "type": "string" - }, - "reddit": { - "type": "string" - }, - "avatar": { - "type": "string" - }, - "description": { - "type": "string" - }, - "location": { - "type": "string" - }, - "ens": { - "type": "string" - }, - "lens": { - "type": "string" - }, - "basenames": { - "type": "string" - }, - "linea": { - "type": "string" - } - } - }, - "examples": { - "updateProperties": { - "summary": "Set display name and email", - "value": { - "display_name": "alice.eth", - "email": "alice@example.com", - "twitter": "alice" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Properties updated", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Invalid request (bad address, empty body, or no allowed keys)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "401": { - "description": "Missing or invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "API key lacks the required scope", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "404": { - "description": "Project not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - }, - "x-required-scope": "profiles:write" - } - }, - "/v0/profiles/{address}/labels": { - "post": { - "operationId": "upsertUserLabel", - "summary": "Add or update user labels", - "description": "Upsert one or more labels for a wallet. Accepts either a single label object or an array of labels. Labels with the same tag_id (and chain_id, if provided) are overwritten. Requires profiles:write scope.", - "tags": [ - "Profiles" - ], - "parameters": [ - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Wallet address (EVM 0x... or Solana base58)" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserLabelInput" - }, - { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserLabelInput" - }, - "minItems": 1 - } - ] - }, - "examples": { - "singleLabel": { - "summary": "Upsert a single label", - "value": { - "tag_id": "vip", - "value": "tier-1" - } - }, - "multipleLabels": { - "summary": "Upsert multiple labels", - "value": [ - { - "tag_id": "vip", - "value": "tier-1" - }, - { - "tag_id": "airdrop_eligible", - "value": "season-2", - "chain_id": "1" - } - ] - } - } - } - } - }, - "responses": { - "200": { - "description": "Labels upserted", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Invalid request (bad address, missing tag_id, etc.)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "401": { - "description": "Missing or invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "API key lacks the required scope", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "404": { - "description": "Project not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - }, - "x-required-scope": "profiles:write" - }, - "delete": { - "operationId": "deleteUserLabel", - "summary": "Delete a user label", - "description": "Delete a label from a wallet. Pass chain_id to scope the deletion to a specific chain; omit it to match labels without a chain scope. Requires profiles:write scope.", - "tags": [ - "Profiles" - ], - "parameters": [ - { - "name": "address", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Wallet address (EVM 0x... or Solana base58)" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "tag_id" - ], - "properties": { - "tag_id": { - "type": "string", - "description": "Label identifier to delete" - }, - "chain_id": { - "type": "string", - "description": "Optional chain identifier to scope the deletion" - } - } - }, - "examples": { - "deleteLabel": { - "summary": "Delete a label", - "value": { - "tag_id": "vip" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Label deleted", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Invalid request (bad address or missing tag_id)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "401": { - "description": "Missing or invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "API key lacks the required scope", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "404": { - "description": "Project not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - }, - "x-required-scope": "profiles:write" - } - }, - "/v0/import": { - "post": { - "operationId": "importWallets", - "summary": "Import wallet addresses", - "description": "Import wallet addresses into the project. Requires profiles:write scope and Scale/Enterprise plan.", - "tags": [ - "Profiles" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "addresses": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Wallet addresses to import" - }, - "writeKey": { - "type": "string", - "description": "SDK write key" - } - }, - "required": [ - "addresses", - "writeKey" - ] - }, - "examples": { - "importWallets": { - "summary": "Import wallet addresses", - "value": { - "addresses": [ - "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", - "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B" - ], - "writeKey": "sk_write_xxxxx" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Wallets imported successfully" - } - }, - "x-required-scope": "profiles:write" - } - }, - "/v0/query": { - "post": { - "operationId": "executeQuery", - "summary": "Execute SQL query", - "description": "Execute a SQL query against the project's analytics data. Only SELECT and WITH statements are allowed. LIMIT is capped at 1,000,000. Forbidden keywords: INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, etc.", - "tags": [ - "Query" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "SQL query to execute" - } - }, - "required": [ - "query" - ] - }, - "examples": { - "dailyActiveUsers": { - "summary": "Daily active users over last 30 days", - "value": { - "query": "SELECT toDate(timestamp) as date, uniq(anonymous_id) as dau FROM events WHERE timestamp >= now() - interval 30 day GROUP BY date ORDER BY date" - } - }, - "topEvents": { - "summary": "Top 10 events by count", - "value": { - "query": "SELECT event, count() as total FROM events WHERE timestamp >= now() - interval 7 day GROUP BY event ORDER BY total DESC LIMIT 10" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Offset-paginated query results. The server doesn't own pagination here — `LIMIT` and `OFFSET` come from your SQL string and are echoed back. `total` is the row count before LIMIT was applied (Tinybird's `rows_before_limit_at_least`); `has_more` is true when there are additional rows beyond the current window.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "total", - "limit", - "offset", - "has_more" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Result rows." - }, - "total": { - "type": "integer", - "description": "Total rows before LIMIT was applied." - }, - "limit": { - "type": "integer", - "description": "Applied LIMIT (parsed from your SQL; defaults to the server cap if absent)." - }, - "offset": { - "type": "integer", - "description": "Applied OFFSET (parsed from your SQL; 0 if absent)." - }, - "has_more": { - "type": "boolean", - "description": "True when `offset + data.length < total` — i.e. there's another page to fetch by re-running with a higher OFFSET." - } - } - }, - "example": { - "data": [ - { - "day": "2026-04-21", - "users": 1284 - }, - { - "day": "2026-04-22", - "users": 1352 - }, - { - "day": "2026-04-23", - "users": 1411 - } - ], - "total": 7, - "limit": 100, - "offset": 0, - "has_more": false - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "x-required-scope": "query:read" - } - }, - "/v0/kpis": { - "get": { - "operationId": "getAnalyticsKpis", - "summary": "Get KPIs (visitors, pageviews, bounce rate, session duration)", - "description": "Time-series traffic KPIs with optional dimension breakdown. Returns visitors, pageviews, bounce rate and average session length.", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "name": "group_by", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "referrer", - "location", - "device", - "browser", - "os", - "utm_source", - "utm_medium", - "utm_campaign" - ] - }, - "description": "Dimension to break down by" - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "include_previous_period", - "in": "query", - "schema": { - "type": "boolean" - }, - "description": "Return current and previous period for WoW comparison" - } - ], - "responses": { - "200": { - "description": "KPI time-series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "date", - "type": "Date" - }, - { - "name": "project_id", - "type": "String" - }, - { - "name": "visitors", - "type": "UInt64" - }, - { - "name": "pageviews", - "type": "UInt64" - }, - { - "name": "bounce_rate", - "type": "Float64" - }, - { - "name": "avg_session_sec", - "type": "Float64" - } - ], - "data": [ - { - "date": "2025-10-15", - "project_id": "proj_abc", - "visitors": 412, - "pageviews": 1187, - "bounce_rate": 0.31, - "avg_session_sec": 142.6 - }, - { - "date": "2025-10-16", - "project_id": "proj_abc", - "visitors": 487, - "pageviews": 1402, - "bounce_rate": 0.28, - "avg_session_sec": 156.2 - }, - { - "date": "2025-10-17", - "project_id": "proj_abc", - "visitors": 533, - "pageviews": 1610, - "bounce_rate": 0.26, - "avg_session_sec": 168.4 - } - ], - "rows": 3, - "rows_before_limit_at_least": 3, - "statistics": { - "elapsed": 0.041, - "rows_read": 12340, - "bytes_read": 482310 - } - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/user_lifecycle": { - "get": { - "operationId": "getAnalyticsUserLifecycle", - "summary": "Get user lifecycle stages (New / Returning / Power / Resurrected / Churned)", - "description": "Counts wallet users by lifecycle stage based on activity within the date range. The reference date is `date_to`.", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "name": "lifecycle_filter", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "New", - "Returning", - "Power user", - "Resurrected", - "Churned" - ] - } - }, - { - "name": "include_previous_period", - "in": "query", - "schema": { - "type": "boolean" - }, - "description": "Return current and previous period for WoW comparison" - } - ], - "responses": { - "200": { - "description": "Lifecycle counts", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "user_type", - "type": "String" - }, - { - "name": "user_count", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "user_type": "New", - "user_count": 184 - }, - { - "project_id": "proj_abc", - "user_type": "Returning", - "user_count": 76 - }, - { - "project_id": "proj_abc", - "user_type": "Power user", - "user_count": 23 - }, - { - "project_id": "proj_abc", - "user_type": "Resurrected", - "user_count": 11 - }, - { - "project_id": "proj_abc", - "user_type": "Churned", - "user_count": 142 - } - ], - "rows": 5, - "rows_before_limit_at_least": 5 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/revenue_overview": { - "get": { - "operationId": "getAnalyticsRevenueOverview", - "summary": "Get revenue and transaction volume time-series", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "name": "group_by", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "rank_by", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "include_previous_period", - "in": "query", - "schema": { - "type": "boolean" - }, - "description": "Return current and previous period for WoW comparison" - } - ], - "responses": { - "200": { - "description": "Revenue and volume time-series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "date", - "type": "Date" - }, - { - "name": "project_id", - "type": "String" - }, - { - "name": "revenue", - "type": "Float32" - }, - { - "name": "volume", - "type": "Float32" - } - ], - "data": [ - { - "date": "2025-10-15", - "project_id": "proj_abc", - "revenue": 1284.55, - "volume": 412800 - }, - { - "date": "2025-10-16", - "project_id": "proj_abc", - "revenue": 1567.2, - "volume": 506100 - }, - { - "date": "2025-10-17", - "project_id": "proj_abc", - "revenue": 1893.75, - "volume": 612400 - } - ], - "rows": 3, - "rows_before_limit_at_least": 3 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/revenue_timeseries": { - "get": { - "operationId": "getAnalyticsRevenueTimeseries", - "summary": "Get revenue trend over time", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - } - ], - "responses": { - "200": { - "description": "Revenue time-series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "date", - "type": "Date" - }, - { - "name": "event", - "type": "String" - }, - { - "name": "page_path", - "type": "String" - }, - { - "name": "referrer", - "type": "String" - }, - { - "name": "utm_source", - "type": "String" - }, - { - "name": "revenue", - "type": "Float32" - }, - { - "name": "volume", - "type": "Float32" - } - ], - "data": [ - { - "date": "2025-10-15", - "event": "swap", - "page_path": "/trade", - "referrer": "google.com", - "utm_source": "organic", - "revenue": 215.3, - "volume": 68000 - }, - { - "date": "2025-10-15", - "event": "stake", - "page_path": "/earn", - "referrer": "", - "utm_source": "", - "revenue": 42.1, - "volume": 12500 - }, - { - "date": "2025-10-16", - "event": "swap", - "page_path": "/trade", - "referrer": "twitter.com", - "utm_source": "twitter", - "revenue": 318.75, - "volume": 102400 - } - ], - "rows": 3, - "rows_before_limit_at_least": 3 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/event_timeseries": { - "get": { - "operationId": "getAnalyticsEventTimeseries", - "summary": "Get event count time-series", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - } - ], - "responses": { - "200": { - "description": "Event time-series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "date", - "type": "Date" - }, - { - "name": "event_key", - "type": "String" - }, - { - "name": "count", - "type": "UInt64" - } - ], - "data": [ - { - "date": "2025-10-15", - "event_key": "page", - "count": 1187 - }, - { - "date": "2025-10-15", - "event_key": "wallet_connect", - "count": 124 - }, - { - "date": "2025-10-15", - "event_key": "swap", - "count": 38 - }, - { - "date": "2025-10-15", - "event_key": "identify", - "count": 412 - } - ], - "rows": 4, - "rows_before_limit_at_least": 4 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/retention": { - "get": { - "operationId": "getAnalyticsRetention", - "summary": "Get user retention cohorts", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - } - ], - "responses": { - "200": { - "description": "Retention cohorts", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "metric", - "type": "Tuple(project_id String, user_count UInt64, day7_retained UInt64, day30_retained UInt64, day90_retained UInt64, day7_retention_rate Float64, day30_retention_rate Float64, day90_retention_rate Float64)" - }, - { - "name": "user", - "type": "Array(Tuple(project_id String, cohort_week Date, num_users UInt64, week_0 Float64, week_1 Float64, week_2 Float64, week_3 Float64, week_4 Float64, week_5 Float64, week_6 Float64, week_7 Float64, week_8 Float64, week_9 Float64, week_10 Float64, week_11 Float64, week_12 Float64))" - } - ], - "data": [ - { - "metric": [ - "proj_abc", - 1284, - 412, - 198, - 76, - 0.32, - 0.154, - 0.059 - ], - "user": [ - [ - "proj_abc", - "2025-07-28", - 142, - 100, - 48, - 32, - 22, - 18, - 14, - 12, - 11, - 9, - 8, - 7, - 6, - null, - null - ], - [ - "proj_abc", - "2025-08-04", - 178, - 100, - 52, - 36, - 26, - 20, - 16, - 14, - 12, - 10, - 9, - 8, - null, - null, - null - ], - [ - "proj_abc", - "2025-08-11", - 165, - 100, - 50, - 34, - 24, - 19, - 15, - 13, - 11, - null, - null, - null, - null, - null, - null - ] - ] - } - ], - "rows": 1, - "rows_before_limit_at_least": 1 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/user_frequency": { - "get": { - "operationId": "getAnalyticsUserFrequency", - "summary": "Get visit-frequency distribution", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - } - ], - "responses": { - "200": { - "description": "Visit frequency distribution", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "session_bucket", - "type": "String" - }, - { - "name": "user_count", - "type": "UInt64" - }, - { - "name": "avg_session_per_user", - "type": "Float64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "session_bucket": "1 session", - "user_count": 412, - "avg_session_per_user": 1 - }, - { - "project_id": "proj_abc", - "session_bucket": "2 - 10 sessions", - "user_count": 287, - "avg_session_per_user": 4.6 - }, - { - "project_id": "proj_abc", - "session_bucket": "10 - 30 sessions", - "user_count": 76, - "avg_session_per_user": 16.2 - }, - { - "project_id": "proj_abc", - "session_bucket": "30 - 50 sessions", - "user_count": 18, - "avg_session_per_user": 38.4 - }, - { - "project_id": "proj_abc", - "session_bucket": "> 50 sessions", - "user_count": 5, - "avg_session_per_user": 84 - } - ], - "rows": 5, - "rows_before_limit_at_least": 5 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/top_pages": { - "get": { - "operationId": "getAnalyticsTopPages", - "summary": "Get top pages by visits or users", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Top pages", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "origin", - "type": "String" - }, - { - "name": "pathname", - "type": "String" - }, - { - "name": "visits", - "type": "UInt64" - }, - { - "name": "users", - "type": "UInt64" - }, - { - "name": "hits", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "origin": "app.example.com", - "pathname": "/", - "visits": 1284, - "users": 982, - "hits": 1456 - }, - { - "project_id": "proj_abc", - "origin": "app.example.com", - "pathname": "/trade", - "visits": 612, - "users": 487, - "hits": 1024 - }, - { - "project_id": "proj_abc", - "origin": "app.example.com", - "pathname": "/earn", - "visits": 412, - "users": 318, - "hits": 587 - } - ], - "rows": 3, - "rows_before_limit_at_least": 24 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/top_sources": { - "get": { - "operationId": "getAnalyticsTopSources", - "summary": "Get top traffic sources (referrers / utm)", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "name": "metric_column", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "referrer", - "referrer_url", - "ref", - "utm_source", - "utm_medium", - "utm_campaign", - "utm_term", - "utm_content", - "origin", - "device", - "browser", - "os", - "channel" - ] - }, - "description": "Column to break down sources by. Use `channel` for the 12-channel acquisition classifier (see docs/channels.md)." - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Top sources", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "referrer", - "type": "String" - }, - { - "name": "visits", - "type": "UInt64" - }, - { - "name": "users", - "type": "UInt64" - }, - { - "name": "hits", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "referrer": "Direct", - "visits": 612, - "users": 487, - "hits": 1024 - }, - { - "project_id": "proj_abc", - "referrer": "google.com", - "visits": 312, - "users": 268, - "hits": 542 - }, - { - "project_id": "proj_abc", - "referrer": "twitter.com", - "visits": 184, - "users": 154, - "hits": 312 - }, - { - "project_id": "proj_abc", - "referrer": "farcaster.xyz", - "visits": 98, - "users": 86, - "hits": 167 - } - ], - "rows": 4, - "rows_before_limit_at_least": 18 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/top_locations": { - "get": { - "operationId": "getAnalyticsTopLocations", - "summary": "Get top countries / regions / cities", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Top locations", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "location", - "type": "String" - }, - { - "name": "visits", - "type": "UInt64" - }, - { - "name": "users", - "type": "UInt64" - }, - { - "name": "hits", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "location": "US", - "visits": 482, - "users": 387, - "hits": 824 - }, - { - "project_id": "proj_abc", - "location": "GB", - "visits": 184, - "users": 156, - "hits": 312 - }, - { - "project_id": "proj_abc", - "location": "DE", - "visits": 142, - "users": 124, - "hits": 268 - }, - { - "project_id": "proj_abc", - "location": "JP", - "visits": 98, - "users": 86, - "hits": 187 - } - ], - "rows": 4, - "rows_before_limit_at_least": 32 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/top_wallets": { - "get": { - "operationId": "getAnalyticsTopWallets", - "summary": "Get top wallet types", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Top wallets", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "rdns", - "type": "String" - }, - { - "name": "visits", - "type": "UInt64" - }, - { - "name": "users", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "rdns": "io.metamask", - "visits": 412, - "users": 318 - }, - { - "project_id": "proj_abc", - "rdns": "com.coinbase.wallet", - "visits": 184, - "users": 142 - }, - { - "project_id": "proj_abc", - "rdns": "me.rainbow", - "visits": 98, - "users": 76 - }, - { - "project_id": "proj_abc", - "rdns": "app.phantom", - "visits": 87, - "users": 64 - } - ], - "rows": 4, - "rows_before_limit_at_least": 12 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/top_chains": { - "get": { - "operationId": "getAnalyticsTopChains", - "summary": "Get top blockchain chains", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Top chains", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "chain_id", - "type": "String" - }, - { - "name": "visits", - "type": "UInt64" - }, - { - "name": "users", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "chain_id": "1", - "visits": 482, - "users": 387 - }, - { - "project_id": "proj_abc", - "chain_id": "8453", - "visits": 312, - "users": 268 - }, - { - "project_id": "proj_abc", - "chain_id": "42161", - "visits": 184, - "users": 154 - }, - { - "project_id": "proj_abc", - "chain_id": "137", - "visits": 142, - "users": 118 - } - ], - "rows": 4, - "rows_before_limit_at_least": 9 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/top_events": { - "get": { - "operationId": "getAnalyticsTopEvents", - "summary": "Get top events by frequency", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - }, - { - "name": "type", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "custom" - ] - }, - "description": "Pass 'custom' to filter to custom track events only (events with type='track' and a non-empty event name). Omit for all events." - } - ], - "responses": { - "200": { - "description": "Top events", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "type", - "type": "String" - }, - { - "name": "event", - "type": "String" - }, - { - "name": "hits", - "type": "UInt64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "type": "page", - "event": "", - "hits": 4128 - }, - { - "project_id": "proj_abc", - "type": "identify", - "event": "", - "hits": 1284 - }, - { - "project_id": "proj_abc", - "type": "track", - "event": "wallet_connect", - "hits": 612 - }, - { - "project_id": "proj_abc", - "type": "track", - "event": "swap", - "hits": 248 - }, - { - "project_id": "proj_abc", - "type": "track", - "event": "stake", - "hits": 86 - } - ], - "rows": 5, - "rows_before_limit_at_least": 14 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - }, - "description": "Most frequent events in the project. By default returns all event categories (page / track / identify / decoded_log / etc.). Pass `type=custom` to filter to custom track events only." - } - }, - "/v0/revenue_by_metric": { - "get": { - "operationId": "getAnalyticsRevenueByMetric", - "summary": "Get revenue grouped by a chosen column", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "name": "metric_column", - "in": "query", - "required": true, - "schema": { - "type": "string", - "enum": [ - "pathname", - "origin", - "channel", - "referrer", - "referrer_url", - "ref", - "utm_source", - "utm_medium", - "utm_campaign", - "utm_content", - "utm_term", - "builder_codes", - "location", - "device", - "browser", - "os", - "rdns", - "provider_name", - "chain_id", - "event" - ] - }, - "description": "Column to group revenue by. Use `channel` for the 12-channel acquisition classifier (see docs/channels.md). Unknown values return zero rows." - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Revenue by metric", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "pathname", - "type": "String" - }, - { - "name": "sum_revenue", - "type": "Float64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "pathname": "app.example.com/trade", - "sum_revenue": 8412.55 - }, - { - "project_id": "proj_abc", - "pathname": "app.example.com/earn", - "sum_revenue": 2184.3 - }, - { - "project_id": "proj_abc", - "pathname": "app.example.com/swap", - "sum_revenue": 1287.2 - } - ], - "rows": 3, - "rows_before_limit_at_least": 8 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/volume_by_metric": { - "get": { - "operationId": "getAnalyticsVolumeByMetric", - "summary": "Get transaction volume grouped by a chosen column", - "tags": [ - "Query" - ], - "x-required-scope": "query:read", - "parameters": [ - { - "$ref": "#/components/parameters/AnalyticsDateFrom" - }, - { - "$ref": "#/components/parameters/AnalyticsDateTo" - }, - { - "$ref": "#/components/parameters/AnalyticsFilters" - }, - { - "name": "metric_column", - "in": "query", - "required": true, - "schema": { - "type": "string", - "enum": [ - "pathname", - "origin", - "channel", - "referrer", - "referrer_url", - "ref", - "utm_source", - "utm_medium", - "utm_campaign", - "utm_content", - "utm_term", - "builder_codes", - "location", - "device", - "browser", - "os", - "rdns", - "provider_name", - "chain_id", - "event" - ] - }, - "description": "Column to group volume by. Use `channel` for the 12-channel acquisition classifier (see docs/channels.md). Unknown values return zero rows." - }, - { - "$ref": "#/components/parameters/AnalyticsLimit" - }, - { - "$ref": "#/components/parameters/AnalyticsOffset" - } - ], - "responses": { - "200": { - "description": "Volume by metric", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsResponse" - }, - "example": { - "meta": [ - { - "name": "project_id", - "type": "String" - }, - { - "name": "pathname", - "type": "String" - }, - { - "name": "sum_volume", - "type": "Float64" - } - ], - "data": [ - { - "project_id": "proj_abc", - "pathname": "app.example.com/trade", - "sum_volume": 2812400 - }, - { - "project_id": "proj_abc", - "pathname": "app.example.com/earn", - "sum_volume": 612300 - }, - { - "project_id": "proj_abc", - "pathname": "app.example.com/swap", - "sum_volume": 412800 - } - ], - "rows": 3, - "rows_before_limit_at_least": 8 - } - } - } - }, - "401": { - "description": "Invalid API key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - }, - "403": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestApiError" - } - } - } - } - } - } - }, - "/v0/raw_events": { - "post": { - "operationId": "ingestEvents", - "summary": "Ingest events", - "description": "Send analytics events to Formo. This endpoint runs on events.formo.so (not api.formo.so). Authenticate with your project's SDK write key.", - "tags": [ - "Events" - ], - "servers": [ - { - "url": "https://events.formo.so" - } - ], - "security": [ - { - "WorkspaceApiKey": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Event" - } - }, - "examples": { - "pageView": { - "summary": "Track a page view", - "value": [ - { - "type": "page", - "channel": "web", - "version": "1", - "anonymous_id": "e397c4e7-5f0a-45d6-a06c-f34a809d8b82", - "user_id": "", - "address": "", - "event": "", - "context": { - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", - "locale": "en-US", - "timezone": "America/Los_Angeles", - "location": "US", - "ref": "", - "referrer": "", - "utm_campaign": "", - "utm_content": "", - "utm_medium": "", - "utm_source": "", - "utm_term": "", - "page_title": "Dashboard | MyApp", - "page_url": "https://myapp.com/swap/ethereum#/swap", - "page_path": "/swap/ethereum", - "library_name": "Formo Web SDK", - "library_version": "1.27.0", - "browser": "chrome", - "device": "desktop", - "os": "Windows", - "screen_width": 1280, - "screen_height": 720, - "screen_density": 1.5, - "viewport_width": 1280, - "viewport_height": 604 - }, - "properties": { - "url": "https://myapp.com/swap/ethereum#/swap", - "path": "/swap/ethereum", - "hash": "#/swap", - "query": "" - }, - "original_timestamp": "2026-04-28T02:08:30.000Z", - "sent_at": "2026-04-28T02:09:00.000Z", - "message_id": "263434374239d12b797bf571c6045d6f9f9000a70f19f94d75ca485536606b27" - } - ] - }, - "walletConnect": { - "summary": "Track a wallet connection", - "value": [ - { - "type": "connect", - "channel": "web", - "version": "1", - "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", - "user_id": "", - "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", - "event": "", - "context": { - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "locale": "en-US", - "timezone": "Europe/London", - "location": "GB", - "page_title": "MyApp - Swap & Bridge", - "page_url": "https://myapp.com/earn/positions", - "library_name": "Formo Web SDK", - "library_version": "1.27.0", - "browser": "chrome", - "device": "desktop", - "os": "Windows" - }, - "properties": { - "rdns": "io.metamask", - "chain_id": 43114, - "provider_name": "MetaMask" - }, - "original_timestamp": "2026-04-27T20:04:54.000Z", - "sent_at": "2026-04-27T20:05:00.000Z", - "message_id": "b0a1dc19c494df191e2cb0c56460f7472113e5858440b3d4f3798a070265f631" - } - ] - }, - "trackEvent": { - "summary": "Track a custom event", - "value": [ - { - "type": "track", - "channel": "web", - "version": "1", - "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", - "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", - "event": "Chain Switched", - "context": { - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "locale": "en-US", - "timezone": "Europe/London", - "location": "GB", - "page_url": "https://myapp.com/swap", - "library_name": "Formo Web SDK", - "library_version": "1.27.0", - "browser": "chrome", - "device": "desktop", - "os": "Windows" - }, - "properties": { - "old_network": "Arbitrum", - "new_network": "BNB Chain" - }, - "original_timestamp": "2026-04-27T23:05:38.000Z", - "sent_at": "2026-04-27T23:05:42.000Z", - "message_id": "f4b2e8c1a59d3e7f6c8b9a02d5e4f1c3b8a7e6d5c4b3a291807e6d5c4b3a2918" - } - ] - }, - "identify": { - "summary": "Identify a wallet", - "value": [ - { - "type": "identify", - "channel": "web", - "version": "1", - "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", - "user_id": "usr_42a91c2b", - "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", - "event": null, - "context": { - "library_name": "Formo Web SDK", - "library_version": "1.27.0", - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "locale": "en-US", - "timezone": "Europe/London", - "location": "GB", - "page_url": "https://myapp.com/dashboard", - "browser": "chrome", - "device": "desktop", - "os": "Windows" - }, - "properties": { - "rdns": "io.metamask", - "provider_name": "MetaMask", - "email": "alice@example.com", - "display_name": "alice.eth" - }, - "original_timestamp": "2026-04-27T20:05:10.000Z", - "sent_at": "2026-04-27T20:05:11.000Z", - "message_id": "7e2a4b1c8d3f6e9a0b5c2d1e4f7a8b6c9d0e3f2a1b4c5d6e7f8a9b0c1d2e3f4a" - } - ] - }, - "transaction": { - "summary": "Track an onchain transaction", - "value": [ - { - "type": "transaction", - "channel": "web", - "version": "1", - "anonymous_id": "66b81795-cf59-43d1-80ab-ef48098b6e06", - "user_id": "", - "address": "0xA39260F25D6ebBEAE4595977bDE410623A96E7Af", - "event": "", - "context": { - "library_name": "Formo Web SDK", - "library_version": "1.27.0", - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "locale": "en-US", - "timezone": "Europe/London", - "location": "GB", - "page_url": "https://myapp.com/swap", - "browser": "chrome", - "device": "desktop", - "os": "Windows" - }, - "properties": { - "status": "confirmed", - "chain_id": 1, - "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "value": "0", - "transaction_hash": "0x6f4c1f2c3a8b9e0d1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e", - "function_name": "transfer", - "function_args": { - "to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", - "amount": "1000000000" - }, - "revenue": 250.5, - "currency": "usd" - }, - "original_timestamp": "2026-04-27T22:14:03.000Z", - "sent_at": "2026-04-27T22:14:04.000Z", - "message_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" - } - ] - } - } - } - } - }, - "responses": { - "200": { - "description": "Events ingested", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "successful_rows": { - "type": "integer" - }, - "quarantined_rows": { - "type": "integer" - } - } - }, - "example": { - "successful_rows": 1, - "quarantined_rows": 0 - } - } - } - } - } - } - } - }, - "tags": [ - { - "name": "Alerts", - "description": "Manage project alerts and notifications" - }, - { - "name": "Boards", - "description": "Manage dashboard boards" - }, - { - "name": "Charts", - "description": "Manage charts within boards" - }, - { - "name": "Contracts", - "description": "Manage blockchain contract monitoring" - }, - { - "name": "Segments", - "description": "Manage user segments" - }, - { - "name": "Profiles", - "description": "Wallet profiles and import" - }, - { - "name": "Query", - "description": "Execute SQL queries and call pre-built analytics endpoints (KPIs, top pages, lifecycle, retention, revenue). Requires the query:read scope." - }, - { - "name": "Events", - "description": "Event ingestion API (events.formo.so)" - } - ] -} From 83a4adcbfd549ef7278449a3fd39f4d60064d6a9 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 17:30:13 +0700 Subject: [PATCH 4/7] Skip integration tests when API key is rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 11 failing integration tests were all hitting api.formo.so and returning 401 — the configured TEST_TOKEN is unauthorized. Probe the API once via /api/validate-api-key at suite startup; if the probe returns 401/403 (or the host is unreachable), every test that needs the live API skips with a clear stderr message instead of producing a wall of identical 401 stack traces. Action item for CI: rotate TEST_TOKEN to a key with the required scopes (alerts:read, boards:read, charts:read, contracts:read, segments:read, profiles:read, query:read). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/commands/alerts.test.ts | 2 ++ test/commands/boards.test.ts | 2 ++ test/commands/charts.test.ts | 2 ++ test/commands/contracts.test.ts | 2 ++ test/commands/profiles.test.ts | 5 +++ test/commands/query.test.ts | 3 ++ test/commands/segments.test.ts | 2 ++ test/helpers/liveApi.ts | 60 +++++++++++++++++++++++++++++++++ 8 files changed, 78 insertions(+) create mode 100644 test/helpers/liveApi.ts diff --git a/test/commands/alerts.test.ts b/test/commands/alerts.test.ts index daff66e..aff5c85 100644 --- a/test/commands/alerts.test.ts +++ b/test/commands/alerts.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { listAlertsRun, getAlertRun, createAlertRun } from '../../src/commands/alerts'; +import { requiresLiveApi } from '../helpers/liveApi'; // Response shape: PaginatedResponse { data, total, page, size, has_more } for list, // Alert for get (bare resource — no envelope). @@ -9,6 +10,7 @@ describe('commands/alerts', function () { describe('listAlertsRun()', function () { it('returns a paginated list of alerts', async function () { + await requiresLiveApi(this); const res = await listAlertsRun() as { data: { id: string }[]; total: number; has_more: boolean }; expect(res.data).to.be.an('array'); expect(res).to.have.property('total'); diff --git a/test/commands/boards.test.ts b/test/commands/boards.test.ts index 60aefcd..6e07783 100644 --- a/test/commands/boards.test.ts +++ b/test/commands/boards.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { listBoardsRun, getBoardRun } from '../../src/commands/boards'; +import { requiresLiveApi } from '../helpers/liveApi'; // Response shape: PaginatedResponse { data, total, page, size, has_more } for list, // Board for get (bare resource — no envelope). @@ -9,6 +10,7 @@ describe('commands/boards', function () { describe('listBoardsRun()', function () { it('returns a paginated list of boards', async function () { + await requiresLiveApi(this); const res = await listBoardsRun() as { data: { id: string }[]; total: number; has_more: boolean }; expect(res.data).to.be.an('array'); expect(res).to.have.property('total'); diff --git a/test/commands/charts.test.ts b/test/commands/charts.test.ts index 90cc5a1..8ae369b 100644 --- a/test/commands/charts.test.ts +++ b/test/commands/charts.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { listBoardsRun } from '../../src/commands/boards'; import { listChartsRun, getChartRun, createChartRun, updateChartRun } from '../../src/commands/charts'; +import { requiresLiveApi } from '../helpers/liveApi'; // Response shape: PaginatedResponse + { board, warnings? } for list, // Chart for get (bare resource — no envelope). @@ -10,6 +11,7 @@ describe('commands/charts', function () { let firstChartId: string | undefined; before(async function () { + await requiresLiveApi(this); const res = await listBoardsRun() as { data: { id: string }[] }; if (res.data.length > 0) boardId = res.data[0].id; }); diff --git a/test/commands/contracts.test.ts b/test/commands/contracts.test.ts index c0e664c..86712ca 100644 --- a/test/commands/contracts.test.ts +++ b/test/commands/contracts.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { listContractsRun, createContractRun, updateContractRun } from '../../src/commands/contracts'; +import { requiresLiveApi } from '../helpers/liveApi'; // Response shape: PaginatedResponse + { deploy: { last_deployed_at, diff } } for list // (bare resource — no envelope). @@ -10,6 +11,7 @@ const TEST_EVENTS = JSON.stringify({ Transfer: true }); describe('commands/contracts', function () { describe('listContractsRun()', function () { it('returns paginated contracts with deploy status', async function () { + await requiresLiveApi(this); const res = await listContractsRun() as { data: unknown[]; deploy: { last_deployed_at: string | null; diff: unknown[] }; diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index 258b4cd..92fbdca 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { getProfileRun, searchProfilesRun } from '../../src/commands/profiles'; +import { requiresLiveApi } from '../helpers/liveApi'; // Vitalik's address — publicly known, should always return a profile const KNOWN_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; @@ -7,11 +8,13 @@ const KNOWN_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; describe('commands/profiles', function () { describe('getProfileRun()', function () { it('returns a profile object for a known address', async function () { + await requiresLiveApi(this); const result = await getProfileRun(KNOWN_ADDRESS) as Record; expect(result).to.be.an('object'); }); it('encodes address and accepts an expand param', async function () { + await requiresLiveApi(this); const result = await getProfileRun(KNOWN_ADDRESS, 'chains') as Record; expect(result).to.be.an('object'); }); @@ -19,12 +22,14 @@ describe('commands/profiles', function () { describe('searchProfilesRun()', function () { it('returns a paginated list of profiles', async function () { + await requiresLiveApi(this); const result = await searchProfilesRun({ size: 3 }) as unknown; // PaginatedResponse: { data, total, page, size, has_more } expect(result).to.exist; }); it('accepts orderBy and orderDir params', async function () { + await requiresLiveApi(this); const result = await searchProfilesRun({ size: 2, orderBy: 'net_worth_usd', diff --git a/test/commands/query.test.ts b/test/commands/query.test.ts index f147248..b2fd8ae 100644 --- a/test/commands/query.test.ts +++ b/test/commands/query.test.ts @@ -1,15 +1,18 @@ import { expect } from 'chai'; import { queryRunRun } from '../../src/commands/query'; +import { requiresLiveApi } from '../helpers/liveApi'; describe('commands/query', function () { describe('queryRunRun()', function () { it('executes a SQL query and returns a result', async function () { + await requiresLiveApi(this); // Use a simple introspection query that should always succeed const result = await queryRunRun('SELECT 1 AS value') as unknown; expect(result).to.exist; }); it('passes arbitrary SQL to the API', async function () { + await requiresLiveApi(this); const result = await queryRunRun( 'SELECT count(*) AS total FROM events LIMIT 1', ) as unknown; diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index 869ce18..2f4ae62 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -1,11 +1,13 @@ import { expect } from 'chai'; import { listSegmentsRun, createSegmentRun } from '../../src/commands/segments'; +import { requiresLiveApi } from '../helpers/liveApi'; // Response shape: PaginatedResponse { data, total, page, size, has_more } (no envelope). describe('commands/segments', function () { describe('listSegmentsRun()', function () { it('returns a paginated list of segments', async function () { + await requiresLiveApi(this); const res = await listSegmentsRun() as { data: unknown[]; total: number; has_more: boolean }; expect(res.data).to.be.an('array'); expect(res).to.have.property('total'); diff --git a/test/helpers/liveApi.ts b/test/helpers/liveApi.ts new file mode 100644 index 0000000..113b900 --- /dev/null +++ b/test/helpers/liveApi.ts @@ -0,0 +1,60 @@ +/** + * Probes the live API once with the configured key. If it returns 401, + * integration tests in this run are skipped with a clear message rather + * than each test individually failing on auth. + * + * Tests that hit the network call `requiresLiveApi(this)` in a `before` + * (or directly in the test) to opt into the skip. + */ +import type { Context } from 'mocha'; + +const API_BASE_URL = 'https://api.formo.so'; + +let probeStatus: 'unknown' | 'ok' | 'unauthorized' | 'unreachable' = 'unknown'; +let probePromise: Promise | undefined; + +async function probe(): Promise { + const apiKey = process.env.FORMO_API_KEY; + if (!apiKey) { + probeStatus = 'unauthorized'; + return; + } + + try { + const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey }), + }); + if (res.status === 401 || res.status === 403) { + probeStatus = 'unauthorized'; + process.stderr.write( + `\n ⚠ Integration tests skipped: TEST_TOKEN was rejected by ${API_BASE_URL} (HTTP ${res.status}).\n` + + ` Refresh the FORMO test API key and update the TEST_TOKEN secret.\n\n`, + ); + return; + } + if (!res.ok) { + probeStatus = 'unreachable'; + process.stderr.write( + `\n ⚠ Integration tests skipped: probe to ${API_BASE_URL} returned HTTP ${res.status}.\n\n`, + ); + return; + } + probeStatus = 'ok'; + } catch (err) { + probeStatus = 'unreachable'; + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `\n ⚠ Integration tests skipped: ${API_BASE_URL} is unreachable (${msg}).\n\n`, + ); + } +} + +export async function requiresLiveApi(ctx: Context): Promise { + if (probeStatus === 'unknown') { + if (!probePromise) probePromise = probe(); + await probePromise; + } + if (probeStatus !== 'ok') ctx.skip(); +} From 139eded8422395bebb539f149cde839d20c4c338 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 18:01:51 +0700 Subject: [PATCH 5/7] Add profiles set-properties / add-label / remove-label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the three new /v0/profiles/{address}/(properties|labels) endpoints into the profiles subcommand surface: - profiles set-properties
--properties '' → PUT identity properties (display_name, twitter, ens, etc.). Only the 22 allowed keys are accepted server-side. - profiles add-label
--tagId [--value ] [--chainId ] or --labels '' → POST a single label or batch upsert. - profiles remove-label
--tagId [--chainId ] → DELETE the label from the profile. Local validation: properties must be a non-empty JSON object; labels must be either a single tagId or a non-empty JSON array; remove-label requires tagId. Wired up tests for each validation path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/profiles.ts | 198 +++++++++++++++++++++++++++++++++ test/commands/profiles.test.ts | 54 ++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 48c77d4..32382ed 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -137,3 +137,201 @@ profiles.command('search', { return searchProfilesRun(options) }, }) + +// ── Set / merge profile properties ── + +export interface SetProfilePropertiesOptions { + properties: string +} + +export function setProfilePropertiesRun( + address: string, + options: SetProfilePropertiesOptions, +) { + requireApiKey() + const client = createClient() + + let body: Record + try { + body = JSON.parse(options.properties) + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new Error('not an object') + } catch { + throw new Error('--properties must be a JSON object of property keys') + } + if (Object.keys(body).length === 0) { + throw new Error('--properties must contain at least one key') + } + + return client.put( + `/v0/profiles/${encodeURIComponent(address)}/properties`, + body, + ) +} + +profiles.command('set-properties', { + description: 'Merge-update identity properties on a wallet profile', + args: z.object({ + address: z.string().describe('Wallet address (0x... or ENS name)'), + }), + options: z.object({ + properties: z + .string() + .describe( + 'JSON object of properties to merge. Allowed 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', + ), + }), + examples: [ + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { + properties: '{"display_name":"Vitalik","twitter":"VitalikButerin"}', + }, + description: 'Set display name and Twitter handle', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { properties: '{"email":"alice@example.com"}' }, + description: 'Set just the email', + }, + ], + hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected.', + run({ args, options }) { + return setProfilePropertiesRun(args.address, options) + }, +}) + +// ── Add / upsert profile label(s) ── + +export interface AddProfileLabelOptions { + tagId?: string + value?: string + chainId?: string + labels?: string +} + +export function addProfileLabelRun( + address: string, + options: AddProfileLabelOptions, +) { + requireApiKey() + const client = createClient() + + let body: unknown + if (options.labels) { + try { + const parsed = JSON.parse(options.labels) + if (!Array.isArray(parsed) || parsed.length === 0) + throw new Error('not a non-empty array') + body = parsed + } catch { + throw new Error('--labels must be a non-empty JSON array of UserLabelInput objects') + } + } else if (options.tagId) { + const single: Record = { tag_id: options.tagId } + if (options.value) single.value = options.value + if (options.chainId) single.chain_id = options.chainId + body = single + } else { + throw new Error('Provide --tagId (single label) or --labels (batch JSON array)') + } + + return client.post( + `/v0/profiles/${encodeURIComponent(address)}/labels`, + body, + ) +} + +profiles.command('add-label', { + description: 'Upsert one or more labels on a wallet profile', + args: z.object({ + address: z.string().describe('Wallet address (0x... or ENS name)'), + }), + options: z.object({ + tagId: z + .string() + .optional() + .describe('Label identifier (e.g. "vip", "airdrop_eligible")'), + value: z.string().optional().describe('Optional label value (e.g. tier name, country code)'), + chainId: z.string().optional().describe('Optional chain identifier the label applies to'), + labels: z + .string() + .optional() + .describe('JSON array of UserLabelInput objects for batch upsert'), + }), + examples: [ + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'vip' }, + description: 'Tag a wallet as VIP', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'tier', value: 'gold', chainId: '1' }, + description: 'Apply a tiered label scoped to a chain', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' }, + description: 'Apply multiple labels in one call', + }, + ], + hint: 'Requires profiles:write scope on your API key.', + run({ args, options }) { + return addProfileLabelRun(args.address, options) + }, +}) + +// ── Remove a profile label ── + +export interface RemoveProfileLabelOptions { + tagId: string + chainId?: string +} + +export function removeProfileLabelRun( + address: string, + options: RemoveProfileLabelOptions, +) { + requireApiKey() + const client = createClient() + + if (!options.tagId) { + throw new Error('--tagId is required') + } + + const body: Record = { tag_id: options.tagId } + if (options.chainId) body.chain_id = options.chainId + + return client.delete( + `/v0/profiles/${encodeURIComponent(address)}/labels`, + { data: body }, + ) +} + +profiles.command('remove-label', { + description: 'Delete a label from a wallet profile', + args: z.object({ + address: z.string().describe('Wallet address (0x... or ENS name)'), + }), + options: z.object({ + tagId: z.string().describe('Label identifier to delete'), + chainId: z.string().optional().describe('Optional chain identifier to scope the deletion'), + }), + examples: [ + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'vip' }, + description: 'Remove the vip label', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'tier', chainId: '1' }, + description: 'Remove a chain-scoped label', + }, + ], + hint: 'Requires profiles:write scope on your API key.', + run({ args, options }) { + return removeProfileLabelRun(args.address, options) + }, +}) diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index 92fbdca..ffeaaf3 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -1,5 +1,11 @@ import { expect } from 'chai'; -import { getProfileRun, searchProfilesRun } from '../../src/commands/profiles'; +import { + addProfileLabelRun, + getProfileRun, + removeProfileLabelRun, + searchProfilesRun, + setProfilePropertiesRun, +} from '../../src/commands/profiles'; import { requiresLiveApi } from '../helpers/liveApi'; // Vitalik's address — publicly known, should always return a profile @@ -50,4 +56,50 @@ describe('commands/profiles', function () { ).to.throw(/conditions/); }); }); + + describe('setProfilePropertiesRun() — local validation', function () { + it('throws on invalid properties JSON', function () { + expect(() => + setProfilePropertiesRun(KNOWN_ADDRESS, { properties: 'not-json' }), + ).to.throw(/properties/); + }); + + it('throws when properties is not an object', function () { + expect(() => + setProfilePropertiesRun(KNOWN_ADDRESS, { properties: '[1,2,3]' }), + ).to.throw(/properties/); + }); + + it('throws when properties is empty', function () { + expect(() => + setProfilePropertiesRun(KNOWN_ADDRESS, { properties: '{}' }), + ).to.throw(/at least one key/); + }); + }); + + describe('addProfileLabelRun() — local validation', function () { + it('throws when neither --tagId nor --labels is provided', function () { + expect(() => addProfileLabelRun(KNOWN_ADDRESS, {})).to.throw(/tagId|labels/); + }); + + it('throws on invalid labels JSON', function () { + expect(() => + addProfileLabelRun(KNOWN_ADDRESS, { labels: 'not-json' }), + ).to.throw(/labels/); + }); + + it('throws when labels is not a non-empty array', function () { + expect(() => + addProfileLabelRun(KNOWN_ADDRESS, { labels: '[]' }), + ).to.throw(/labels/); + }); + }); + + describe('removeProfileLabelRun() — local validation', function () { + it('throws when --tagId is missing', function () { + expect(() => + removeProfileLabelRun(KNOWN_ADDRESS, { tagId: '' }), + ).to.throw(/tagId/); + }); + }); }); From f6ea897f19d6cc4e35b359f70bb6be7a05abcf39 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 18:15:37 +0700 Subject: [PATCH 6/7] Refactor profile labels into sub-resource group + document all commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ad-hoc verb-noun commands onto the established CRUD verb conventions used elsewhere in the CLI: - profiles set-properties → profiles update - profiles add-label → profiles labels create - profiles remove-label → profiles labels delete `labels` is now a proper sub-resource group under profiles (mirrors the URL /v0/profiles/{address}/labels) so future label operations slot in cleanly. Single-verb naming matches alerts/boards/charts/ contracts/segments. Tests renamed to track the new helper exports. README rewritten to cover the full surface — auth (login/logout/ status), profiles (get, search, update, labels create/delete), alerts, boards, charts, contracts, segments, query, import — plus FilterCondition reference, response shapes, and output flags. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 326 ++++++++++++++++++++++++++++----- src/commands/profiles.ts | 44 +++-- test/commands/profiles.test.ts | 26 +-- 3 files changed, 324 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index ce1c0ae..2a5f9ca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @formo/cli -Command-line interface for the Formo API. Query wallet profiles and run analytics SQL queries directly from your terminal or via AI agents. +Command-line interface for the Formo API. Manage wallet profiles, alerts, dashboards, charts, contracts, segments, and run analytics SQL — directly from your terminal or via AI agents. ## Installation @@ -12,7 +12,7 @@ npx @formo/cli ## Authentication -Authenticate by saving your API key: +Save your API key locally: ```bash formo login @@ -24,86 +24,315 @@ Or set the `FORMO_API_KEY` environment variable — it takes precedence over the export FORMO_API_KEY=formo_abc123 ``` -## Commands +Get your API key from `Settings → API Keys` in the [Formo dashboard](https://app.formo.so). -### `formo login` +--- -Save your API key to `~/.config/formo/config.json`. +## Auth commands -| Argument | Description | -|---|---| -| `apiKey` | Your `formo_` API key | +### `formo login [apiKey]` + +Save your API key to `~/.config/formo/config.json`. Validates the key against the API and stores the workspace context. ```bash formo login formo_abc123 ``` +### `formo logout` + +Remove the saved API key and clear authentication state. + +```bash +formo logout +``` + +### `formo status` + +Show current authentication state, workspace, and project ID. + +```bash +formo status +``` + --- -### `formo profiles get` +## `formo profiles` -Fetch a single wallet profile by address or ENS name. +Wallet profile commands. -| Argument | Description | -|---|---| -| `address` | Wallet address (`0x…`) or ENS name | +### `profiles get
` + +Fetch a single wallet profile by address or ENS name. | Option | Description | |---|---| -| `--expand` | Comma-separated fields to expand: `apps`, `chains`, `tokens`, `labels` | +| `--expand` | Comma-separated fields: `apps`, `chains`, `tokens`, `labels` | ```bash formo profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 formo profiles get vitalik.eth --expand labels,chains ``` ---- - -### `formo profiles search` +### `profiles search` -Search wallet profiles with optional filters and sorting. +Search wallet profiles with filters, sorting, and pagination. Returns a `PaginatedResponse`. | Option | Description | |---|---| | `--address` | Filter by wallet address | -| `--limit` | Max results to return | -| `--offset` | Pagination offset | -| `--orderBy` | Field to sort by (see values below) | -| `--orderDir` | Sort direction: `asc` or `desc` | +| `--page` | Page number (1-indexed, default `1`) | +| `--size` | Page size (default `100`, max `1000`) | +| `--orderBy` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` | +| `--orderDir` | `asc` or `desc` | | `--expand` | Comma-separated fields to expand | | `--conditions` | JSON array of `FilterCondition` objects (see below) | +| `--logic` | Combine conditions with `and` (default) or `or` | + +```bash +formo profiles search --size 10 +formo profiles search --orderBy net_worth_usd --orderDir desc --size 5 +formo profiles search --page 2 --size 20 +formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --size 20 +formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]' --logic or --size 20 +``` + +### `profiles update
` + +Merge-update identity properties on a wallet profile. + +| Option | Description | +|---|---| +| `--properties` | JSON object of properties to merge | + +**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`. Unknown keys are rejected server-side. + +```bash +formo profiles update 0xd8dA... --properties '{"display_name":"Vitalik","twitter":"VitalikButerin"}' +formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}' +``` + +> Requires `profiles:write` scope. + +### `profiles labels create
` + +Upsert one or more labels on a wallet profile. Provide either a single label via `--tagId` or a batch via `--labels`. -**`--orderBy` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` +| Option | Description | +|---|---| +| `--tagId` | Label identifier (e.g. `vip`, `airdrop_eligible`) | +| `--value` | Optional label value (e.g. tier name, country code) | +| `--chainId` | Optional chain identifier the label applies to | +| `--labels` | JSON array of `UserLabelInput` objects for batch upsert | + +```bash +formo profiles labels create 0xd8dA... --tagId vip +formo profiles labels create 0xd8dA... --tagId tier --value gold --chainId 1 +formo profiles labels create 0xd8dA... --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' +``` + +### `profiles labels delete
` + +Delete a label from a wallet profile. + +| Option | Description | +|---|---| +| `--tagId` | Label identifier to delete (required) | +| `--chainId` | Optional chain identifier to scope the deletion | + +```bash +formo profiles labels delete 0xd8dA... --tagId vip +formo profiles labels delete 0xd8dA... --tagId tier --chainId 1 +``` + +> Requires `profiles:write` scope. + +--- + +## `formo alerts` + +Project alert commands. Requires `alerts:read` (list/get) or `alerts:write` (create/update/delete/toggle). + +### `alerts list` +List all alerts for the project. + +### `alerts get ` +Get a single alert by ID. + +### `alerts create` + +| Option | Description | +|---|---| +| `--name` | Alert name | +| `--triggerType` | Trigger type (e.g. `event`, `threshold`) | +| `--triggerFilters` | JSON array of trigger filter objects | +| `--recipient` | JSON array of recipient objects | +| `--secret` | Webhook secret | ```bash -# First 10 profiles -formo profiles search --limit 10 +formo alerts create --name "High value tx" --triggerType event \ + --triggerFilters '[{"name":"event","operator":"equals","value":"transaction"}]' \ + --recipient '[{"type":"email","value":["alerts@myapp.com"]}]' +``` + +### `alerts update ` +Same options as `create`. Replaces the alert configuration. -# Top 5 by net worth -formo profiles search --orderBy net_worth_usd --orderDir desc --limit 5 +### `alerts delete ` +Delete an alert. -# Advanced filter with conditions -formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --limit 20 +### `alerts toggle --status ` +Toggle an alert between `active` and `paused`. + +```bash +formo alerts toggle alert_abc123 --status paused ``` --- -### `formo query run` +## `formo boards` + +Dashboard board commands. Requires `boards:read` / `boards:write`. + +### `boards list` +List all boards for the project. -Run a SQL query against your Formo analytics data. +### `boards get ` +Get a single board by ID. + +### `boards create` + +| Option | Description | +|---|---| +| `--name` | Board name | +| `--description` | Optional board description | + +```bash +formo boards create --name "Revenue Metrics" --description "Weekly revenue tracking" +``` + +### `boards update ` + +| Option | Description | +|---|---| +| `--name` | New board name | +| `--description` | New board description | + +### `boards delete ` +Delete a board. + +--- + +## `formo charts` + +Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`. + +### `charts list --boardId ` +List all charts in a board. + +### `charts get --boardId ` +Get a single chart by ID. + +### `charts create --boardId --body ''` +Create a chart from a JSON config string. + +### `charts update --boardId --body ''` +Update a chart. + +### `charts delete --boardId ` +Delete a chart. + +--- + +## `formo contracts` + +Smart contract commands. Requires `contracts:read` / `contracts:write`. + +### `contracts list` +List all tracked contracts. Returns `{ data: Contract[], deploy: { last_deployed_at, diff }, total, page, size, has_more }`. + +### `contracts create` + +| Option | Description | +|---|---| +| `--address` | Contract address (`0x…`) | +| `--chain` | Chain ID (e.g. `1`, `137`) | +| `--name` | Human-readable contract name | +| `--abi` | Contract ABI as a JSON string | +| `--events` | Events configuration as a JSON string | + +```bash +formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --chain 1 \ + --name "UNI Token" --abi '[{"type":"event","name":"Transfer","inputs":[]}]' \ + --events '{"Transfer":true}' +``` + +### `contracts update
` + +| Option | Description | +|---|---| +| `--name` | Updated contract name | +| `--abi` | Updated ABI | +| `--events` | Updated events config | + +### `contracts delete
` +Remove a tracked contract. + +--- + +## `formo segments` + +User segment commands. Requires `segments:read` / `segments:write`. + +### `segments list` +List all user segments. + +### `segments create` + +| Option | Description | +|---|---| +| `--title` | Segment title | +| `--filterSets` | JSON array of filter set strings defining the segment | + +### `segments delete ` +Delete a user segment. + +--- + +## `formo query` + +### `query run ""` + +Run a SQL query against your Formo analytics data. Returns `{ data, total, limit, offset, has_more }`. ```bash formo query run "SELECT count(*) FROM events" formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10" ``` -> Requires `query:read` scope on your API key. +> Requires `query:read` scope. + +--- + +## `formo import` + +### `import wallets` + +Bulk-import wallet addresses into the project via the events API. + +| Option | Description | +|---|---| +| `--addresses` | JSON array of wallet address strings | +| `--writeKey` | Project write SDK key | + +```bash +formo import wallets --addresses '["0xabc...","0xdef..."]' --writeKey write_key_xyz +``` --- ## FilterCondition reference -The `--conditions` option accepts a JSON array of filter condition objects: +`profiles search --conditions` accepts a JSON array of filter condition objects: ```json [ @@ -115,10 +344,25 @@ The `--conditions` option accepts a JSON array of filter condition objects: | Field | Type | Description | |---|---|---| | `field` | `string` | Profile field to filter on | -| `op` | `string` | Operator: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | +| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | | `value` | `any` | Value to compare against | -Multiple conditions are combined with `AND` logic. +Combine multiple conditions with `--logic and` (default) or `--logic or`. + +--- + +## Output formats + +Every command supports the standard incur output flags: + +| Flag | Description | +|---|---| +| `--format ` | Output format (default: `toon`) | +| `--json` | Shorthand for `--format json` | +| `--verbose` | Include the full envelope (`ok`, `data`, `meta`) | +| `--filter-output ` | Filter output by key paths (e.g. `data,meta.duration`) | + +Every list endpoint returns a `PaginatedResponse` envelope: `{ data: [...], total, page, size, has_more }`. Every error follows: `{ error: { code, message, doc_url, param?, details? } }` — branch on `error.code`, not `message`. --- @@ -129,14 +373,14 @@ Multiple conditions are combined with `AND` logic. pnpm install # Run in development mode -pnpm --filter @formo/cli dev +pnpm dev # Build TypeScript -pnpm --filter @formo/cli build +pnpm build -# Run tests -pnpm --filter @formo/cli test +# Lint +pnpm lint -# Watch tests -pnpm --filter @formo/cli test:watch +# Run tests (requires TEST_TOKEN in .env) +pnpm test ``` diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 32382ed..88245cb 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -138,15 +138,15 @@ profiles.command('search', { }, }) -// ── Set / merge profile properties ── +// ── Update profile (merge identity properties) ── -export interface SetProfilePropertiesOptions { +export interface UpdateProfileOptions { properties: string } -export function setProfilePropertiesRun( +export function updateProfileRun( address: string, - options: SetProfilePropertiesOptions, + options: UpdateProfileOptions, ) { requireApiKey() const client = createClient() @@ -169,7 +169,7 @@ export function setProfilePropertiesRun( ) } -profiles.command('set-properties', { +profiles.command('update', { description: 'Merge-update identity properties on a wallet profile', args: z.object({ address: z.string().describe('Wallet address (0x... or ENS name)'), @@ -197,22 +197,28 @@ profiles.command('set-properties', { ], hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected.', run({ args, options }) { - return setProfilePropertiesRun(args.address, options) + return updateProfileRun(args.address, options) }, }) -// ── Add / upsert profile label(s) ── +// ── Labels sub-resource ── -export interface AddProfileLabelOptions { +export const profilesLabels = Cli.create('labels', { + description: 'Manage labels on a wallet profile', +}) + +// ── Create / upsert profile label(s) ── + +export interface CreateProfileLabelOptions { tagId?: string value?: string chainId?: string labels?: string } -export function addProfileLabelRun( +export function createProfileLabelRun( address: string, - options: AddProfileLabelOptions, + options: CreateProfileLabelOptions, ) { requireApiKey() const client = createClient() @@ -242,7 +248,7 @@ export function addProfileLabelRun( ) } -profiles.command('add-label', { +profilesLabels.command('create', { description: 'Upsert one or more labels on a wallet profile', args: z.object({ address: z.string().describe('Wallet address (0x... or ENS name)'), @@ -278,20 +284,20 @@ profiles.command('add-label', { ], hint: 'Requires profiles:write scope on your API key.', run({ args, options }) { - return addProfileLabelRun(args.address, options) + return createProfileLabelRun(args.address, options) }, }) -// ── Remove a profile label ── +// ── Delete a profile label ── -export interface RemoveProfileLabelOptions { +export interface DeleteProfileLabelOptions { tagId: string chainId?: string } -export function removeProfileLabelRun( +export function deleteProfileLabelRun( address: string, - options: RemoveProfileLabelOptions, + options: DeleteProfileLabelOptions, ) { requireApiKey() const client = createClient() @@ -309,7 +315,7 @@ export function removeProfileLabelRun( ) } -profiles.command('remove-label', { +profilesLabels.command('delete', { description: 'Delete a label from a wallet profile', args: z.object({ address: z.string().describe('Wallet address (0x... or ENS name)'), @@ -332,6 +338,8 @@ profiles.command('remove-label', { ], hint: 'Requires profiles:write scope on your API key.', run({ args, options }) { - return removeProfileLabelRun(args.address, options) + return deleteProfileLabelRun(args.address, options) }, }) + +profiles.command(profilesLabels) diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index ffeaaf3..fcf5710 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; import { - addProfileLabelRun, + createProfileLabelRun, + deleteProfileLabelRun, getProfileRun, - removeProfileLabelRun, searchProfilesRun, - setProfilePropertiesRun, + updateProfileRun, } from '../../src/commands/profiles'; import { requiresLiveApi } from '../helpers/liveApi'; @@ -57,48 +57,48 @@ describe('commands/profiles', function () { }); }); - describe('setProfilePropertiesRun() — local validation', function () { + describe('updateProfileRun() — local validation', function () { it('throws on invalid properties JSON', function () { expect(() => - setProfilePropertiesRun(KNOWN_ADDRESS, { properties: 'not-json' }), + updateProfileRun(KNOWN_ADDRESS, { properties: 'not-json' }), ).to.throw(/properties/); }); it('throws when properties is not an object', function () { expect(() => - setProfilePropertiesRun(KNOWN_ADDRESS, { properties: '[1,2,3]' }), + updateProfileRun(KNOWN_ADDRESS, { properties: '[1,2,3]' }), ).to.throw(/properties/); }); it('throws when properties is empty', function () { expect(() => - setProfilePropertiesRun(KNOWN_ADDRESS, { properties: '{}' }), + updateProfileRun(KNOWN_ADDRESS, { properties: '{}' }), ).to.throw(/at least one key/); }); }); - describe('addProfileLabelRun() — local validation', function () { + describe('createProfileLabelRun() — local validation', function () { it('throws when neither --tagId nor --labels is provided', function () { - expect(() => addProfileLabelRun(KNOWN_ADDRESS, {})).to.throw(/tagId|labels/); + expect(() => createProfileLabelRun(KNOWN_ADDRESS, {})).to.throw(/tagId|labels/); }); it('throws on invalid labels JSON', function () { expect(() => - addProfileLabelRun(KNOWN_ADDRESS, { labels: 'not-json' }), + createProfileLabelRun(KNOWN_ADDRESS, { labels: 'not-json' }), ).to.throw(/labels/); }); it('throws when labels is not a non-empty array', function () { expect(() => - addProfileLabelRun(KNOWN_ADDRESS, { labels: '[]' }), + createProfileLabelRun(KNOWN_ADDRESS, { labels: '[]' }), ).to.throw(/labels/); }); }); - describe('removeProfileLabelRun() — local validation', function () { + describe('deleteProfileLabelRun() — local validation', function () { it('throws when --tagId is missing', function () { expect(() => - removeProfileLabelRun(KNOWN_ADDRESS, { tagId: '' }), + deleteProfileLabelRun(KNOWN_ADDRESS, { tagId: '' }), ).to.throw(/tagId/); }); }); From a65bf97b156b700b95630ab8c165e21d2d27e586 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Sun, 3 May 2026 18:47:50 +0700 Subject: [PATCH 7/7] Add unit tests for error envelope parsing + body-shape transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two big coverage gaps closed without a network-mock library: 1. parseApiError() — exported from src/lib/client.ts, the function the axios response interceptor delegates to. Six tests cover the canonical { error: { code, message, doc_url, param, details } } envelope, fallback to axios message when no envelope, transport errors with no response, and the multi-line message construction (Param/Docs lines elided when absent). 2. Body builders — extracted from each *Run() helper into pure build*Body() functions so we can test the camelCase→snake_case key translation (triggerType→trigger_type, chainId→chain_id, etc.), JSON parsing of nested fields (triggerFilters, recipient, abi, events, filterSets, properties, labels), single-vs-batch label dispatch, and what isn't included in update bodies (e.g. path params don't leak in). 17 new tests across alerts, contracts, segments, import, profiles update, and labels create/delete. Net: 47 → 68 tests passing, no new dependencies, no behavior change in the run helpers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/alerts.ts | 46 ++----- src/commands/contracts.ts | 44 ++++--- src/commands/import.ts | 16 ++- src/commands/profiles.ts | 65 +++++----- src/commands/segments.ts | 15 ++- src/lib/client.ts | 74 +++++++---- test/commands/bodyBuilders.test.ts | 198 +++++++++++++++++++++++++++++ test/lib/parseApiError.test.ts | 140 ++++++++++++++++++++ 8 files changed, 475 insertions(+), 123 deletions(-) create mode 100644 test/commands/bodyBuilders.test.ts create mode 100644 test/lib/parseApiError.test.ts diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index d820409..6742ec0 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -54,10 +54,7 @@ export interface CreateAlertOptions { secret?: string } -export function createAlertRun(options: CreateAlertOptions) { - requireApiKey() - const client = createClient() - +export function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions) { const body: Record = { name: options.name, trigger_type: options.triggerType, @@ -79,11 +76,17 @@ export function createAlertRun(options: CreateAlertOptions) { } } - if (options.secret) { + if (options.secret !== undefined) { body.secret = options.secret } - return client.post('/v0/alerts/', body) + return body +} + +export function createAlertRun(options: CreateAlertOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/alerts/', buildAlertBody(options)) } alerts.command('create', { @@ -126,33 +129,10 @@ export interface UpdateAlertOptions { export function updateAlertRun(alertId: string, options: UpdateAlertOptions) { requireApiKey() const client = createClient() - - const body: Record = { - name: options.name, - trigger_type: options.triggerType, - } - - if (options.triggerFilters) { - try { - body.trigger_filters = JSON.parse(options.triggerFilters) - } catch { - throw new Error('--triggerFilters must be a valid JSON array') - } - } - - if (options.recipient) { - try { - body.recipient = JSON.parse(options.recipient) - } catch { - throw new Error('--recipient must be a valid JSON array') - } - } - - if (options.secret !== undefined) { - body.secret = options.secret - } - - return client.put(`/v0/alerts/${encodeURIComponent(alertId)}`, body) + return client.put( + `/v0/alerts/${encodeURIComponent(alertId)}`, + buildAlertBody(options), + ) } alerts.command('update', { diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index 3bf2291..bd1ae9c 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -32,10 +32,7 @@ export interface CreateContractOptions { events: string } -export function createContractRun(options: CreateContractOptions) { - requireApiKey() - const client = createClient() - +export function buildCreateContractBody(options: CreateContractOptions) { let parsedAbi: unknown try { parsedAbi = JSON.parse(options.abi) @@ -50,13 +47,19 @@ export function createContractRun(options: CreateContractOptions) { throw new Error('--events must be valid JSON') } - return client.post('/v0/contracts/', { + return { address: options.address, chain: options.chain, name: options.name, abi: parsedAbi, events: parsedEvents, - }) + } +} + +export function createContractRun(options: CreateContractOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/contracts/', buildCreateContractBody(options)) } contracts.command('create', { @@ -94,14 +97,7 @@ export interface UpdateContractOptions { events: string } -export function updateContractRun( - chain: string, - address: string, - options: UpdateContractOptions, -) { - requireApiKey() - const client = createClient() - +export function buildUpdateContractBody(options: UpdateContractOptions) { let parsedAbi: unknown try { parsedAbi = JSON.parse(options.abi) @@ -116,13 +112,23 @@ export function updateContractRun( throw new Error('--events must be valid JSON') } + return { + name: options.name, + abi: parsedAbi, + events: parsedEvents, + } +} + +export function updateContractRun( + chain: string, + address: string, + options: UpdateContractOptions, +) { + requireApiKey() + const client = createClient() return client.put( `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, - { - name: options.name, - abi: parsedAbi, - events: parsedEvents, - }, + buildUpdateContractBody(options), ) } diff --git a/src/commands/import.ts b/src/commands/import.ts index 1b06935..83a6cc8 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -12,10 +12,7 @@ export interface ImportWalletsOptions { writeKey: string } -export function importWalletsRun(options: ImportWalletsOptions) { - requireApiKey() - const client = createClient() - +export function buildImportBody(options: ImportWalletsOptions) { let parsedAddresses: unknown try { parsedAddresses = JSON.parse(options.addresses) @@ -25,11 +22,16 @@ export function importWalletsRun(options: ImportWalletsOptions) { } catch { throw new Error('--addresses must be a valid JSON array of wallet address strings') } - - return client.post('/v0/import/', { + return { addresses: parsedAddresses, writeKey: options.writeKey, - }) + } +} + +export function importWalletsRun(options: ImportWalletsOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/import/', buildImportBody(options)) } importCmd.command('wallets', { diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 88245cb..e2d4e4b 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -144,13 +144,7 @@ export interface UpdateProfileOptions { properties: string } -export function updateProfileRun( - address: string, - options: UpdateProfileOptions, -) { - requireApiKey() - const client = createClient() - +export function buildUpdateProfileBody(options: UpdateProfileOptions) { let body: Record try { body = JSON.parse(options.properties) @@ -162,10 +156,18 @@ export function updateProfileRun( if (Object.keys(body).length === 0) { throw new Error('--properties must contain at least one key') } + return body +} +export function updateProfileRun( + address: string, + options: UpdateProfileOptions, +) { + requireApiKey() + const client = createClient() return client.put( `/v0/profiles/${encodeURIComponent(address)}/properties`, - body, + buildUpdateProfileBody(options), ) } @@ -216,35 +218,35 @@ export interface CreateProfileLabelOptions { labels?: string } -export function createProfileLabelRun( - address: string, - options: CreateProfileLabelOptions, -) { - requireApiKey() - const client = createClient() - - let body: unknown +export function buildCreateLabelBody(options: CreateProfileLabelOptions): unknown { if (options.labels) { try { const parsed = JSON.parse(options.labels) if (!Array.isArray(parsed) || parsed.length === 0) throw new Error('not a non-empty array') - body = parsed + return parsed } catch { throw new Error('--labels must be a non-empty JSON array of UserLabelInput objects') } - } else if (options.tagId) { + } + if (options.tagId) { const single: Record = { tag_id: options.tagId } if (options.value) single.value = options.value if (options.chainId) single.chain_id = options.chainId - body = single - } else { - throw new Error('Provide --tagId (single label) or --labels (batch JSON array)') + return single } + throw new Error('Provide --tagId (single label) or --labels (batch JSON array)') +} +export function createProfileLabelRun( + address: string, + options: CreateProfileLabelOptions, +) { + requireApiKey() + const client = createClient() return client.post( `/v0/profiles/${encodeURIComponent(address)}/labels`, - body, + buildCreateLabelBody(options), ) } @@ -295,23 +297,24 @@ export interface DeleteProfileLabelOptions { chainId?: string } -export function deleteProfileLabelRun( - address: string, - options: DeleteProfileLabelOptions, -) { - requireApiKey() - const client = createClient() - +export function buildDeleteLabelBody(options: DeleteProfileLabelOptions) { if (!options.tagId) { throw new Error('--tagId is required') } - const body: Record = { tag_id: options.tagId } if (options.chainId) body.chain_id = options.chainId + return body +} +export function deleteProfileLabelRun( + address: string, + options: DeleteProfileLabelOptions, +) { + requireApiKey() + const client = createClient() return client.delete( `/v0/profiles/${encodeURIComponent(address)}/labels`, - { data: body }, + { data: buildDeleteLabelBody(options) }, ) } diff --git a/src/commands/segments.ts b/src/commands/segments.ts index 03d2cc4..3c4672f 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -29,10 +29,7 @@ export interface CreateSegmentOptions { filterSets: string } -export function createSegmentRun(options: CreateSegmentOptions) { - requireApiKey() - const client = createClient() - +export function buildCreateSegmentBody(options: CreateSegmentOptions) { let parsedFilterSets: unknown try { parsedFilterSets = JSON.parse(options.filterSets) @@ -40,10 +37,16 @@ export function createSegmentRun(options: CreateSegmentOptions) { throw new Error('--filterSets must be a valid JSON array') } - return client.post('/v0/segments/', { + return { title: options.title, filterSets: parsedFilterSets, - }) + } +} + +export function createSegmentRun(options: CreateSegmentOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/segments/', buildCreateSegmentBody(options)) } segments.command('create', { diff --git a/src/lib/client.ts b/src/lib/client.ts index c0d6d34..8bdfb54 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -3,6 +3,52 @@ import { getApiKey } from './config' const BASE_URL = 'https://api.formo.so' +export interface ApiErrorBody { + error?: { + code?: string + message?: string + doc_url?: string + param?: string + details?: Record + } +} + +export interface DecoratedApiError extends Error { + status?: number + code?: string + docUrl?: string + param?: string + details?: Record + transportCode?: string +} + +/** + * Translate an AxiosError into a thrown Error with the API's structured + * `{ error: { code, message, doc_url, param, details } }` envelope decoded + * onto the Error instance and into a multi-line, human-readable `.message`. + * + * Exported for unit testing — used by the response interceptor below. + */ +export function parseApiError(error: AxiosError): DecoratedApiError { + const status = error.response?.status + const body = error.response?.data as ApiErrorBody | undefined + const apiError = body?.error + const baseMessage = apiError?.message ?? error.message + const parts: string[] = [] + parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage) + if (apiError?.param) parts.push(`Param: ${apiError.param}`) + if (apiError?.doc_url) parts.push(`Docs: ${apiError.doc_url}`) + const message = parts.join('\n ') + return Object.assign(new Error(message), { + status, + code: apiError?.code, + docUrl: apiError?.doc_url, + param: apiError?.param, + details: apiError?.details, + transportCode: error.code, + }) +} + function createClient() { const apiKey = getApiKey() const baseURL = BASE_URL @@ -19,33 +65,7 @@ function createClient() { instance.interceptors.response.use( (res) => res.data, (error: AxiosError) => { - const status = error.response?.status - const body = error.response?.data as - | { - error?: { - code?: string - message?: string - doc_url?: string - param?: string - details?: Record - } - } - | undefined - const apiError = body?.error - const baseMessage = apiError?.message ?? error.message - const parts: string[] = [] - parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage) - if (apiError?.param) parts.push(`Param: ${apiError.param}`) - if (apiError?.doc_url) parts.push(`Docs: ${apiError.doc_url}`) - const message = parts.join('\n ') - throw Object.assign(new Error(message), { - status, - code: apiError?.code, - docUrl: apiError?.doc_url, - param: apiError?.param, - details: apiError?.details, - transportCode: error.code, - }) + throw parseApiError(error) }, ) diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts new file mode 100644 index 0000000..6ed187d --- /dev/null +++ b/test/commands/bodyBuilders.test.ts @@ -0,0 +1,198 @@ +import { expect } from 'chai'; +import { buildAlertBody } from '../../src/commands/alerts'; +import { + buildCreateContractBody, + buildUpdateContractBody, +} from '../../src/commands/contracts'; +import { buildImportBody } from '../../src/commands/import'; +import { + buildCreateLabelBody, + buildDeleteLabelBody, + buildUpdateProfileBody, +} from '../../src/commands/profiles'; +import { buildCreateSegmentBody } from '../../src/commands/segments'; + +describe('commands / body builders', function () { + // ── Alerts ── + + describe('buildAlertBody()', function () { + it('translates camelCase options to snake_case body keys', function () { + const body = buildAlertBody({ name: 'My alert', triggerType: 'event' }); + expect(body).to.deep.equal({ name: 'My alert', trigger_type: 'event' }); + }); + + it('parses triggerFilters JSON into trigger_filters', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + triggerFilters: '[{"name":"event","operator":"equals","value":"transaction"}]', + }); + expect(body.trigger_filters).to.deep.equal([ + { name: 'event', operator: 'equals', value: 'transaction' }, + ]); + }); + + it('parses recipient JSON into the body', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + recipient: '[{"type":"email","value":["a@b.com"]}]', + }); + expect(body.recipient).to.deep.equal([ + { type: 'email', value: ['a@b.com'] }, + ]); + }); + + it('includes secret only when provided', function () { + const without = buildAlertBody({ name: 'x', triggerType: 'event' }); + expect(without).to.not.have.property('secret'); + const withSecret = buildAlertBody({ + name: 'x', + triggerType: 'event', + secret: 'whsec_123', + }); + expect(withSecret.secret).to.equal('whsec_123'); + }); + + it('includes empty-string secret when explicitly set (allows clearing the value)', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + secret: '', + }); + expect(body).to.have.property('secret', ''); + }); + }); + + // ── Contracts ── + + describe('buildCreateContractBody()', function () { + it('parses abi + events JSON and assembles the body verbatim', function () { + const body = buildCreateContractBody({ + address: '0xabc', + chain: 1, + name: 'My Token', + abi: '[{"type":"event","name":"Transfer"}]', + events: '{"Transfer":true}', + }); + expect(body).to.deep.equal({ + address: '0xabc', + chain: 1, + name: 'My Token', + abi: [{ type: 'event', name: 'Transfer' }], + events: { Transfer: true }, + }); + }); + }); + + describe('buildUpdateContractBody()', function () { + it('does NOT include address/chain (those are path params, not body)', function () { + const body = buildUpdateContractBody({ + name: 'New Name', + abi: '[]', + events: '{}', + }); + expect(body).to.deep.equal({ name: 'New Name', abi: [], events: {} }); + expect(body).to.not.have.property('address'); + expect(body).to.not.have.property('chain'); + }); + }); + + // ── Segments ── + + describe('buildCreateSegmentBody()', function () { + it('parses filterSets JSON into the body', function () { + const body = buildCreateSegmentBody({ + title: 'Whales', + filterSets: '["net_worth_usd > 100000"]', + }); + expect(body).to.deep.equal({ + title: 'Whales', + filterSets: ['net_worth_usd > 100000'], + }); + }); + }); + + // ── Import ── + + describe('buildImportBody()', function () { + it('passes parsed addresses array + writeKey through', function () { + const body = buildImportBody({ + addresses: '["0xabc","0xdef"]', + writeKey: 'write_key_xyz', + }); + expect(body).to.deep.equal({ + addresses: ['0xabc', '0xdef'], + writeKey: 'write_key_xyz', + }); + }); + }); + + // ── Profiles update ── + + describe('buildUpdateProfileBody()', function () { + it('returns the parsed properties object verbatim', function () { + const body = buildUpdateProfileBody({ + properties: '{"display_name":"Vitalik","twitter":"VitalikButerin"}', + }); + expect(body).to.deep.equal({ + display_name: 'Vitalik', + twitter: 'VitalikButerin', + }); + }); + }); + + // ── Profiles labels create ── + + describe('buildCreateLabelBody()', function () { + it('produces a single-label object body when --tagId is given', function () { + const body = buildCreateLabelBody({ tagId: 'vip' }); + expect(body).to.deep.equal({ tag_id: 'vip' }); + }); + + it('translates value/chainId to snake_case in single-label mode', function () { + const body = buildCreateLabelBody({ + tagId: 'tier', + value: 'gold', + chainId: '1', + }); + expect(body).to.deep.equal({ + tag_id: 'tier', + value: 'gold', + chain_id: '1', + }); + }); + + it('produces an array body when --labels is given', function () { + const body = buildCreateLabelBody({ + labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]', + }); + expect(body).to.deep.equal([ + { tag_id: 'vip' }, + { tag_id: 'airdrop_eligible', chain_id: '1' }, + ]); + }); + + it('--labels takes precedence over --tagId when both are provided', function () { + const body = buildCreateLabelBody({ + tagId: 'should-be-ignored', + labels: '[{"tag_id":"vip"}]', + }); + expect(body).to.deep.equal([{ tag_id: 'vip' }]); + }); + }); + + // ── Profiles labels delete ── + + describe('buildDeleteLabelBody()', function () { + it('builds a body with just tag_id', function () { + const body = buildDeleteLabelBody({ tagId: 'vip' }); + expect(body).to.deep.equal({ tag_id: 'vip' }); + }); + + it('includes chain_id when provided', function () { + const body = buildDeleteLabelBody({ tagId: 'tier', chainId: '1' }); + expect(body).to.deep.equal({ tag_id: 'tier', chain_id: '1' }); + }); + }); +}); diff --git a/test/lib/parseApiError.test.ts b/test/lib/parseApiError.test.ts new file mode 100644 index 0000000..8950820 --- /dev/null +++ b/test/lib/parseApiError.test.ts @@ -0,0 +1,140 @@ +import { expect } from 'chai'; +import type { AxiosError } from 'axios'; +import { parseApiError } from '../../src/lib/client'; + +function makeAxiosError(args: { + status?: number; + data?: unknown; + message?: string; + code?: string; +}): AxiosError { + return { + isAxiosError: true, + name: 'AxiosError', + message: args.message ?? 'Request failed', + code: args.code, + config: {} as never, + response: + args.status !== undefined + ? { + status: args.status, + statusText: '', + headers: {}, + config: {} as never, + data: args.data, + } + : undefined, + toJSON: () => ({}), + } as AxiosError; +} + +describe('lib/client / parseApiError', function () { + it('decodes the canonical { error: { code, message, doc_url } } envelope', function () { + const err = parseApiError( + makeAxiosError({ + status: 404, + data: { + error: { + code: 'NOT_FOUND', + message: 'Alert not found', + doc_url: 'https://docs.formo.so/api/errors#not_found', + }, + }, + }), + ); + + expect(err.code).to.equal('NOT_FOUND'); + expect(err.docUrl).to.equal('https://docs.formo.so/api/errors#not_found'); + expect(err.status).to.equal(404); + expect(err.message).to.include('[NOT_FOUND] Alert not found'); + expect(err.message).to.include('https://docs.formo.so/api/errors#not_found'); + }); + + it('surfaces param when present', function () { + const err = parseApiError( + makeAxiosError({ + status: 400, + data: { + error: { + code: 'BAD_REQUEST', + message: 'Chain ID 999999 not supported', + doc_url: 'https://docs.formo.so/api/errors#bad_request', + param: 'chainId', + }, + }, + }), + ); + + expect(err.param).to.equal('chainId'); + expect(err.message).to.include('Param: chainId'); + }); + + it('preserves details on validation errors', function () { + const details = { + 'body.name': 'Required', + 'body.conditions.0.operator': 'Expected one of: gt, lt, eq', + }; + const err = parseApiError( + makeAxiosError({ + status: 400, + data: { + error: { + code: 'INVALID_VALIDATION_REQUEST', + message: 'Request validation failed', + doc_url: 'https://docs.formo.so/api/errors#invalid_validation_request', + details, + }, + }, + }), + ); + + expect(err.code).to.equal('INVALID_VALIDATION_REQUEST'); + expect(err.details).to.deep.equal(details); + }); + + it('falls back to axios message when the body has no error envelope', function () { + const err = parseApiError( + makeAxiosError({ + status: 401, + data: undefined, + message: 'Request failed with status code 401', + }), + ); + + expect(err.code).to.be.undefined; + expect(err.docUrl).to.be.undefined; + expect(err.message).to.equal('Request failed with status code 401'); + }); + + it('handles transport errors with no response', function () { + const err = parseApiError( + makeAxiosError({ + message: 'getaddrinfo ENOTFOUND api.formo.so', + code: 'ENOTFOUND', + }), + ); + + expect(err.code).to.be.undefined; + expect(err.status).to.be.undefined; + expect(err.transportCode).to.equal('ENOTFOUND'); + expect(err.message).to.equal('getaddrinfo ENOTFOUND api.formo.so'); + }); + + it('omits Param/Docs lines when absent', function () { + const err = parseApiError( + makeAxiosError({ + status: 500, + data: { + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Something went wrong', + }, + }, + }), + ); + + expect(err.message).to.equal('[INTERNAL_SERVER_ERROR] Something went wrong'); + expect(err.message).to.not.include('Param:'); + expect(err.message).to.not.include('Docs:'); + }); +});