> ## 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 cohort LTV

> Person-grain cohort lifetime value. Each cohort carries one row per elapsed period with the revenue booked in that period, the revenue booked cumulatively, the cumulative revenue per acquired person, and how many of the cohort had paid by then. The parameters are identical to `/retention` on purpose: the same range and `granularity` select the same cohorts on both endpoints.

`cumulative_ltv` divides by every person ACQUIRED, payers and non-payers alike, so it compares directly against a blended acquisition cost. Revenue per payer is `cumulative_revenue` divided by `paying_persons`.

Realized revenue only. Nothing is extrapolated, and a cohort returns only the periods it has actually entered — a cohort acquired yesterday returns one or two rows, never a flat twelve-period curve. Each period carries `period_complete`, which is `false` for the in-flight period at the end of a young cohort; compare cohorts on complete periods only.

`by=channel` splits every cohort by the FIRST-TOUCH channel that acquired each person — the same nine buckets `/attribution` reports (`meta`, `google`, `tiktok`, `snapchat`, `email`, `referral`, `organic`, `direct`, `other`). One channel per person; credit is never shared across touches. The curves move under a `channels` array and the cohort's top-level `periods` array is absent, so a total row and a channel row can never be added together by mistake.

Persons whose acquisition is not mapped are returned in an `unattributed` bucket and are never dropped from the cohort. The person-to-channel mapping reaches about 365 days back from each person's first activity and is maintained going forward, so cohorts older than that read heavily `unattributed`. That is unknown acquisition, not a channel without customers.

Campaign-level and ad-level LTV do not exist. The breakdown is channel only.



## OpenAPI

````yaml /api-reference/read-api.yaml get /ltv
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:
  /ltv:
    get:
      tags:
        - Events and users
      summary: Get cohort LTV
      description: >-
        Person-grain cohort lifetime value. Each cohort carries one row per
        elapsed period with the revenue booked in that period, the revenue
        booked cumulatively, the cumulative revenue per acquired person, and how
        many of the cohort had paid by then. The parameters are identical to
        `/retention` on purpose: the same range and `granularity` select the
        same cohorts on both endpoints.


        `cumulative_ltv` divides by every person ACQUIRED, payers and non-payers
        alike, so it compares directly against a blended acquisition cost.
        Revenue per payer is `cumulative_revenue` divided by `paying_persons`.


        Realized revenue only. Nothing is extrapolated, and a cohort returns
        only the periods it has actually entered — a cohort acquired yesterday
        returns one or two rows, never a flat twelve-period curve. Each period
        carries `period_complete`, which is `false` for the in-flight period at
        the end of a young cohort; compare cohorts on complete periods only.


        `by=channel` splits every cohort by the FIRST-TOUCH channel that
        acquired each person — the same nine buckets `/attribution` reports
        (`meta`, `google`, `tiktok`, `snapchat`, `email`, `referral`, `organic`,
        `direct`, `other`). One channel per person; credit is never shared
        across touches. The curves move under a `channels` array and the
        cohort's top-level `periods` array is absent, so a total row and a
        channel row can never be added together by mistake.


        Persons whose acquisition is not mapped are returned in an
        `unattributed` bucket and are never dropped from the cohort. The
        person-to-channel mapping reaches about 365 days back from each person's
        first activity and is maintained going forward, so cohorts older than
        that read heavily `unattributed`. That is unknown acquisition, not a
        channel without customers.


        Campaign-level and ad-level LTV do not exist. The breakdown is channel
        only.
      operationId: get-ltv
      parameters:
        - name: start_date
          in: query
          description: >-
            Inclusive first cohort date, as `YYYY-MM-DD` or an ISO 8601 instant.
            Supply with `end_date`; omit both for the last 90 days of cohorts.
          schema:
            type: string
        - name: end_date
          in: query
          description: Inclusive last cohort date, as `YYYY-MM-DD` or an ISO 8601 instant.
          schema:
            type: string
        - name: granularity
          in: query
          description: Cohort bucket and period unit. Weeks start Monday.
          schema:
            type: string
            enum:
              - day
              - week
              - month
            default: week
        - name: max_periods
          in: query
          description: >-
            Highest period index returned, counted in `granularity` units from
            period 0.
          schema:
            type: integer
            minimum: 1
            maximum: 24
            default: 12
        - name: by
          in: query
          description: >-
            `total` returns one curve per cohort. `channel` returns one curve
            per (cohort, first-touch acquisition channel), plus an
            `unattributed` bucket. Any other value is a `400` rather than a
            silent fall back to the total.
          schema:
            type: string
            enum:
              - total
              - channel
            default: total
      responses:
        '200':
          $ref: '#/components/responses/Ltv'
        '400':
          $ref: '#/components/responses/Error'
        '503':
          $ref: '#/components/responses/Error'
components:
  responses:
    Ltv:
      description: >-
        Successful response. Realized revenue per acquired person: nothing is
        predicted, and only periods a cohort has entered are returned.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/LtvResponse'
    Error:
      description: Error response.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
  schemas:
    LtvResponse:
      type: object
      additionalProperties: true
      required:
        - cohorts
        - granularity
        - date_range
        - complete
      properties:
        cohorts:
          type: array
          description: >-
            One entry per cohort, oldest first. Empty when no cohort falls in
            the range.
          items:
            type: object
            additionalProperties: true
            required:
              - cohort_date
              - cohort_size
            properties:
              cohort_date:
                type: string
                description: Bucket start, `YYYY-MM-DD`. Monday for weekly cohorts.
              cohort_size:
                type: integer
                description: >-
                  Distinct persons whose all-time first activity falls in this
                  bucket. The `cumulative_ltv` denominator — every acquired
                  person, not every payer. Under `by=channel` this is the SUM of
                  the per-channel denominators; every count is an estimate, so
                  it can differ by a hair from the single count `by=total`
                  reports for the same cohort. Revenue sums exactly.
              periods:
                description: >-
                  The cohort's own curve. Present under `by=total`; ABSENT under
                  `by=channel`, where the curves hang off `channels` instead.
                allOf:
                  - $ref: '#/components/schemas/LtvPeriods'
              channels:
                type: array
                description: >-
                  Present only under `by=channel`. One entry per channel that
                  acquired someone in this cohort, plus `unattributed` for
                  persons with no mapped acquisition — report that bucket as its
                  own line rather than folding it into a channel.
                items:
                  type: object
                  additionalProperties: true
                  required:
                    - channel
                    - cohort_size
                  properties:
                    channel:
                      type: string
                      description: >-
                        One of `meta`, `google`, `tiktok`, `snapchat`, `email`,
                        `referral`, `organic`, `direct`, `other`, or
                        `unattributed`.
                    cohort_size:
                      type: integer
                      description: >-
                        Persons in this cohort acquired through this channel —
                        the denominator for THIS curve, not for the cohort.
                    periods:
                      allOf:
                        - $ref: '#/components/schemas/LtvPeriods'
        granularity:
          type: string
          enum:
            - day
            - week
            - month
        date_range:
          type: object
          additionalProperties: true
          properties:
            start:
              type: string
              description: Resolved first cohort date, `YYYY-MM-DD`.
            end:
              type: string
              description: Resolved last cohort date, `YYYY-MM-DD`.
        complete:
          type: boolean
          description: >-
            Always `true` on a 200. A failed read is a `503`, never a flat zero
            curve.
        meta:
          type: object
          additionalProperties: true
          properties:
            grain:
              type: string
              description: Always `person`.
            metric:
              type: string
              description: Always `cumulative_revenue_per_acquired_person`.
            currency:
              type: string
              description: Always `USD`.
            breakdown:
              type: string
              enum:
                - total
                - channel
              description: Which shape the cohorts carry.
            attribution_model:
              type: string
              description: >-
                `first_touch`, on `by=channel` only. One channel per person;
                credit is never spread across touches the way `/attribution`'s
                linear and time-decay models spread it.
            unattributed_bucket:
              type: string
              description: >-
                `unattributed`, on `by=channel` only. The channel name used for
                persons whose acquisition is not mapped.
            acquisition_lookback_days:
              type: integer
              description: >-
                On `by=channel` only. How far back the person-to-channel mapping
                reaches from each person's first activity. Cohorts older than
                this read heavily `unattributed`.
            max_periods:
              type: integer
            cohort_count:
              type: integer
            period_unit:
              type: string
            cohort_basis:
              type: string
              description: >-
                `all_time_first_seen`. Cohorts are never scoped to the requested
                range.
            limitation:
              type: string
              description: >-
                States in prose that this is realized rather than predicted
                revenue, and under `by=channel` that the breakdown is
                first-touch, that `unattributed` means unknown acquisition
                rather than a channel with no customers, and how far back the
                mapping reaches.
    LtvPeriods:
      type: array
      description: >-
        Ascending by `period_index` and dense from 0: a period with no revenue
        is present with `period_revenue` `0` and an unchanged cumulative,
        because a gap in an LTV curve would read as a gap in the money. Periods
        that have not started yet are absent entirely.
      items:
        type: object
        additionalProperties: true
        properties:
          period_index:
            type: integer
            description: >-
              Elapsed `granularity` units since the cohort bucket. 0 is the
              acquisition period.
          period_revenue:
            type: number
            description: USD booked in this period alone.
          cumulative_revenue:
            type: number
            description: >-
              USD booked from period 0 through this period. Never decreases
              unless refunds do.
          cumulative_ltv:
            type: number
            description: >-
              `cumulative_revenue` divided by the `cohort_size` this curve
              belongs to — revenue per acquired person. Under `by=channel` that
              denominator is the CHANNEL's size, which is what a channel-level
              acquisition-cost comparison needs.
          paying_persons:
            type: integer
            description: >-
              Members of this curve who had recorded positive revenue by this
              period. An exact count, not an estimate, and never above its
              `cohort_size`.
          period_complete:
            type: boolean
            description: >-
              `false` for the in-flight period at the end of a young cohort's
              curve. Compare cohorts on complete periods only.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Datalyr Agent key (dk_agent_…)

````