> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formo.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Send Query

> Execute read-only ClickHouse SQL queries against your Formo analytics data warehouse via the API. Returns structured results for events, users, and sessions.

Run a read-only SQL query to read your data. See examples on the [Explorer page](/features/product-analytics/explore).

<Frame>
  <img src="https://mintcdn.com/formo/tr0ExPbiDnzvNT6Z/images/query-api-diagram.png?fit=max&auto=format&n=tr0ExPbiDnzvNT6Z&q=85&s=3cb50ba34ff1f9dd5677ba0c80bb04ee" alt="Query API architecture diagram showing your service sending SQL queries to the Query API and receiving data from the Data Warehouse" width="1090" height="522" data-path="images/query-api-diagram.png" />
</Frame>

## Authentication

Use a **Workspace API Key** with `query:read` permission. Send it in the `Authorization` header:

```bash theme={null}
Authorization: Bearer <your_workspace_api_key>
```

## Request

* Method: `POST`
* Path: `/v0/query`
* Body:

```json theme={null}
{
  "query": "SELECT * FROM events LIMIT 100"
}
```

Limits:

* Query must be a single `SELECT`/`WITH` statement (read-only).
* No comments or multiple statements.
* `LIMIT` is required and must be `<= 1000`.

## Response

`200 OK` returns rows plus pagination metadata reflecting the `LIMIT` / `OFFSET` you wrote into the SQL.

```json theme={null}
{
  "data": [
    {
      "event": "page",
      "address": "0x123...",
      "timestamp": "2025-01-20T10:00:00Z"
    }
  ],
  "total": 120,
  "limit": 100,
  "offset": 0,
  "has_more": true
}
```

| Field      | Type    | Description                              |
| ---------- | ------- | ---------------------------------------- |
| `data`     | array   | Result rows                              |
| `total`    | integer | Total rows before `LIMIT` was applied    |
| `limit`    | integer | `LIMIT` echoed from the SQL              |
| `offset`   | integer | `OFFSET` echoed from the SQL             |
| `has_more` | boolean | `true` if `total > offset + data.length` |

This endpoint uses `limit`/`offset` (not `page`/`size`) because the client controls pagination directly through the SQL query - Formo just echoes the values back.

## Errors

Branch on `error.code`; see [Errors](/api/errors) for the full reference.

* `400 BAD_REQUEST`: missing query, invalid SQL, `LIMIT` over 1000, or missing project id.
* `401 UNAUTHORIZED`: missing/invalid authorization header or API key.
* `403 FORBIDDEN`: API key lacks `query:read` scope.
* `404 NOT_FOUND`: project or read token not found.
* `429 TOO_MANY_REQUESTS`: per-workspace rate limit exceeded.
* `500 INTERNAL_SERVER_ERROR`: failure executing the query.


## OpenAPI

````yaml POST /v0/query
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.


    **Auth.** All endpoints require a workspace API key with the appropriate
    scopes (see `x-api-scopes`).


    **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.


    **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).


    **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: []
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)
paths:
  /v0/query:
    post:
      tags:
        - Query
      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.
      operationId: executeQuery
      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;
            `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'
components:
  responses:
    BadRequest:
      description: >-
        The request was rejected. `code` is either `INVALID_VALIDATION_REQUEST`
        (Zod schema mismatch; `details` carries a `{ fieldPath: message }` map)
        or `BAD_REQUEST` (semantic validation failure outside Zod, e.g.
        mismatched IDs, business-rule violations). Branch on `code`, not status,
        to tell the two apart.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            validation:
              summary: Zod schema mismatch
              value:
                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)
            semantic:
              summary: Semantic validation failure
              value:
                error:
                  code: BAD_REQUEST
                  message: Target board must be different from the current board
                  doc_url: https://docs.formo.so/api/errors#bad_request
    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
    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
  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, so 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
  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.

````