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

# Get a conversion funnel

> Ordered conversion funnel over 2 to 5 event names. Each returned step carries the visitors who completed every step up to and including it, in order, with all of them inside `window_hours` of the first step. Rates are given against step 1 and against the previous step.

Two limits are inherent to the source and are reported, not hidden. The funnel is VISITOR-grain: the source carries no person id, so one person on two devices counts as two visitors and cannot complete a single funnel. And the source retains 90 days: an earlier `start_date` is pulled forward to that floor and `meta.from_clamped` is `true`, while a range entirely behind the floor is a `400` rather than a funnel of zeros. A step taken after `end_date` is not counted even when it falls inside `window_hours`, so widen `end_date` to close that edge.



## OpenAPI

````yaml /api-reference/read-api.yaml get /funnel
openapi: 3.1.0
info:
  title: Datalyr API
  version: 1.0.0
  description: >-
    Read analytics, attribution, revenue and workspace data from Datalyr, and
    change conversion rules, trackable links and container-script drafts under
    an explicit write scope.
servers:
  - url: https://api.datalyr.com/v1
security:
  - bearerAuth: []
tags:
  - name: Analytics
  - name: Events and users
  - name: Attribution and revenue
  - name: Workspace
  - name: Signup and onboarding
    description: >-
      The two routes an agent needs to take a prospect from nothing to a working
      install: one to start a signup, one to check what is still missing.


      **Agents sign up. Humans pay.** `POST /signup` is the only unauthenticated
      route in this API, and the only thing it does is send a verification
      email. It does not create an account, a workspace, a plan or an API key,
      and no parameter makes it. The person who reads that email verifies it,
      chooses a plan, and completes an Autumn-hosted Stripe checkout in their
      own browser. Only then does a workspace exist, and only then can a key be
      minted. An agent never handles a payment credential and cannot complete a
      checkout on anyone's behalf.


      **Throttled instead of captcha-gated.** A captcha proves a human is
      present, which is the opposite of this endpoint's premise. `POST /signup`
      is capped per client IP and per email address instead. Exceeding either
      returns `429` with a `Retry-After`; the limiter failing returns `503`, and
      never lets a request through.


      **No account-existence oracle.** An accepted `POST /signup` always answers
      `{"status": "verification_sent"}`, whether or not the address already has
      a Datalyr account. Do not infer anything about the address from the
      response.


      **`GET /onboarding` is the checklist.** It needs an Agent key, so it only
      works once a workspace exists — that is, after payment. Read `next_steps`
      and drive the human through them; re-read it to confirm each one landed.
  - name: Documentation
    description: >-
      Search the published Datalyr documentation. The only routes here that read
      content outside your workspace.
  - name: Exports
    description: >-
      Ask for a file instead of a page. An export runs asynchronously and
      produces a CSV or NDJSON object you download with a temporary link.


      **It is a job, not a response.** `POST /exports` returns `202` with a job
      id and no data — an export can scan a year of events and take minutes,
      which is longer than any HTTP request should live. Poll `GET
      /exports/{exportId}` until `status` is `completed`, then read
      `download.url`.


      **Scope.** `datalyr:read`, the same scope every other read on this page
      needs — including the original per-workspace Agent key. An export returns
      rows you can already fetch through `GET /events` or `GET /users`; it does
      not change anything you configured, so it does not require a write scope.


      **The download link is temporary and so is the file.** `download.url` is a
      signed URL valid for one hour from the moment you polled; poll again for a
      fresh one. The file itself is deleted seven days after the export was
      created (`expires_at`), after which the job reads `expired` and must be
      re-run. Treat the URL as a credential: anyone holding it can download the
      file until it expires.


      **Limits.** One export covers at most 366 days and one million rows (users
      exports: 100,000, the bound of the person-grain query). Hitting the row
      cap sets `truncated: true` on the finished job rather than failing it —
      you get the rows that fit and are told the range is not fully covered. A
      workspace may have five exports queued or running at once.


      **What the files contain.** `events` mirrors `GET /events` column for
      column; `users` mirrors `GET /users`; `attribution` is the channel-grain
      summary from `GET /attribution`, one row per channel plus an
      `unattributed` row. Filters are an allowlist per type — an unrecognized
      filter is rejected rather than ignored, so you never receive more rows
      than you asked for without being told.
  - name: Write API
    description: >-
      Create, change and delete conversion rules and trackable links, reverse
      any of it, and propose container scripts for a human to approve.


      **Two tiers, and the difference is deliberate.** Conversion rules and
      trackable links are direct writes. Container scripts are **propose-only**:
      `POST /scripts/proposals` records a draft and nothing else. No scope
      publishes a script, and there is no endpoint that approves one — an owner
      or admin approves it in the Datalyr dashboard, and only then does the
      script exist. A container script is arbitrary JavaScript injected into
      every page of the customer's site, so a key that could publish one would
      turn a leaked key into a supply-chain compromise rather than a data-access
      problem.


      **Scopes.** These routes need a named API key carrying an explicit write
      scope: `datalyr:write:rules` for conversion rules, `datalyr:write:links`
      for trackable links, `datalyr:propose:scripts` to draft a container
      script. The original per-workspace Agent key does not have them and never
      will — it resolves to `datalyr:read` alone, so it reads every route in
      this spec and writes none of them. Mint a named key with the scopes you
      want; a request without the right one returns `403` with `code:
      insufficient_scope` and the `required_scope` it wanted.


      **Dry run.** Every mutation accepts `dry_run`. A dry run validates the
      request, reads whatever it needs to describe the outcome, and returns the
      exact change it would make — including, for a conversion rule, which
      platform, ad account and pixel the rule would send conversions to. It
      writes nothing: no row, no audit entry, no edge redirect, no idempotency
      record. It defaults to `false` over raw HTTP, because a `DELETE` you sent
      is a `DELETE` you meant. The MCP write tools default it to `true`.


      **Idempotency.** `POST` accepts an `Idempotency-Key` header. The first
      request with a given key is performed and its response kept for 24 hours;
      an identical retry returns that same response with `Idempotent-Replay:
      true` rather than creating a second resource. The record covers the
      request body too, so reusing a key with different content is a new
      request, not a replay. `PATCH` and `DELETE` are keyed on a resource id and
      need no header.


      **Audit and undo.** Every applied write records what changed, who changed
      it and the state it replaced, in the same transaction as the change. `GET
      /audit` lists those entries and `POST /audit/{auditId}/undo` puts the
      resource back. Undo refuses rather than guessing: it will not reverse a
      create (call `DELETE`, which previews and audits like any other write),
      will not run twice on one entry, and will not restore over a resource that
      has since been deleted or re-created.


      **Rate limit.** Mutations spend a separate, tighter budget than reads: 20
      per minute per key, including dry runs.
paths:
  /funnel:
    get:
      tags:
        - Events and users
      summary: Get a conversion funnel
      description: >-
        Ordered conversion funnel over 2 to 5 event names. Each returned step
        carries the visitors who completed every step up to and including it, in
        order, with all of them inside `window_hours` of the first step. Rates
        are given against step 1 and against the previous step.


        Two limits are inherent to the source and are reported, not hidden. The
        funnel is VISITOR-grain: the source carries no person id, so one person
        on two devices counts as two visitors and cannot complete a single
        funnel. And the source retains 90 days: an earlier `start_date` is
        pulled forward to that floor and `meta.from_clamped` is `true`, while a
        range entirely behind the floor is a `400` rather than a funnel of
        zeros. A step taken after `end_date` is not counted even when it falls
        inside `window_hours`, so widen `end_date` to close that edge.
      operationId: get-funnel
      parameters:
        - name: steps
          in: query
          required: true
          description: >-
            Comma-separated event names in funnel order, 2 to 5 of them. Empty
            names are rejected rather than dropped. Use `/event-names` to get
            the names this workspace tracks; an untracked name yields zero
            visitors, not an error.
          schema:
            type: string
        - name: window_hours
          in: query
          description: >-
            Conversion window: every step must occur within this many hours of
            the first step. The maximum equals the source's 90-day retention.
          schema:
            type: integer
            minimum: 1
            maximum: 2160
            default: 168
        - name: start_date
          in: query
          description: >-
            Inclusive first event date, as `YYYY-MM-DD` or an ISO 8601 instant.
            Supply with `end_date`; omit both for the last 30 days. Clamped to
            90 days ago.
          schema:
            type: string
        - name: end_date
          in: query
          description: Inclusive last event date, as `YYYY-MM-DD` or an ISO 8601 instant.
          schema:
            type: string
      responses:
        '200':
          $ref: '#/components/responses/Funnel'
        '400':
          $ref: '#/components/responses/Error'
        '503':
          $ref: '#/components/responses/Error'
components:
  responses:
    Funnel:
      description: >-
        Successful response. Visitor-grain, last 90 days only: a person on two
        devices is two visitors, and an earlier start is clamped with
        `meta.from_clamped`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/FunnelResponse'
    Error:
      description: Error response.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
  schemas:
    FunnelResponse:
      type: object
      additionalProperties: true
      required:
        - steps
        - date_range
        - complete
      properties:
        steps:
          type: array
          description: >-
            One entry per requested step, ascending by `step_index`. Always as
            many entries as `steps` named — a step nobody reached is `0`, not
            absent.
          items:
            type: object
            additionalProperties: true
            required:
              - step_index
              - step_name
              - visitors
            properties:
              step_index:
                type: integer
                description: 1-based position in the requested step list.
              step_name:
                type: string
                description: The event name requested for this step.
              visitors:
                type: integer
                description: >-
                  Visitors who completed every step through this one, in order,
                  inside `window_hours` of step 1.
              conversion_rate_from_first:
                type: number
                description: '`visitors` divided by step 1 `visitors`, between 0 and 1.'
              conversion_rate_from_previous:
                type: number
                description: >-
                  `visitors` divided by the previous step's `visitors`, between
                  0 and 1. Always `1` at step 1.
        date_range:
          type: object
          additionalProperties: true
          properties:
            start:
              type: string
              description: Resolved first event date, `YYYY-MM-DD`, after the 90-day clamp.
            end:
              type: string
              description: Resolved last event date, `YYYY-MM-DD`.
        complete:
          type: boolean
          description: >-
            Always `true` on a 200. A failed read is a `503`, never a funnel of
            zeros.
        meta:
          type: object
          additionalProperties: true
          properties:
            grain:
              type: string
              description: Always `visitor` in this version.
            window_hours:
              type: integer
            step_names:
              type: array
              items:
                type: string
              description: The requested step names in order.
            step_count:
              type: integer
            from_clamped:
              type: boolean
              description: >-
                `true` when `start_date` was pulled forward to the 90-day
                retention floor.
            start_date_requested:
              type: string
              description: >-
                Present only when `from_clamped` is `true`; the range originally
                asked for.
            max_lookback_days:
              type: integer
              description: Always `90` — the source retention.
            limitation:
              type: string
              description: States the visitor-grain and retention scope in prose.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Datalyr Agent key (dk_agent_…)

````