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

# Get Funnel

> Multi-step conversion funnel with per-step user counts, conversion ratios, and median time-to-convert.

Returns the same funnel data the dashboard renders on the Funnels page. For an ordered list of step specs, you get one row per step with the unique-user count, conversion ratios against step 1 and the previous step, drop-off ratio, and median time-to-convert.

Use `funnel_type=closed` (default) for ordered, in-window conversions (the default that powers the dashboard) or `funnel_type=open` to count whoever fired step k regardless of order - open mode also returns a `dropped_off_users` column.

Set `group_by` to a dimension (`device`, `browser`, `os`, `location`, `referrer`, `ref`, `builder_codes`, or any UTM column) to group each step by per-user attribution; `limit` controls how many categories are kept (default `5`) before bucketing the rest as `Others`. Attribution defaults to first-touch; pass `attribution=last_touch` to bucket each user by their latest value instead.

<Note>
  The `group_by`, `limit`, and `attribution` params replace the former `breakdown` and `breakdown_top_n` params. Direct callers using `?breakdown=` / `?breakdown_top_n=` must migrate - there are no aliases.
</Note>

## Defining steps

The `steps` query parameter is a JSON-encoded array of 2 to 10 step specs. Each step is `{type, event, name, filters?: [...]}`:

* **`type`** - event type (`event` for page views, `track` for custom events, `transaction`, `signature`, `decoded_log`).
* **`event`** - the event name to match.
* **`name`** - a unique step id. Use `<event>::<index>` (e.g. `"connect::1"`) so the same event re-used at multiple steps can be told apart in the response.
* **`filters`** - optional `[{field, op, value}]`.
  * **Operators:**
    * `eq`
    * `neq`
    * `in`
    * `nin`
    * `gt`
    * `lt`
    * `gte`
    * `lte`
    * `startsWith`
    * `endsWith`
    * `contains`
    * `notEmpty` / `isEmpty` - value-less existence checks (the `value` is ignored; rejected on the numeric event columns `volume`/`revenue`/`points`)
  * Canonical tokens only: the retired long-form spellings (`equals`, `notEquals`, `notIn`, `includes`, `greater`, `greaterOrEqual`, `less`, `lessOrEqual`) are rejected with a `400` naming the token.
  * For `in` / `nin`, pass the values as a `|`-separated string in `value` (e.g. `"ethereum|polygon|base"`).
  * **`field`** may target a standard event column or a JSON property on `properties`.

## Example

```bash theme={null}
curl -G "https://api.formo.so/v0/funnel" \
  -H "Authorization: Bearer $FORMO_API_KEY" \
  --data-urlencode "dateFrom=2026-04-01" \
  --data-urlencode "dateTo=2026-04-30" \
  --data-urlencode "window_seconds=86400" \
  --data-urlencode 'steps=[
    {"type":"event","event":"page","name":"page::0","filters":[]},
    {"type":"track","event":"connect","name":"connect::1","filters":[]},
    {"type":"track","event":"swap","name":"swap::2","filters":[{"field":"chain","op":"eq","value":"ethereum"}]}
  ]'
```


## OpenAPI

````yaml GET /v0/funnel
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.** Every endpoint on `api.formo.so` requires a workspace API key with
    the appropriate scopes (see `x-api-scopes`). The one exception is `POST
    /v0/raw_events`, which runs on `events.formo.so` and authenticates with the
    project SDK write key instead.


    **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.** Non-2xx responses from `api.formo.so` use the `Error` envelope,
    with two exceptions: some rate-limit rejections reply in plain text, and
    `POST /v0/raw_events` returns a plain `{ "error": "..." }` object. Treat the
    HTTP status as authoritative and parse defensively: `{ 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 write requests to
    alerts, boards, charts, contracts, segments, and import to make retries
    safe; the response is cached for 24 h and replayed on duplicate keys.
    Profile writes and `POST /v0/query` do not support it.
  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/funnel:
    get:
      tags:
        - Query
      summary: Get multi-step conversion funnel
      description: >-
        Multi-step conversion funnel. For an ordered list of step specs, returns
        one row per step with the unique-user count, conversion ratios against
        step 1 and the previous step, drop-off ratio, and median time-to-convert
        (from-previous and from-start, in seconds).


        **Closed vs open:** `funnel_type=closed` (default) requires the user to
        fire the steps in order within `window_seconds`. `funnel_type=open`
        counts whoever fired step k regardless of order, and adds a
        `dropped_off_users` column for each step.


        **Breakdown:** when `group_by` is set to a column from the breakdown
        allowlist (`device`, `browser`, `os`, `location`, `referrer`, `ref`,
        `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`,
        `builder_codes`), the response gains a `breakdown` column and is grouped
        per (step, top-N category + 'Others', up to `limit` which defaults to
        5). First-touch attribution is used by default; pass
        `attribution=last_touch` to bucket by each user's latest value.


        **Steps:** the `steps` query param is a JSON-encoded array of 2 to 10
        step specs of shape `{type, event, name, filters?: [{field, op,
        value}]}`. Use `name` (e.g. `"page::0"`) as a unique step id so the same
        event re-used at different steps can be disambiguated in the response.
        `op` accepts `eq | neq | in | nin | gt | lt | gte | lte | startsWith |
        endsWith | contains | notEmpty | isEmpty` (canonical tokens only; the
        retired long-form spellings are rejected, `notEmpty`/`isEmpty` are
        value-less existence checks, rejected on the numeric event columns
        `volume`/`revenue`/`points`). `field` may target a standard event column
        or a JSON property on `properties`.
      operationId: getAnalyticsFunnel
      parameters:
        - name: date_from
          in: query
          required: true
          schema:
            type: string
            format: date
          description: >-
            Inclusive ISO date for the start of the funnel window (YYYY-MM-DD).
            The events scan extends past `date_to` by `window_seconds` so a user
            who fires step 1 just before `date_to` can still complete the funnel
            inside their conversion window.
          example: '2026-04-01'
        - name: date_to
          in: query
          required: true
          schema:
            type: string
            format: date
          description: >-
            Inclusive ISO date for the end of the start-event window
            (YYYY-MM-DD).
          example: '2026-04-30'
        - name: steps
          in: query
          required: true
          schema:
            type: string
          description: >-
            JSON-encoded array of 2 to 10 step specs. Each step is `{type,
            event, name, filters?: [{field, op, value}]}`. `type` is the event
            type (e.g. `event` for page views, `track` for custom events,
            `transaction`, `signature`, `decoded_log`). `event` is the event
            name. `name` is the unique step id (use `"<event>::<index>"` to
            disambiguate repeated events).


            **Filter operators:** `eq`, `neq`, `in`, `nin`, `gt`, `lt`, `gte`,
            `lte`, `startsWith`, `endsWith`, `contains`, `notEmpty`, `isEmpty`
            (canonical tokens only; the retired long-form spellings
            `equals`/`greater`/`includes`/… are rejected, `notEmpty`/`isEmpty`
            are value-less existence checks, rejected on numeric event columns
            `volume`/`revenue`/`points`). For `in`/`nin`, pass the values as a
            `|`-separated string in `value` (e.g. `"ethereum|polygon|base"`).


            **Standard columns** (rendered via direct column access): `origin`,
            `device`, `browser`, `os`, `location`, `referrer`, `direct`, `ref`,
            `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
            `utm_term`, `builder_codes`, `version`, `locale`, `timezone`,
            `page_path`. Anything else is treated as a JSON property and read
            from `properties` via `JSONExtractString` (or `JSONExtractFloat` for
            numeric comparators).
          examples:
            simple:
              summary: 'Simple 3-step funnel: page → connect → swap'
              value: >-
                [{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]},{"type":"track","event":"swap","name":"swap::2","filters":[]}]
            standardColumnFilter:
              summary: Step filter on a standard column (mobile-only first step)
              value: >-
                [{"type":"event","event":"page","name":"page::0","filters":[{"value":"mobile","field":"device","op":"eq"}]},{"type":"track","event":"signup","name":"signup::1","filters":[]}]
            utmSourceIn:
              summary: >-
                Step filter using `in` on a standard column (UTM-attributed
                sessions)
              value: >-
                [{"type":"event","event":"page","name":"page::0","filters":[{"value":"twitter|farcaster|telegram","field":"utm_source","op":"in"}]},{"type":"track","event":"connect","name":"connect::1","filters":[]}]
            pagePathStartsWith:
              summary: Step filter using `startsWith` on `page_path`
              value: >-
                [{"type":"event","event":"page","name":"page::0","filters":[{"value":"/swap","field":"page_path","op":"startsWith"}]},{"type":"track","event":"swap","name":"swap::1","filters":[]}]
            jsonPropertyFilter:
              summary: Step property filter (JSON property `provider_name=MetaMask`)
              value: >-
                [{"type":"track","event":"connect","name":"connect::0","filters":[{"value":"MetaMask","field":"provider_name","op":"eq"}]},{"type":"track","event":"swap","name":"swap::1","filters":[]}]
            jsonNumericFilter:
              summary: >-
                Step property filter using a numeric comparator (price > 100 →
                JSONExtractFloat)
              value: >-
                [{"type":"track","event":"swap","name":"swap::0","filters":[{"value":"100","field":"price","op":"gt"}]},{"type":"track","event":"refund","name":"refund::1","filters":[]}]
            combinedFilters:
              summary: Step with multiple filters (standard column + JSON property)
              value: >-
                [{"type":"event","event":"page","name":"page::0","filters":[{"value":"desktop","field":"device","op":"eq"},{"value":"/earn","field":"page_path","op":"startsWith"}]},{"type":"track","event":"deposit","name":"deposit::1","filters":[{"value":"1","field":"chain_id","op":"eq"},{"value":"USDC|USDT|DAI","field":"asset","op":"in"}]}]
            transactionStatus:
              summary: >-
                Onchain step (`transaction` type, success-only via JSON
                property)
              value: >-
                [{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"transaction","event":"swap","name":"swap::1","filters":[{"value":"success","field":"status","op":"eq"}]}]
        - name: window_seconds
          in: query
          schema:
            type: integer
            default: 7200
            minimum: 1
          description: >-
            Conversion window length in seconds. Defaults to 7,200 (2 hours).
            For closed funnels this is the in-order completion cap; for both
            variants the events scan is extended by this amount past `date_to`.
          example: 86400
        - name: funnel_type
          in: query
          schema:
            type: string
            enum:
              - closed
              - open
            default: closed
          description: >-
            `closed` (default): ordered, in-window. `open`: unordered per-step;
            emits an extra `dropped_off_users` column.
        - name: group_by
          in: query
          schema:
            type: string
            enum:
              - device
              - browser
              - os
              - location
              - referrer
              - ref
              - utm_source
              - utm_medium
              - utm_campaign
              - utm_content
              - utm_term
              - builder_codes
          description: >-
            Optional dimension to break each step down by. Defaults to
            first-touch attribution; pass `attribution=last_touch` to bucket by
            each user's latest value. When set, the response gains a `breakdown`
            column.
        - name: limit
          in: query
          schema:
            type: integer
            default: 5
            minimum: 1
          description: >-
            Top-N breakdown categories to keep (by user count), used only with
            `group_by`. Remaining categories are bucketed as `Others`. Defaults
            to 5.
        - name: attribution
          in: query
          schema:
            type: string
            enum:
              - first_touch
              - last_touch
            default: first_touch
          description: >-
            Per-user attribution for the `group_by` dimension. `first_touch`
            (default) buckets each user by their earliest value; `last_touch` by
            their latest. Ignored unless `group_by` is set.
      responses:
        '200':
          description: Per-step funnel results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsResponse'
              example:
                meta:
                  - name: step
                    type: UInt64
                  - name: event
                    type: String
                  - name: users
                    type: UInt64
                  - name: total
                    type: UInt64
                  - name: conversion_from_start
                    type: Nullable(Float64)
                  - name: conversion_from_previous
                    type: Nullable(Float64)
                  - name: dropoff_from_previous
                    type: Nullable(Float64)
                  - name: median_seconds_from_previous
                    type: Nullable(Float64)
                  - name: median_seconds_from_start
                    type: Nullable(Float64)
                data:
                  - step: 1
                    event: page::0
                    users: 1240
                    total: 1240
                    conversion_from_start: 1
                    conversion_from_previous: null
                    dropoff_from_previous: null
                    median_seconds_from_previous: null
                    median_seconds_from_start: null
                  - step: 2
                    event: connect::1
                    users: 482
                    total: 482
                    conversion_from_start: 0.3887
                    conversion_from_previous: 0.3887
                    dropoff_from_previous: 0.6113
                    median_seconds_from_previous: 38
                    median_seconds_from_start: 38
                  - step: 3
                    event: swap::2
                    users: 187
                    total: 187
                    conversion_from_start: 0.1508
                    conversion_from_previous: 0.388
                    dropoff_from_previous: 0.612
                    median_seconds_from_previous: 124
                    median_seconds_from_start: 162
                rows: 3
                rows_before_limit_at_least: 3
        '400':
          description: Invalid `steps`, `window_seconds`, or other query parameters
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RestApiError'
        '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'
components:
  schemas:
    AnalyticsResponse:
      type: object
      description: >-
        Analytics endpoint response. The `data` array contains the rows; the
        exact row shape depends on the endpoint. `meta` carries column type
        information for rendering, `rows` is the row count, and `statistics`
        holds query timing metadata.
      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
    RestApiError:
      $ref: '#/components/schemas/Error'
      description: >-
        Deprecated alias for `Error`. Existing endpoint specs reference this
        name; new specs should reference `Error` directly.
    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.

````